one-code-extension 0.5.0 → 0.6.0

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.
Files changed (95) hide show
  1. package/README.md +1 -1
  2. package/docs/guide/differences-from-claude-code.md +9 -6
  3. package/docs/guide/installation.md +3 -4
  4. package/docs/guide/troubleshooting.md +1 -1
  5. package/extensions/auto-mode/denials.ts +3 -2
  6. package/extensions/background/cron.ts +127 -0
  7. package/extensions/background/index.ts +135 -47
  8. package/extensions/background/loop-fire.ts +39 -2
  9. package/extensions/background/switch-guard.ts +10 -6
  10. package/extensions/background/wakeup.ts +21 -2
  11. package/extensions/bash/index.ts +8 -30
  12. package/extensions/btw/index.ts +32 -18
  13. package/extensions/cache-marks/index.ts +42 -0
  14. package/extensions/claude-context/index.ts +157 -172
  15. package/extensions/compaction/index.ts +11 -8
  16. package/extensions/context-budget/budget.ts +16 -2
  17. package/extensions/context-budget/index.ts +15 -14
  18. package/extensions/context-management/index.ts +11 -28
  19. package/extensions/effort/index.ts +70 -7
  20. package/extensions/file-tracker/index.ts +111 -44
  21. package/extensions/file-tracker/replay.ts +76 -49
  22. package/extensions/file-tracker/restore.ts +12 -9
  23. package/extensions/file-tracker/tracker.ts +14 -1
  24. package/extensions/hooks/index.ts +60 -76
  25. package/extensions/hooks/prompt-context.ts +59 -0
  26. package/extensions/hooks/trust.ts +13 -7
  27. package/extensions/ledger/index.ts +46 -0
  28. package/extensions/ledger/wire.ts +154 -0
  29. package/extensions/lib/agent-cron.ts +17 -1
  30. package/extensions/lib/agent-loader.ts +3 -2
  31. package/extensions/lib/anthropic-auth.ts +98 -0
  32. package/extensions/lib/child-extensions.ts +14 -4
  33. package/extensions/lib/context-facts.ts +75 -56
  34. package/extensions/lib/covered-messages.ts +23 -0
  35. package/extensions/lib/deferred.ts +152 -12
  36. package/extensions/lib/ledger/fold-memo.ts +49 -0
  37. package/extensions/lib/ledger/fold.ts +265 -0
  38. package/extensions/lib/ledger/layout.ts +72 -0
  39. package/extensions/lib/ledger/plan.ts +102 -0
  40. package/extensions/lib/ledger/records.ts +276 -0
  41. package/extensions/lib/ledger/state.ts +47 -0
  42. package/extensions/lib/ledger/validate.ts +141 -0
  43. package/extensions/lib/ledger/wire.ts +632 -0
  44. package/extensions/lib/local-command.ts +24 -6
  45. package/extensions/lib/model-catalog/huggingface.json +5 -1
  46. package/extensions/lib/model-catalog/models-dev.json +10535 -10299
  47. package/extensions/lib/model-catalog/openrouter.json +191 -393
  48. package/extensions/lib/notifications.ts +57 -17
  49. package/extensions/lib/pi-version.ts +25 -8
  50. package/extensions/lib/plan-mode-channels.ts +29 -7
  51. package/extensions/lib/previous-mark.ts +101 -0
  52. package/extensions/lib/process-state.ts +48 -0
  53. package/extensions/lib/process-tree.ts +3 -2
  54. package/extensions/lib/reminders.ts +327 -621
  55. package/extensions/lib/request-replay.ts +186 -78
  56. package/extensions/lib/side-call.ts +11 -0
  57. package/extensions/lib/subagent-handback.ts +0 -1
  58. package/extensions/lib/tool-additions.ts +2 -22
  59. package/extensions/lib/turn-budget-layout.ts +18 -2
  60. package/extensions/lsp/index.ts +6 -8
  61. package/extensions/mcp/announce.ts +63 -98
  62. package/extensions/mcp/index.ts +51 -90
  63. package/extensions/mcp/trust.ts +13 -6
  64. package/extensions/model-default/index.ts +86 -19
  65. package/extensions/permissions/index.ts +157 -48
  66. package/extensions/permissions/modes.ts +5 -0
  67. package/extensions/plan-mode/index.ts +142 -45
  68. package/extensions/plan-mode/run-state.ts +26 -0
  69. package/extensions/plugins/index.ts +4 -1
  70. package/extensions/powershell/index.ts +2 -0
  71. package/extensions/recap/index.ts +11 -14
  72. package/extensions/search-tools/index.ts +2 -0
  73. package/extensions/skill/announce.ts +30 -0
  74. package/extensions/skill/index.ts +65 -48
  75. package/extensions/skill/invoke.ts +0 -67
  76. package/extensions/subagents/agents.ts +30 -8
  77. package/extensions/subagents/fork-cache.ts +9 -3
  78. package/extensions/subagents/index.ts +180 -107
  79. package/extensions/subagents/outcome.ts +3 -0
  80. package/extensions/subagents/parent-mode.ts +50 -0
  81. package/extensions/subagents/pending-claim.ts +18 -1
  82. package/extensions/subagents/runner.ts +29 -2
  83. package/extensions/subagents/runs.ts +5 -0
  84. package/extensions/system-prompt/index.ts +40 -6
  85. package/extensions/system-prompt/template.ts +10 -1
  86. package/extensions/system-reminder/index.ts +174 -202
  87. package/extensions/tool-search/index.ts +241 -167
  88. package/extensions/workflow/agent-session.ts +4 -1
  89. package/extensions/workflow/index.ts +16 -4
  90. package/extensions/workflow/run-manager.ts +4 -0
  91. package/extensions/worktree/index.ts +50 -14
  92. package/package.json +5 -2
  93. package/extensions/lib/context-stack.ts +0 -81
  94. package/extensions/subagents/announce.ts +0 -69
  95. package/extensions/tool-search/announce.ts +0 -58
@@ -20,26 +20,72 @@
20
20
  */
21
21
 
22
22
  import type { Message } from "@earendil-works/pi-ai";
23
+ import { settleSystemMessages } from "./ledger/layout.ts";
24
+ import { isObject } from "./ledger/records.ts";
25
+ import { appendPoint, isToolsItem, leadingToolsItem, listKey, loadedInList, preludeLength, responsesDeclarations, stripMarkers, toolName, toolsItemFor } from "./ledger/wire.ts";
23
26
  import { estimateMessageTokens } from "./pi-ai-estimate.ts";
24
27
 
25
28
  /** The compaction extension publishes a RequestCapture here, or undefined when none is valid. */
26
29
  export const LAST_REQUEST_CHANNEL = "one-code:last-request";
27
30
 
28
31
  /** The provider APIs whose request body can be extended by appending to one list. */
29
- export type ReplayApi = "anthropic-messages" | "openai-completions" | "openai-responses";
32
+ export type ReplayApi =
33
+ | "anthropic-messages"
34
+ | "openai-completions"
35
+ | "openai-responses"
36
+ | "azure-openai-responses"
37
+ | "openai-codex-responses"
38
+ | "bedrock-converse-stream"
39
+ | "google-generative-ai"
40
+ | "google-vertex";
30
41
 
31
- const LIST_KEY: Record<ReplayApi, string> = {
32
- "anthropic-messages": "messages",
33
- "openai-completions": "messages",
34
- "openai-responses": "input",
42
+ /**
43
+ * Where the output cap lives, first match in the captured body wins: a
44
+ * top-level field, or one level down (Bedrock's `inferenceConfig.maxTokens`,
45
+ * Google's `config.maxOutputTokens`). Codex sends none.
46
+ */
47
+ const CAP_PATHS: Record<ReplayApi, ReadonlyArray<readonly string[]>> = {
48
+ "anthropic-messages": [["max_tokens"]],
49
+ "openai-completions": [["max_completion_tokens"], ["max_tokens"]],
50
+ "openai-responses": [["max_output_tokens"]],
51
+ "azure-openai-responses": [["max_output_tokens"]],
52
+ "openai-codex-responses": [],
53
+ "bedrock-converse-stream": [["inferenceConfig", "maxTokens"]],
54
+ "google-generative-ai": [["config", "maxOutputTokens"]],
55
+ "google-vertex": [["config", "maxOutputTokens"]],
35
56
  };
36
57
 
37
- /** The output cap's field, first match in the captured body wins. */
38
- const MAX_TOKEN_KEYS: Record<ReplayApi, readonly string[]> = {
39
- "anthropic-messages": ["max_tokens"],
40
- "openai-completions": ["max_completion_tokens", "max_tokens"],
41
- "openai-responses": ["max_output_tokens"],
42
- };
58
+ const REPLAY_APIS: ReadonlySet<string> = new Set<ReplayApi>([
59
+ "anthropic-messages",
60
+ "openai-completions",
61
+ "openai-responses",
62
+ "azure-openai-responses",
63
+ "openai-codex-responses",
64
+ "bedrock-converse-stream",
65
+ "google-generative-ai",
66
+ "google-vertex",
67
+ ]);
68
+
69
+ /** The value at `path` in `payload`, when it is a number. */
70
+ function capAt(payload: Record<string, unknown>, path: readonly string[]): number | undefined {
71
+ let value: unknown = payload;
72
+ for (const key of path) value = isObject(value) ? value[key] : undefined;
73
+ return typeof value === "number" ? value : undefined;
74
+ }
75
+
76
+ /** The path of the cap `payload` carries for `api`, or the first one the API takes. */
77
+ function capPath(api: ReplayApi, payload: Record<string, unknown>): readonly string[] | undefined {
78
+ const paths = CAP_PATHS[api];
79
+ return paths.find((path) => capAt(payload, path) !== undefined) ?? paths[0];
80
+ }
81
+
82
+ /** `payload` with `value` at `path`, copying only the objects on the way. */
83
+ function withCap(payload: Record<string, unknown>, path: readonly string[], value: number): Record<string, unknown> {
84
+ const [head, ...rest] = path;
85
+ if (rest.length === 0) return { ...payload, [head]: value };
86
+ const inner = isObject(payload[head]) ? (payload[head] as Record<string, unknown>) : {};
87
+ return { ...payload, [head]: withCap(inner, rest, value) };
88
+ }
43
89
 
44
90
  /** The session's last provider request body, and the model it went to. */
45
91
  export interface RequestCapture {
@@ -68,9 +114,9 @@ interface ModelRef {
68
114
  /** A capture of `payload` for `model`, or undefined when its API cannot be replayed. */
69
115
  export function captureRequest(model: ModelRef | undefined, payload: unknown, covers?: CoveredThrough, sessionId?: string): RequestCapture | undefined {
70
116
  const api = model?.api;
71
- if (api !== "anthropic-messages" && api !== "openai-completions" && api !== "openai-responses") return undefined;
72
- if (!model?.provider || !model.id || !isRecord(payload) || !Array.isArray(payload[LIST_KEY[api]])) return undefined;
73
- return { api, provider: model.provider, modelId: model.id, payload, ...(covers ? { covers } : {}), ...(sessionId ? { sessionId } : {}) };
117
+ if (!api || !REPLAY_APIS.has(api)) return undefined;
118
+ if (!model?.provider || !model.id || !isObject(payload) || !Array.isArray(payload[listKey(api)])) return undefined;
119
+ return { api: api as ReplayApi, provider: model.provider, modelId: model.id, payload, ...(covers ? { covers } : {}), ...(sessionId ? { sessionId } : {}) };
74
120
  }
75
121
 
76
122
  /** The model fields pi-ai reads to choose its session-affinity headers. */
@@ -176,23 +222,46 @@ export class LastExchange<Reply extends MessageLike = MessageLike> {
176
222
  * trailing user message, so the roles still alternate.
177
223
  */
178
224
  export function extendPayload(capture: RequestCapture, tail: Record<string, unknown>, maxTokens?: number): Record<string, unknown> {
179
- const key = LIST_KEY[capture.api];
225
+ const key = listKey(capture.api);
180
226
  const base = capture.payload[key] as unknown[];
181
- const extra = withoutCacheControl(Array.isArray(tail[key]) ? (tail[key] as unknown[]) : []) as unknown[];
182
- const list = capture.api === "anthropic-messages" ? appendAnthropic(base, extra) : [...base, ...extra];
183
- const out: Record<string, unknown> = { ...capture.payload, [key]: list };
184
- if (maxTokens !== undefined) {
185
- const keys = MAX_TOKEN_KEYS[capture.api];
186
- out[keys.find((k) => k in capture.payload) ?? keys[0]] = maxTokens;
227
+ // The tail's own markers are dropped: the capture's mark the entry the session wrote.
228
+ const extra = stripMarkers(Array.isArray(tail[key]) ? (tail[key] as unknown[]) : []);
229
+ const list =
230
+ capture.api === "anthropic-messages"
231
+ ? settleSystemMessages(appendAnthropic(base, extra), capture.api, base.length)
232
+ : capture.api === "bedrock-converse-stream" || capture.api.startsWith("google-")
233
+ ? joinTurns(base, extra, capture.api.startsWith("google-") ? "parts" : "content")
234
+ : [...base, ...extra];
235
+ let out: Record<string, unknown> = { ...capture.payload, [key]: list };
236
+ // Google's config carries the request's live abort signal: the side call's own, never the session's.
237
+ if (capture.api.startsWith("google-") && isObject(out.config)) {
238
+ const { abortSignal: _session, ...config } = out.config;
239
+ const own = isObject(tail.config) ? tail.config.abortSignal : undefined;
240
+ out = { ...out, config: own === undefined ? config : { ...config, abortSignal: own } };
187
241
  }
188
- return out;
242
+ const path = maxTokens === undefined ? undefined : capPath(capture.api, capture.payload);
243
+ return path && maxTokens !== undefined ? withCap(out, path, maxTokens) : out;
244
+ }
245
+
246
+ /**
247
+ * The tail appended so turns still alternate: a tail that opens with a user
248
+ * turn joins a captured trailing user turn (Bedrock and Google reject two in
249
+ * a row). `field` is where a turn keeps its blocks (`parts` on Google).
250
+ */
251
+ function joinTurns(base: unknown[], tail: unknown[], field: "content" | "parts"): unknown[] {
252
+ const last = base.at(-1) as Record<string, unknown> | undefined;
253
+ const first = tail[0] as Record<string, unknown> | undefined;
254
+ if (last?.role === "user" && first?.role === "user" && Array.isArray(last[field]) && Array.isArray(first[field])) {
255
+ return [...base.slice(0, -1), { ...last, [field]: [...(last[field] as unknown[]), ...(first[field] as unknown[])] }, ...tail.slice(1)];
256
+ }
257
+ return [...base, ...tail];
189
258
  }
190
259
 
191
260
  /** The output cap the captured body carries, if any. */
192
261
  function capturedMaxTokens(capture: RequestCapture): number | undefined {
193
- for (const key of MAX_TOKEN_KEYS[capture.api]) {
194
- const value = capture.payload[key];
195
- if (typeof value === "number") return value;
262
+ for (const path of CAP_PATHS[capture.api]) {
263
+ const value = capAt(capture.payload, path);
264
+ if (value !== undefined) return value;
196
265
  }
197
266
  return undefined;
198
267
  }
@@ -262,24 +331,63 @@ export const MAX_CACHE_MARKERS = 4;
262
331
  * tail's mark the fork's own entry, and the earliest are dropped past four.
263
332
  */
264
333
  export function forkRequestPayload(capture: RequestCapture, child: Record<string, unknown>, baseline: ReadonlySet<string>): Record<string, unknown> {
265
- const key = LIST_KEY[capture.api];
334
+ const key = listKey(capture.api);
266
335
  const base = capture.payload[key] as unknown[];
267
- const tail = (Array.isArray(child[key]) ? (child[key] as WireMessage[]) : []).filter((entry) => entry.role !== "system" && entry.role !== "developer");
268
- const out: Record<string, unknown> = { ...capture.payload, [key]: capture.api === "anthropic-messages" ? joinUserTurns(base, tail) : [...base, ...tail] };
269
- const tools = forkTools(capture.payload.tools, child.tools, baseline, tail);
270
- if (tools) out.tools = tools;
271
- const capKey = MAX_TOKEN_KEYS[capture.api].find((k) => typeof capture.payload[k] === "number");
336
+ const childList = Array.isArray(child[key]) ? (child[key] as WireMessage[]) : [];
337
+ const parentDeclared = captureDeclarations(capture);
338
+ // The child's own prelude and system messages go; a Responses load item its
339
+ // tool_search placed in the tail stays, less what the capture declares.
340
+ const tail = childList.slice(preludeLength(childList, capture.api)).flatMap((entry): WireMessage[] => {
341
+ if (isToolsItem(entry)) {
342
+ const tools = entry.tools.filter((tool) => !parentDeclared.has(toolName(tool) ?? ""));
343
+ return tools.length > 0 ? [{ ...entry, tools } as WireMessage] : [];
344
+ }
345
+ return entry.role !== "system" && entry.role !== "developer" ? [entry] : [];
346
+ });
347
+ const childTools = [...(Array.isArray(child.tools) ? child.tools : []), ...(leadingToolsItem(child.tools, childList)?.tools ?? [])];
348
+ const inTail = loadedInList(tail);
349
+ const gained = forkGainedTools(parentDeclared, childTools.filter((tool) => !inTail.has(toolName(tool) ?? "")), baseline, tail);
350
+ // A gained tool goes where the capture keeps its tools: in an item at the
351
+ // start of the fork's tail when the capture declares in a leading item
352
+ // (after the parent's prefix, which it then keeps), else appended to the
353
+ // top-level array.
354
+ const parentItem = leadingToolsItem(capture.payload.tools, base);
355
+ const ownTail = gained.length > 0 && parentItem ? [toolsItemFor(gained, parentItem) as WireMessage, ...tail] : tail;
356
+ const out: Record<string, unknown> = { ...capture.payload, [key]: capture.api === "anthropic-messages" ? settleSystemMessages(appendTurns(base, ownTail), capture.api, base.length) : [...base, ...ownTail] };
357
+ // A capture that declares no tools at its head takes the child's own,
358
+ // wherever the child declared them.
359
+ const parentTools = capture.payload.tools;
360
+ const appended = !parentItem && Array.isArray(parentTools) ? gained : [];
361
+ if (!parentItem) {
362
+ const tools = Array.isArray(parentTools) ? (appended.length > 0 ? [...parentTools, ...appended] : parentTools) : childTools.length > 0 ? stripMarkers(childTools) : undefined;
363
+ if (tools) out.tools = tools;
364
+ }
365
+ const capKey = CAP_PATHS[capture.api].find((path) => capAt(capture.payload, path) !== undefined);
272
366
  // Appended tool schemas spend room too; the parent's own tools are already in its cap.
273
- const appended = Array.isArray(tools) && Array.isArray(capture.payload.tools) ? tools.slice(capture.payload.tools.length) : [];
274
- const tailRoom = forkOutputRoom(capture, tail);
367
+ const tailRoom = forkOutputRoom(capture, ownTail);
275
368
  const room = tailRoom === undefined ? undefined : tailRoom - (appended.length > 0 ? Math.ceil(JSON.stringify(appended).length / 4) : 0);
369
+ let result = out;
276
370
  if (capKey && room !== undefined) {
277
371
  // Never above the room: the fork chose the fallback before this when too little was left.
278
- const own = typeof child[capKey] === "number" ? (child[capKey] as number) : room;
279
- out[capKey] = Math.max(1, Math.min(own, room));
372
+ const own = capAt(child, capKey) ?? room;
373
+ result = withCap(out, capKey, Math.max(1, Math.min(own, room)));
280
374
  }
281
- capCacheMarkers(out, key, capture, tail);
282
- return out;
375
+ capCacheMarkers(result, key, capture, ownTail);
376
+ return result;
377
+ }
378
+
379
+ /** The tool names a fork's first request declares, wherever it keeps them: the baseline its later gains are measured from. */
380
+ export function forkBaselineNames(payload: Record<string, unknown>, api: string): Set<string> {
381
+ return new Set(api.endsWith("-responses") ? responsesDeclarations(payload, api).keys() : toolNames(payload.tools));
382
+ }
383
+
384
+ /** What each capture declares, computed once: every request of a fork splices the same capture. */
385
+ const declaredByCapture = new WeakMap<RequestCapture, Set<string>>();
386
+
387
+ function captureDeclarations(capture: RequestCapture): Set<string> {
388
+ let names = declaredByCapture.get(capture);
389
+ if (!names) declaredByCapture.set(capture, (names = forkBaselineNames(capture.payload, capture.api)));
390
+ return names;
283
391
  }
284
392
 
285
393
  /**
@@ -289,49 +397,60 @@ export function forkRequestPayload(capture: RequestCapture, child: Record<string
289
397
  * `MIN_REPLAY_OUTPUT_TOKENS` must not be spliced: it goes out whole instead.
290
398
  */
291
399
  export function forkOutputRoom(capture: RequestCapture, tail: unknown): number | undefined {
292
- const capKey = MAX_TOKEN_KEYS[capture.api].find((k) => typeof capture.payload[k] === "number");
293
- return capKey === undefined ? undefined : (capture.payload[capKey] as number) - Math.ceil(JSON.stringify(tail).length / 4);
400
+ const cap = capturedMaxTokens(capture);
401
+ return cap === undefined ? undefined : cap - Math.ceil(JSON.stringify(tail).length / 4);
294
402
  }
295
403
 
296
- /** The names a body's tool list declares (Anthropic, Chat Completions and Responses shapes). */
404
+ /** The names a body's tool list declares, in any API's shape (lib/ledger/wire.ts toolName). */
297
405
  export function toolNames(tools: unknown): string[] {
298
406
  return Array.isArray(tools) ? tools.map(toolName).filter((name): name is string => name !== undefined) : [];
299
407
  }
300
408
 
301
- function toolName(tool: unknown): string | undefined {
302
- if (!isRecord(tool)) return undefined;
303
- if (typeof tool.name === "string") return tool.name;
304
- return isRecord(tool.function) && typeof tool.function.name === "string" ? tool.function.name : undefined;
305
- }
306
-
307
- function forkTools(parent: unknown, child: unknown, baseline: ReadonlySet<string>, tail: unknown[]): unknown[] | undefined {
308
- if (!Array.isArray(parent)) return Array.isArray(child) ? (withoutCacheControl(child) as unknown[]) : undefined;
309
- const declared = new Set(toolNames(parent));
409
+ /**
410
+ * The child's tools the capture does not declare and the fork needs: one it
411
+ * gained after its first request (not in `baseline`), or one a tail
412
+ * `tool_reference` names. Markers stripped: the capture's own mark stays.
413
+ */
414
+ function forkGainedTools(declared: ReadonlySet<string>, child: readonly unknown[], baseline: ReadonlySet<string>, tail: unknown[]): unknown[] {
310
415
  const referenced = referencedTools(tail);
311
- const extra = (Array.isArray(child) ? child : []).filter((tool) => {
312
- const name = toolName(tool);
313
- return name !== undefined && !declared.has(name) && (!baseline.has(name) || referenced.has(name));
314
- });
315
- return extra.length > 0 ? [...parent, ...(withoutCacheControl(extra) as unknown[])] : parent;
416
+ return stripMarkers(
417
+ child.filter((tool) => {
418
+ const name = toolName(tool);
419
+ return name !== undefined && !declared.has(name) && (!baseline.has(name) || referenced.has(name));
420
+ }),
421
+ );
316
422
  }
317
423
 
318
424
  /** Tool names the tail's `tool_reference` blocks load (Anthropic tool search). */
319
425
  function referencedTools(value: unknown, found = new Set<string>()): Set<string> {
320
426
  if (Array.isArray(value)) for (const entry of value) referencedTools(entry, found);
321
- else if (isRecord(value)) {
427
+ else if (isObject(value)) {
322
428
  if (value.type === "tool_reference" && typeof value.tool_name === "string") found.add(value.tool_name);
323
429
  for (const entry of Object.values(value)) referencedTools(entry, found);
324
430
  }
325
431
  return found;
326
432
  }
327
433
 
328
- /** Anthropic needs alternating roles: a tail that opens with a user turn joins the captured last one. */
329
- function joinUserTurns(base: unknown[], tail: unknown[]): unknown[] {
434
+ /**
435
+ * `tail` after a captured Anthropic list, with the capture kept a byte
436
+ * prefix. A tail that opens with a user turn joins the captured last one, so
437
+ * the roles alternate. A capture that ends in a content system run (a
438
+ * `<total_tokens>` countdown, a context message) took no clean reply: a user
439
+ * turn after it would strand the run, and the layout pass would move the run
440
+ * behind it, rewriting what the session cached there (findings §73). A text
441
+ * turn goes in as a system message instead, so the run stays where its cache
442
+ * entry was written. A side prompt opens its tail that way, and so does a fork
443
+ * taken before the capture's reply landed (a `/btw` fork while it streams).
444
+ */
445
+ function appendTurns(base: unknown[], tail: unknown[]): unknown[] {
330
446
  const last = base.at(-1) as WireMessage | undefined;
331
447
  const first = tail[0] as WireMessage | undefined;
332
448
  if (last?.role === "user" && first?.role === "user") {
333
449
  return [...base.slice(0, -1), { ...last, content: [...blocksOf(last.content), ...blocksOf(first.content)] }, ...tail.slice(1)];
334
450
  }
451
+ if (first?.role === "user" && endsInSystemRun(base) && blocksOf(first.content).every((block) => block.type === "text")) {
452
+ return [...base, { role: "system", content: blocksOf(first.content) }, ...tail.slice(1)];
453
+ }
335
454
  return [...base, ...tail];
336
455
  }
337
456
 
@@ -357,7 +476,7 @@ function captureMarkers(capture: RequestCapture): number {
357
476
  let count = capturedMarkerCounts.get(capture);
358
477
  if (count === undefined) {
359
478
  const payload = capture.payload;
360
- count = countMarkers(payload.tools) + countMarkers(payload.system) + countMarkers(payload[LIST_KEY[capture.api]]);
479
+ count = countMarkers(payload.tools) + countMarkers(payload.system) + countMarkers(payload[listKey(capture.api)]);
361
480
  capturedMarkerCounts.set(capture, count);
362
481
  }
363
482
  return count;
@@ -365,7 +484,7 @@ function captureMarkers(capture: RequestCapture): number {
365
484
 
366
485
  function countMarkers(value: unknown): number {
367
486
  if (Array.isArray(value)) return value.reduce((sum: number, entry) => sum + countMarkers(entry), 0);
368
- if (!isRecord(value)) return 0;
487
+ if (!isObject(value)) return 0;
369
488
  let count = "cache_control" in value ? 1 : 0;
370
489
  for (const [key, entry] of Object.entries(value)) if (key !== "cache_control") count += countMarkers(entry);
371
490
  return count;
@@ -382,7 +501,7 @@ function dropFirstMarkers(value: unknown, budget: { drop: number }): unknown {
382
501
  });
383
502
  return changed ? out : value;
384
503
  }
385
- if (!isRecord(value)) return value;
504
+ if (!isObject(value)) return value;
386
505
  let out: Record<string, unknown> | undefined;
387
506
  if ("cache_control" in value) {
388
507
  const { cache_control: _, ...rest } = value;
@@ -413,27 +532,16 @@ function appendAnthropic(base: unknown[], extra: unknown[]): unknown[] {
413
532
  const content = replyBlocks.map((block, index) => (index === anchor ? { ...block, cache_control: markers.at(-1) } : block));
414
533
  return [...base.slice(0, -1), lastUnmarked, { ...first, content }, ...extra.slice(1)];
415
534
  }
416
- if (last && last.role === "user" && first.role === "user") {
417
- return [...base.slice(0, -1), { ...last, content: [...blocksOf(last.content), ...blocksOf(first.content)] }, ...extra.slice(1)];
418
- }
419
- return [...base, ...extra];
535
+ return appendTurns(base, extra);
536
+ }
537
+
538
+ /** Whether the list, past its trailing directives, ends in a system message with content. */
539
+ function endsInSystemRun(list: unknown[]): boolean {
540
+ const last = list[appendPoint(list, "anthropic-messages") - 1] as WireMessage | undefined;
541
+ return last?.role === "system" && blocksOf(last.content).length > 0;
420
542
  }
421
543
 
422
544
  function blocksOf(content: unknown): Block[] {
423
545
  if (typeof content === "string") return [{ type: "text", text: content }];
424
546
  return Array.isArray(content) ? (content as Block[]) : [];
425
547
  }
426
-
427
- function withoutCacheControl(value: unknown): unknown {
428
- if (Array.isArray(value)) return value.map(withoutCacheControl);
429
- if (!isRecord(value)) return value;
430
- const out: Record<string, unknown> = {};
431
- for (const [key, entry] of Object.entries(value)) {
432
- if (key !== "cache_control") out[key] = withoutCacheControl(entry);
433
- }
434
- return out;
435
- }
436
-
437
- function isRecord(value: unknown): value is Record<string, unknown> {
438
- return typeof value === "object" && value !== null && !Array.isArray(value);
439
- }
@@ -26,6 +26,17 @@ export function toolStubs(names: readonly string[], reason = "Unavailable for th
26
26
  );
27
27
  }
28
28
 
29
+ /**
30
+ * The messages the current branch holds, as pi builds a request from them
31
+ * (compaction-aware), read when the side call runs. A copy kept from the last
32
+ * `context` event goes stale at a `/tree` or a compaction, which fire no
33
+ * `context` event, and the fallback then answered from the abandoned branch or
34
+ * the whole pre-compaction history.
35
+ */
36
+ export function branchMessages<M>(ctx: { sessionManager: { buildSessionProjection(): { messages: M[] } } }): M[] {
37
+ return ctx.sessionManager.buildSessionProjection().messages;
38
+ }
39
+
29
40
  /**
30
41
  * Trim a message history forward to the first clean turn boundary — a `user`
31
42
  * prompt or an `assistant` reply. A raw history can begin mid-exchange with a
@@ -104,7 +104,6 @@ export function subagentHandbackExtension(slot: HandbackSlot, options: { deferre
104
104
  pi.on("session_start", () => {
105
105
  pi.events.emit(REMINDER_CHANNEL, {
106
106
  text: options.deferred ? `${SUBAGENT_HANDBACK_LOAD_FIRST}\n${SUBAGENT_HANDBACK_REMINDER}` : SUBAGENT_HANDBACK_REMINDER,
107
- scope: "every-turn",
108
107
  key: "subagent-handback",
109
108
  placement: "sticky-append",
110
109
  } satisfies ReminderPayload);
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Claude Code's `tool_addition` for tools that arrive mid-session (pure). One
3
- * Code stores the arrival as the deferred-tool addendum one-shot
4
- * (lib/deferred.ts deferredAddendumText), which reads correctly on any model.
3
+ * Code renders the arrival as the deferred-tool addendum on the request's
4
+ * tail (lib/deferred.ts deferredAddendumText), which reads correctly on any model.
5
5
  * On a first-party model that takes mid-conversation tool changes, the
6
6
  * addendum leaves the message that carries it for a system message right
7
7
  * after it: Claude Code's sentence and names, then one reference-form
@@ -127,23 +127,3 @@ export function withToolAdditions(
127
127
  if (all.size === 0) return undefined;
128
128
  return { payload: { ...payload, messages: reseatMessageMark(out) }, names: [...all] };
129
129
  }
130
-
131
- /** A stored message as far as the addendum scan is concerned. */
132
- type BranchEntry = { type?: string; message?: { role?: string; content?: unknown } };
133
-
134
- /**
135
- * The names every addendum persisted into a tool result on the branch
136
- * announced: on resume they are re-activated, since their addition blocks
137
- * are rebuilt from the same stored blocks and the model may call them.
138
- */
139
- export function addendumNamesOnBranch(branch: readonly BranchEntry[]): string[] {
140
- const names = new Set<string>();
141
- for (const entry of branch) {
142
- const message = entry?.type === "message" ? entry.message : undefined;
143
- if (message?.role !== "toolResult" || !Array.isArray(message.content)) continue;
144
- for (const block of message.content as StoredBlock[]) {
145
- for (const name of (typeof block?.text === "string" ? addendumNames(block.text) : undefined) ?? []) names.add(name);
146
- }
147
- }
148
- return [...names];
149
- }
@@ -73,6 +73,8 @@ export interface TurnBudgetOptions {
73
73
  nudge?: boolean;
74
74
  /** False when the budget is off: no prompt carries a marker, so a user's own tag stays text. */
75
75
  markers?: boolean;
76
+ /** False when the list is a request's tail: its first user message is a later prompt, not the stack carrier. Defaults to true. */
77
+ stackCarrier?: boolean;
76
78
  }
77
79
 
78
80
  /** The call ids a wire message answers: Anthropic `tool_result` blocks, an OpenAI output item or tool message. */
@@ -85,6 +87,16 @@ function answeredCalls(message: WireMessage): string[] {
85
87
  return [];
86
88
  }
87
89
 
90
+ /** Whether the next message after `index` that is not a system message is a user message. */
91
+ function followedByUser(messages: readonly unknown[], index: number): boolean {
92
+ for (let i = index + 1; i < messages.length; i++) {
93
+ const role = (messages[i] as WireMessage | undefined)?.role;
94
+ if (role === "system" || role === "developer") continue;
95
+ return role === "user" && !answeredCalls(messages[i] as WireMessage).length;
96
+ }
97
+ return false;
98
+ }
99
+
88
100
  export function withTurnBudgetMessages(payload: Record<string, unknown>, opts: TurnBudgetOptions): Record<string, unknown> | undefined {
89
101
  const key = messagesKey(opts.shape);
90
102
  const messages = payload[key];
@@ -94,7 +106,7 @@ export function withTurnBudgetMessages(payload: Record<string, unknown>, opts: T
94
106
  /** The lowest countdown of the tool results not yet followed by their message. */
95
107
  let pending: number | undefined;
96
108
  /** The first user message carries the context stack, never a marker (context-budget's `skipStackCarrier`). */
97
- let stackCarrierSeen = false;
109
+ let stackCarrierSeen = opts.stackCarrier === false;
98
110
  for (let i = 0; i < messages.length; i++) {
99
111
  let message = messages[i] as WireMessage;
100
112
  const after: WireMessage[] = [];
@@ -111,7 +123,10 @@ export function withTurnBudgetMessages(payload: Record<string, unknown>, opts: T
111
123
  const rest = at === -1 ? undefined : withoutParts(parts, (_, index) => index === at);
112
124
  if (rest) {
113
125
  const line = parts[at].text as string;
114
- if (opts.systemRole) {
126
+ // A system message must reach an assistant message: before another
127
+ // user message (a prompt whose request failed, pi having dropped the
128
+ // error reply) the line stays on the prompt, framed (findings §52).
129
+ if (opts.systemRole && !followedByUser(messages, i)) {
115
130
  message = { ...message, content: rest };
116
131
  after.push(systemMessage(opts.shape, opts.role, line));
117
132
  } else {
@@ -127,6 +142,7 @@ export function withTurnBudgetMessages(payload: Record<string, unknown>, opts: T
127
142
  // On the OpenAI APIs a batch of results is a run of items; one message follows the run.
128
143
  const next = messages[i + 1] as WireMessage | undefined;
129
144
  const runContinues = opts.shape !== "anthropic" && (next?.type === "function_call_output" || next?.role === "tool");
145
+ if (pending !== undefined && !runContinues && followedByUser(messages, i)) pending = undefined;
130
146
  if (pending !== undefined && !runContinues) {
131
147
  after.push(systemMessage(opts.shape, opts.role, `<total_tokens>${pending} tokens left</total_tokens>`));
132
148
  if (opts.nudge) {
@@ -5,9 +5,8 @@
5
5
  * 1. A session-wide watcher (watcher.ts): new diagnostics — cross-file, all
6
6
  * severities, deduplicated against what was already delivered — ride the
7
7
  * tool result of the round that surfaced them as a raw `<new-diagnostics>`
8
- * block (a reminder-queue one-shot, pinned to the trailing tool result
9
- * after the `<total_tokens>` line — the exact place a Claude Code session
10
- * shows them), or the message that opens the next run when they arrived
8
+ * block (a reminder-queue one-shot, delivered on the trailing tool result
9
+ * of the next request — the exact place a Claude Code session shows them), or the message that opens the next run when they arrived
11
10
  * while idle (`agent_start`, so a notification-opened run gets them too).
12
11
  * Until 2026-09-05 they went out as a custom steer message of their own
13
12
  * (a separate user turn on the wire). Editing a file clears its
@@ -289,11 +288,10 @@ export default function lspExtension(pi: ExtensionAPI) {
289
288
 
290
289
  /**
291
290
  * Queue the block where the model reads next. Raw (no `<system-reminder>`
292
- * frame, as in Claude Code) and last-append: from a `tool_result` hook this
293
- * extension runs after system-reminder's, so the one-shot is pending at the
294
- * next `context` and pinned to the trailing tool result (process memory,
295
- * same mechanism as the file-tracker's per-round report); from `agent_start`
296
- * it pins to the message that opened the run.
291
+ * frame, as in Claude Code) and last-append: persisted as a pending entry,
292
+ * it rides the trailing tool result of the next request (the same mechanism
293
+ * as the file-tracker's per-round report); from `agent_start` it rides the
294
+ * message that opened the run.
297
295
  */
298
296
  const queueNewDiagnostics = (text: string) => {
299
297
  pi.events.emit(REMINDER_CHANNEL, { text, placement: "last-append", raw: true } satisfies ReminderPayload);