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
@@ -1,24 +1,21 @@
1
1
  /**
2
- * What to tell the model when MCP state changes after the first request (pure).
2
+ * What to tell the model when MCP state changes inside an epoch (pure).
3
3
  *
4
4
  * The MCP instructions and failed-servers reminders are `first-prepend` blocks
5
- * on message 1. Before the first request they are simply (re)written; after it
6
- * message 1 is part of every request's cached prefix, so they are FROZEN and
7
- * changes ride one-shot notices where the model reads next (same rule as the
8
- * deferred-tools listing, tool-search/announce.ts). `McpAnnounced` records what
9
- * the model has been told; `mcpDelta` compares it with the live state.
5
+ * on message 1. A request that opens an epoch renders them from live state;
6
+ * inside an epoch message 1 is the record (extensions/ledger), so changes ride
7
+ * notices on the request's tail, decided when the request goes out from what
8
+ * the epoch has told (the same rule as the deferred-tools listing). What was
9
+ * told is a whole-text block fact per server (`mcp-instructed:<name>`,
10
+ * `mcp-failed:<name>`): the hash of the instructions or error last told, or of
11
+ * the empty text once a drop or a recovery was announced.
10
12
  */
11
13
 
12
14
  import { mcpFailuresReminder, mcpInstructionsReminder } from "./client.ts";
15
+ import type { BlockFact, ToldFact } from "../lib/ledger/records.ts";
16
+ import { toldBlockFact } from "../lib/reminders.ts";
13
17
  import { countNoun } from "../lib/tui-render.ts";
14
18
 
15
- export interface McpAnnounced {
16
- /** Servers whose instructions the model has been given, with the text it saw. */
17
- instructed: Map<string, string>;
18
- /** Servers the model has been told failed, with the error it saw. */
19
- failed: Map<string, string>;
20
- }
21
-
22
19
  export interface McpSnapshot {
23
20
  connected: Array<{ name: string; instructions?: string }>;
24
21
  failed: Array<{ name: string; error: string }>;
@@ -33,16 +30,19 @@ export interface McpDelta {
33
30
  recovered: string[];
34
31
  /** Servers whose instructions the model saw that are no longer connected and not reported failed. */
35
32
  dropped: string[];
33
+ /** What announcing this delta tells, for the request that carries it. */
34
+ facts: ToldFact[];
36
35
  }
37
36
 
38
- export function emptyAnnounced(): McpAnnounced {
39
- return { instructed: new Map(), failed: new Map() };
40
- }
37
+ const INSTRUCTED = "mcp-instructed:";
38
+ const FAILED = "mcp-failed:";
39
+ /** The told hash of "nothing": a drop or a recovery was announced. */
40
+ const NONE = toldBlockFact("", "").hash;
41
41
 
42
42
  /**
43
43
  * Store and compare the exact bounded values the reminder puts on the wire.
44
44
  * A changing tail beyond either cap is not a model-visible capability change
45
- * and must not become a false resume addendum.
45
+ * and must not become a false notice.
46
46
  */
47
47
  function displayedInstructions(name: string, instructions: string): string {
48
48
  const rendered = mcpInstructionsReminder([{ server: { name }, instructions }]);
@@ -53,95 +53,60 @@ function displayedFailure(error: string): string {
53
53
  return error.split("\n")[0].slice(0, 300);
54
54
  }
55
55
 
56
- /** Maps are serialized as entry arrays so arbitrary server instructions/errors round-trip exactly. */
57
- export interface McpAnnouncedBaseline { instructed: Array<[string, string]>; failed: Array<[string, string]> }
58
- export function serializeMcpAnnounced(announced: McpAnnounced): McpAnnouncedBaseline {
59
- return { instructed: [...announced.instructed], failed: [...announced.failed] };
60
- }
61
- /**
62
- * Best-effort compatibility reader for stacks written before structured MCP
63
- * baselines. With the configured server `names`, a section starts only at a
64
- * `## <name>` heading, so a `## ` heading inside a server's instructions stays
65
- * part of them instead of reading as a server of its own.
66
- */
67
- export function mcpAnnouncedFromReminders(instructions: string | undefined, failures: string | undefined, names?: Iterable<string>): McpAnnounced {
68
- const announced = emptyAnnounced();
69
- const known = names ? new Set(names) : undefined;
70
- const instructionPrefix = "# MCP Server Instructions\n\nThe following MCP servers have provided instructions for how to use their tools and resources:\n\n";
71
- if (instructions?.startsWith(instructionPrefix)) {
72
- const sections: string[] = [];
73
- for (const section of instructions.slice(instructionPrefix.length).split("\n\n## ")) {
74
- const normalized = section.startsWith("## ") ? section.slice(3) : section;
75
- const newline = normalized.indexOf("\n");
76
- const heading = newline > 0 ? normalized.slice(0, newline) : normalized;
77
- if (known && sections.length > 0 && !known.has(heading)) sections[sections.length - 1] += `\n\n## ${normalized}`;
78
- else sections.push(normalized);
79
- }
80
- for (const section of sections) {
81
- const newline = section.indexOf("\n");
82
- if (newline > 0) announced.instructed.set(section.slice(0, newline), section.slice(newline + 1));
83
- }
84
- }
85
- const failurePrefix = "The following MCP servers are configured but failed to connect — their tools (typically named mcp__<server>__*) are unavailable for this session:\n";
86
- if (failures?.startsWith(failurePrefix)) {
87
- for (const line of failures.slice(failurePrefix.length).split("\n")) {
88
- if (!line.startsWith("- ")) break;
89
- const colon = line.indexOf(": ", 2);
90
- if (colon > 2) announced.failed.set(line.slice(2, colon), line.slice(colon + 2));
91
- }
92
- }
93
- return announced;
94
- }
56
+ const instructedFact = (name: string, instructions: string): BlockFact => toldBlockFact(INSTRUCTED + name, displayedInstructions(name, instructions));
57
+ const failedFact = (name: string, error: string): BlockFact => toldBlockFact(FAILED + name, displayedFailure(error));
95
58
 
96
- export function mcpAnnouncedBaseline(value: unknown): McpAnnounced | undefined {
97
- if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
98
- const input = value as Record<string, unknown>;
99
- const pairs = (item: unknown): Array<[string, string]> | undefined =>
100
- Array.isArray(item) && item.every((pair) => Array.isArray(pair) && pair.length === 2 && typeof pair[0] === "string" && typeof pair[1] === "string")
101
- ? item as Array<[string, string]>
102
- : undefined;
103
- const instructed = pairs(input.instructed);
104
- const failed = pairs(input.failed);
105
- return instructed && failed ? { instructed: new Map(instructed), failed: new Map(failed) } : undefined;
59
+ /** What message 1's two MCP blocks rendered from `snapshot` tell. */
60
+ export function mcpFacts(snapshot: McpSnapshot): ToldFact[] {
61
+ return [
62
+ ...snapshot.connected.flatMap((c) => (c.instructions ? [instructedFact(c.name, c.instructions)] : [])),
63
+ ...snapshot.failed.map((f) => failedFact(f.name, f.error)),
64
+ ];
106
65
  }
107
66
 
108
- /** What message 1 says after it was (re)written from `snapshot`, before the first request. */
109
- export function announcedFrom(snapshot: McpSnapshot): McpAnnounced {
110
- return {
111
- instructed: new Map(
112
- snapshot.connected.filter((c) => c.instructions).map((c) => [c.name, displayedInstructions(c.name, c.instructions as string)]),
113
- ),
114
- failed: new Map(snapshot.failed.map((f) => [f.name, displayedFailure(f.error)])),
115
- };
116
- }
117
-
118
- export function mcpDelta(announced: McpAnnounced, snapshot: McpSnapshot): McpDelta {
67
+ /**
68
+ * What changed since the epoch told `told` (whole-text block hashes by key),
69
+ * and the facts announcing it tells. While the startup connections are still
70
+ * `settling`, a server not connected yet is not a drop, and neither is one in
71
+ * `connecting` (a reconnect in flight).
72
+ */
73
+ export function mcpDelta(told: ReadonlyMap<string, string>, snapshot: McpSnapshot, settling = false, connecting: ReadonlySet<string> = new Set()): McpDelta {
119
74
  const connectedNames = new Set(snapshot.connected.map((c) => c.name));
120
- const failedNames = new Set(snapshot.failed.map((f) => f.name));
75
+ // One failure per server (the latest): two kept errors would alternate as the told fact.
76
+ const failed = [...new Map(snapshot.failed.map((f) => [f.name, f])).values()];
77
+ const failedNames = new Set(failed.map((f) => f.name));
78
+ const toldNames = (prefix: string) => [...told].filter(([key, hash]) => key.startsWith(prefix) && hash !== NONE).map(([key]) => key.slice(prefix.length));
79
+ const newInstructions = snapshot.connected
80
+ .filter((c) => c.instructions && told.get(INSTRUCTED + c.name) !== instructedFact(c.name, c.instructions).hash)
81
+ .map((c) => ({ name: c.name, instructions: displayedInstructions(c.name, c.instructions as string) }));
82
+ const newFailures = failed
83
+ .filter((f) => told.get(FAILED + f.name) !== failedFact(f.name, f.error).hash)
84
+ .map((f) => ({ name: f.name, error: displayedFailure(f.error) }));
85
+ const recovered = toldNames(FAILED).filter((name) => connectedNames.has(name));
86
+ const dropped = settling ? [] : toldNames(INSTRUCTED).filter((name) => !connectedNames.has(name) && !failedNames.has(name) && !connecting.has(name));
121
87
  return {
122
- newInstructions: snapshot.connected
123
- .filter((c) => c.instructions && announced.instructed.get(c.name) !== displayedInstructions(c.name, c.instructions as string))
124
- .map((c) => ({ name: c.name, instructions: displayedInstructions(c.name, c.instructions as string) })),
125
- newFailures: snapshot.failed
126
- .filter((f) => announced.failed.get(f.name) !== displayedFailure(f.error))
127
- .map((f) => ({ name: f.name, error: displayedFailure(f.error) })),
128
- recovered: [...announced.failed.keys()].filter((name) => connectedNames.has(name)),
129
- dropped: [...announced.instructed.keys()].filter((name) => !connectedNames.has(name) && !failedNames.has(name)),
88
+ newInstructions,
89
+ newFailures,
90
+ recovered,
91
+ dropped,
92
+ facts: [
93
+ ...newInstructions.map((s) => toldBlockFact(INSTRUCTED + s.name, s.instructions)),
94
+ ...newFailures.map((f) => toldBlockFact(FAILED + f.name, f.error)),
95
+ ...recovered.map((name) => toldBlockFact(FAILED + name, "")),
96
+ ...dropped.map((name) => toldBlockFact(INSTRUCTED + name, "")),
97
+ ],
130
98
  };
131
99
  }
132
100
 
133
- export function applyMcpDelta(announced: McpAnnounced, delta: McpDelta): void {
134
- for (const s of delta.newInstructions) announced.instructed.set(s.name, s.instructions);
135
- for (const f of delta.newFailures) announced.failed.set(f.name, f.error);
136
- for (const name of delta.recovered) announced.failed.delete(name);
137
- for (const name of delta.dropped) announced.instructed.delete(name);
138
- }
139
-
140
- /** One-shot notice texts for a delta, in the order they should be queued. Empty when nothing changed. */
101
+ /**
102
+ * The notices for a delta, in the order they go on the tail. Empty when
103
+ * nothing changed.
104
+ */
141
105
  export function mcpChangeNotices(delta: McpDelta): string[] {
142
106
  const notices: string[] = [];
107
+ const add = (text: string) => notices.push(text);
143
108
  if (delta.recovered.length > 0) {
144
- notices.push(
109
+ add(
145
110
  `MCP server${delta.recovered.length === 1 ? "" : "s"} ${delta.recovered.join(", ")} ` +
146
111
  "connected after this conversation started (an earlier notice reported a connection failure). " +
147
112
  "The server's tools are available as deferred tools — load them with tool_search.",
@@ -149,17 +114,17 @@ export function mcpChangeNotices(delta: McpDelta): string[] {
149
114
  }
150
115
  if (delta.newInstructions.length > 0) {
151
116
  const names = delta.newInstructions.map((s) => s.name);
152
- notices.push(
117
+ add(
153
118
  `MCP server${names.length === 1 ? "" : "s"} ${names.join(", ")} connected after this conversation started; ` +
154
119
  "the tools are available as deferred tools (load with tool_search). Server instructions:\n\n" +
155
120
  mcpInstructionsReminder(delta.newInstructions.map((s) => ({ server: { name: s.name }, instructions: s.instructions }))),
156
121
  );
157
122
  }
158
123
  if (delta.newFailures.length > 0) {
159
- notices.push(mcpFailuresReminder(delta.newFailures));
124
+ add(mcpFailuresReminder(delta.newFailures));
160
125
  }
161
126
  if (delta.dropped.length > 0) {
162
- notices.push(
127
+ add(
163
128
  `MCP server${delta.dropped.length === 1 ? "" : "s"} ${delta.dropped.join(", ")} disconnected; ` +
164
129
  'their tools answer "not connected" until the server is reconnected (the user can do that in /mcp).',
165
130
  );
@@ -36,7 +36,7 @@ import {
36
36
  readResource,
37
37
  readResourceDir,
38
38
  } from "./client.ts";
39
- import { CONTEXT_ORDER, REMINDER_CHANNEL } from "../lib/reminders.ts";
39
+ import { CONTEXT_ORDER, REMINDER_CHANNEL, RENDER_CHANNEL, type RenderRequest } from "../lib/reminders.ts";
40
40
  import { join } from "node:path";
41
41
  import {
42
42
  MCP_STATUS_CHANNEL,
@@ -49,8 +49,7 @@ import { readDisabledMcpServers, setMcpServerDisabled } from "../lib/mcp-overrid
49
49
  import { boundedDockHeight, safeThemeBold, safeThemePaint, truncateLine } from "../lib/tui-render.ts";
50
50
  import { authenticate as runOAuthFlow, silentProvider } from "./oauth/flow.ts";
51
51
  import { hasStoredTokens } from "./oauth/store.ts";
52
- import { announcedFrom, applyMcpDelta, emptyAnnounced, mcpAnnouncedBaseline, mcpAnnouncedFromReminders, mcpChangeNotices, mcpDelta, serializeMcpAnnounced, type McpAnnounced, type McpSnapshot, mcpStartupNotices } from "./announce.ts";
53
- import { CONTEXT_BASELINE_CHANNEL, restoredContext } from "../lib/context-stack.ts";
52
+ import { mcpChangeNotices, mcpDelta, mcpFacts, type McpSnapshot, mcpStartupNotices } from "./announce.ts";
54
53
  import { decodeMcpKey } from "./panel/keys.ts";
55
54
  import { type McpEntry, type McpEntryStatus } from "./panel/model.ts";
56
55
  import { renderMcpPanel, type McpPaint } from "./panel/render.ts";
@@ -115,16 +114,6 @@ export default function mcpExtension(pi: ExtensionAPI) {
115
114
  // Live tool definitions, shared with in-process subagents so they reach MCP
116
115
  // through these same open connections instead of connecting their own.
117
116
  const sharedTools: ToolDefinition[] = [];
118
- // Message 1 carries the MCP instructions and failed-servers reminders. Once a
119
- // request has gone out it is part of every later request's cached prefix, so
120
- // those blocks are frozen and later changes ride one-shot notices (./announce.ts).
121
- let requestSent = false;
122
- let announced = emptyAnnounced();
123
- /** A resumed snapshot from before structured baselines: its MCP blocks, until the server names are known. */
124
- let legacyBlocks: { instructions?: string; failures?: string } | undefined;
125
- pi.on("context", () => {
126
- requestSent = true;
127
- });
128
117
 
129
118
  /**
130
119
  * Where oversized results are persisted — the session dir, matching Claude
@@ -293,10 +282,13 @@ export default function mcpExtension(pi: ExtensionAPI) {
293
282
  if (connection.resources.length > 0 || connection.resourceTemplates?.length) registerResourceTools();
294
283
  };
295
284
 
285
+ /** Servers with a connect in flight (startup, a reconnect, an authenticate): not dropped while it runs. */
286
+ const connectingNames = new Set<string>();
296
287
  const connectOne = async (server: McpServer): Promise<void> => {
297
288
  failures = failures.filter((f) => f.server.name !== server.name);
298
289
  oauthNeeded.delete(server.name);
299
290
  const provider = server.kind === "http" && hasStoredTokens(server) ? silentProvider(server) : undefined;
291
+ connectingNames.add(server.name);
300
292
  try {
301
293
  const connection = await connect(server, provider);
302
294
  if (!alive()) {
@@ -308,6 +300,8 @@ export default function mcpExtension(pi: ExtensionAPI) {
308
300
  if (!alive()) return;
309
301
  if (server.kind === "http" && isUnauthorized(error)) oauthNeeded.add(server.name);
310
302
  else failures.push({ server, error: (error as Error).message });
303
+ } finally {
304
+ connectingNames.delete(server.name);
311
305
  }
312
306
  };
313
307
 
@@ -317,66 +311,53 @@ export default function mcpExtension(pi: ExtensionAPI) {
317
311
  * which servers failed — otherwise it concludes the tools do not exist or the
318
312
  * user has no access instead of reporting a connection failure the user can
319
313
  * fix (review M4). Re-run on every connect, close, reconnect, authenticate,
320
- * enable and disable. Before the first request the two first-prepend blocks on
321
- * message 1 are (re)written; after it they are frozen and only the changes are
322
- * announced (./announce.ts).
314
+ * enable and disable. The two first-prepend blocks are live state: what an
315
+ * epoch's message 1 says. Inside an epoch the render step below announces
316
+ * only what changed since the epoch told it (./announce.ts).
323
317
  */
324
- const publishBaseline = (value: McpAnnounced) => {
325
- pi.events.emit(CONTEXT_BASELINE_CHANNEL, { key: "mcp", value: serializeMcpAnnounced(value) });
326
- };
318
+ const liveSnapshot = (): McpSnapshot => ({
319
+ connected: [...connections.values()].map((c) => ({ name: c.server.name, instructions: c.instructions })),
320
+ failed: failures.filter((f) => !connections.has(f.server.name)).map((f) => ({ name: f.server.name, error: f.error })),
321
+ });
327
322
  const emitInstructions = () => {
328
323
  const live = [...connections.values()];
329
- const snapshot: McpSnapshot = {
330
- connected: live.map((c) => ({ name: c.server.name, instructions: c.instructions })),
331
- failed: failures.filter((f) => !connections.has(f.server.name)).map((f) => ({ name: f.server.name, error: f.error })),
332
- };
333
-
334
- if (!requestSent) {
335
- // Nothing is cached yet: (re)write the standing message-1 blocks.
336
- const nextAnnounced = announcedFrom(snapshot);
337
- publishBaseline(nextAnnounced);
338
- if (snapshot.failed.length === 0) {
339
- pi.events.emit(REMINDER_CHANNEL, { scope: "every-turn", key: "mcp-failures", text: "", remove: true });
340
- } else {
341
- pi.events.emit(REMINDER_CHANNEL, {
342
- scope: "every-turn",
343
- key: "mcp-failures",
344
- text: mcpFailuresReminder(snapshot.failed),
345
- placement: "first-prepend",
346
- order: CONTEXT_ORDER.mcp + 1,
347
- });
348
- }
349
- const instructions = mcpInstructionsReminder(live);
350
- if (instructions) {
351
- pi.events.emit(REMINDER_CHANNEL, {
352
- scope: "every-turn",
353
- key: "mcp-instructions",
354
- text: instructions,
355
- placement: "first-prepend",
356
- order: CONTEXT_ORDER.mcp,
357
- });
358
- } else {
359
- // No server has instructions anymore (e.g. the last one was disabled or
360
- // disconnected) — drop the stale every-turn reminder so the model stops
361
- // being told to use tools that are gone.
362
- pi.events.emit(REMINDER_CHANNEL, { scope: "every-turn", key: "mcp-instructions", text: "", remove: true });
363
- }
364
- announced = nextAnnounced;
365
- return;
324
+ const snapshot = liveSnapshot();
325
+ if (snapshot.failed.length === 0) {
326
+ pi.events.emit(REMINDER_CHANNEL, { key: "mcp-failures", text: "", remove: true });
327
+ } else {
328
+ pi.events.emit(REMINDER_CHANNEL, {
329
+ key: "mcp-failures",
330
+ text: mcpFailuresReminder(snapshot.failed),
331
+ placement: "first-prepend",
332
+ order: CONTEXT_ORDER.mcp + 1,
333
+ facts: mcpFacts({ connected: [], failed: snapshot.failed }),
334
+ });
366
335
  }
367
-
368
- // Message 1 is frozen (rewriting it would re-cache the whole conversation):
369
- // tell the model only what changed, as one-shots where it reads next.
370
- const delta = mcpDelta(announced, snapshot);
371
- const notices = mcpChangeNotices(delta);
372
- if (notices.length > 0) {
373
- const nextAnnounced = { instructed: new Map(announced.instructed), failed: new Map(announced.failed) };
374
- applyMcpDelta(nextAnnounced, delta);
375
- publishBaseline(nextAnnounced);
336
+ const instructions = mcpInstructionsReminder(live);
337
+ if (instructions) {
338
+ pi.events.emit(REMINDER_CHANNEL, {
339
+ key: "mcp-instructions",
340
+ text: instructions,
341
+ placement: "first-prepend",
342
+ order: CONTEXT_ORDER.mcp,
343
+ facts: mcpFacts({ connected: snapshot.connected, failed: [] }),
344
+ });
345
+ } else {
346
+ // No server has instructions anymore (e.g. the last one was disabled or
347
+ // disconnected) — drop the stale block so an epoch does not tell the
348
+ // model to use tools that are gone.
349
+ pi.events.emit(REMINDER_CHANNEL, { key: "mcp-instructions", text: "", remove: true });
376
350
  }
377
- for (const text of notices) pi.events.emit(REMINDER_CHANNEL, { text });
378
- applyMcpDelta(announced, delta);
379
351
  };
352
+ // Inside an epoch, what changed since message 1 (or the last notice) rides
353
+ // the request's tail, decided when the request goes out.
354
+ pi.events.on(RENDER_CHANNEL, (data) => {
355
+ const request = data as RenderRequest;
356
+ if (request.epoch) return;
357
+ const delta = mcpDelta(request.toldBlocks, liveSnapshot(), !connectSettled, connectingNames);
358
+ const notices = mcpChangeNotices(delta);
359
+ notices.forEach((text, i) => request.blocks.push({ text, ...(i === 0 ? { facts: delta.facts } : {}) }));
360
+ });
380
361
 
381
362
  /** Config files that exist but failed to parse (review M11) — shown at startup and in /mcp. */
382
363
  let configErrors: string[] = [];
@@ -407,12 +388,6 @@ export default function mcpExtension(pi: ExtensionAPI) {
407
388
  onNote: (message) => configNotes.push(message),
408
389
  registered: registeredMcpServers(pi),
409
390
  });
410
- // An older snapshot stored only the rendered blocks: read them again by the
411
- // configured names, before any delta is taken against them.
412
- if (legacyBlocks) {
413
- announced = mcpAnnouncedFromReminders(legacyBlocks.instructions, legacyBlocks.failures, servers.map((server) => server.name));
414
- legacyBlocks = undefined;
415
- }
416
391
  const configWarnings = [
417
392
  ...(configErrors.length > 0 ? [`MCP config could not be parsed (ignored): ${configErrors.join("; ")}`] : []),
418
393
  ...configNotes,
@@ -422,14 +397,9 @@ export default function mcpExtension(pi: ExtensionAPI) {
422
397
  else process.stderr.write(`${text}\n`);
423
398
  }
424
399
  disabledNames = readDisabledMcpServers(ctx.cwd, home);
425
- // A resumed first-prepend may describe a server whose configuration was
426
- // removed entirely. There is no connection attempt in that case, so make
427
- // the frozen-prefix delta explicit rather than leaving the stale capability
428
- // silently standing. Fresh empty sessions keep their byte-identical no-op.
429
- if (servers.length === 0) {
430
- if (requestSent) emitInstructions();
431
- return;
432
- }
400
+ // With no server configured there is nothing to connect; a resumed epoch
401
+ // that told the model about one hears that it dropped at render time.
402
+ if (servers.length === 0) return;
433
403
 
434
404
  // Disabled servers are listed but never connected; a server with an unset
435
405
  // credential env var stays "needs authentication" and is not attempted
@@ -609,15 +579,6 @@ export default function mcpExtension(pi: ExtensionAPI) {
609
579
  };
610
580
 
611
581
  pi.on("session_start", async (_event, ctx) => {
612
- const restored = restoredContext(pi.events);
613
- // The restored stack owns message 1. Its structured state records the exact
614
- // arbitrary server text that was shown; a fresh session keeps prior behavior.
615
- requestSent = restored !== undefined;
616
- const stackText = (key: string) => restored?.stack.find((entry) => (entry as { key?: string }).key === key)?.text;
617
- const structured = mcpAnnouncedBaseline(restored?.baselines.mcp);
618
- legacyBlocks = structured ? undefined : { instructions: stackText("mcp-instructions"), failures: stackText("mcp-failures") };
619
- announced = structured ?? mcpAnnouncedFromReminders(legacyBlocks?.instructions, legacyBlocks?.failures);
620
- if (!restored) publishBaseline(announced);
621
582
  // pi awaits session_start handlers serially before the prompt opens, and
622
583
  // remote servers take seconds to answer — awaiting here was the entire
623
584
  // "slow startup" (4.9s → 0.24s measured, findings §15). In the interactive
@@ -32,6 +32,7 @@ import { readSettingsFile, type SettingsSource, settingsSources } from "../lib/c
32
32
  import { claudeSourcesOn } from "../lib/config-mode.ts";
33
33
  import { boundConsentItems } from "../lib/consent-preview.ts";
34
34
  import { consentStorePath } from "../lib/consent-stores.ts";
35
+ import { processState } from "../lib/process-state.ts";
35
36
  import type { McpServer } from "./config.ts";
36
37
  import { escapeControlText } from "../lib/terminal-text.ts";
37
38
 
@@ -313,8 +314,14 @@ export interface McpTrustOutcome {
313
314
  withheld: { server: McpServer; reason: "declined" | "not-approved" | "disabled-by-claude-settings" }[];
314
315
  }
315
316
 
316
- /** Process-lifetime memory per project+server+hash, so /clear (a new session_start) does not re-ask a "No". */
317
- const sessionDecisions = new Map<string, boolean>();
317
+ /**
318
+ * Process-lifetime memory per project+server+hash, so /clear (a new
319
+ * session_start) and /reload do not re-ask a "No". Process state
320
+ * (lib/process-state.ts), not module scope: pi evaluates this module again
321
+ * at `/reload` and at a `/resume` into another directory (lifecycle grid
322
+ * R3-L13).
323
+ */
324
+ const sessionDecisions = () => processState("mcp:trust-decisions", () => new Map<string, boolean>());
318
325
  const pendingPrompts = new Map<string, Promise<string | undefined>>();
319
326
 
320
327
  /**
@@ -363,7 +370,7 @@ export async function approveMcpServers(
363
370
  approved.push(server);
364
371
  continue;
365
372
  }
366
- const remembered = sessionDecisions.get(`${root}:${server.name}:${hash}`);
373
+ const remembered = sessionDecisions().get(`${root}:${server.name}:${hash}`);
367
374
  if (remembered === true) approved.push(server);
368
375
  else if (remembered === false) withheld.push({ server, reason: "declined" });
369
376
  else pending.push(server);
@@ -400,12 +407,12 @@ export async function approveMcpServers(
400
407
  const root = projectRootOf(server);
401
408
  const decisionKey = `${root}:${server.name}:${hashServerConfig(server)}`;
402
409
  if (choice === CHOICE_ALL || choice === CHOICE_THIS || choice === CHOICE_THESE) {
403
- sessionDecisions.set(decisionKey, true);
410
+ sessionDecisions().set(decisionKey, true);
404
411
  if (choice === CHOICE_ALL) persistEnableAll(root, deps.storePath);
405
412
  persistApproval(root, server, deps.storePath);
406
413
  approved.push(server);
407
414
  } else {
408
- sessionDecisions.set(decisionKey, false);
415
+ sessionDecisions().set(decisionKey, false);
409
416
  withheld.push({ server, reason: "declined" });
410
417
  // An explicit "No" is remembered as a disable; Escape is this session only.
411
418
  if (choice === CHOICE_NO) deps.disable(server);
@@ -419,6 +426,6 @@ export async function approveMcpServers(
419
426
 
420
427
  /** Test seam. */
421
428
  export function resetMcpTrustSessionState(): void {
422
- sessionDecisions.clear();
429
+ sessionDecisions().clear();
423
430
  pendingPrompts.clear();
424
431
  }
@@ -21,12 +21,24 @@
21
21
  * sessions", and since persisting is what this extension does, the claim is
22
22
  * true here too (a failed write announces the switch without it). pi handles
23
23
  * `/model` before any extension sees the text, so `model_select` — which also
24
- * covers ctrl+p cycling — is the one signal; the debounce means only the model
25
- * the user settles on is announced.
24
+ * covers ctrl+p cycling — is the one signal.
25
+ *
26
+ * The write and the breadcrumb wait for the model the user settles on: the
27
+ * quiet period after the last switch, or the next prompt, whichever comes
28
+ * first. So a ctrl+p cycle through ten models writes the settings once and
29
+ * queues one breadcrumb, which names the write's real outcome. A quit,
30
+ * /reload, /new or /resume inside the quiet period still writes the settings,
31
+ * but cannot queue the breadcrumb: system-reminder stops persisting one-shots
32
+ * in its `session_shutdown` handler, which runs before this extension's. The
33
+ * choice is recorded on the branch instead, and the next session on that
34
+ * branch (the reload, a resume) announces it while its model is still the
35
+ * one chosen.
26
36
  */
27
37
 
28
38
  import { join } from "node:path";
29
39
  import { getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
40
+ import { isObject } from "../lib/ledger/records.ts";
41
+ import { readState, recordState } from "../lib/ledger/state.ts";
30
42
  import { announceLocalCommand } from "../lib/local-command.ts";
31
43
  import { readSettingsForWrite, writeSettings } from "../lib/one-code-settings.ts";
32
44
 
@@ -44,34 +56,65 @@ export function modelSwitchStdout(displayName: string, saved: boolean): string {
44
56
  return `Set model to \`${displayName}\`${saved ? " and saved as your default for new sessions" : ""}`;
45
57
  }
46
58
 
47
- /** Quiet period after the last switch before it is written (ctrl+p cycles fire one event per step). */
59
+ /** Quiet period after the last switch before it is written and announced (ctrl+p cycles fire one event per step). */
48
60
  export const PERSIST_DEBOUNCE_MS = 400;
49
61
 
62
+ /** The `/model` breadcrumb's pending-entry key: a later switch's breadcrumb replaces an undelivered one. */
63
+ const BREADCRUMB_KEY = "local-command:model";
64
+
65
+ /**
66
+ * The `one-code:state` key of a switch not yet announced: recorded at every
67
+ * switch (`saved` unset until the settings write), so the branch, not this
68
+ * process's timer, says a breadcrumb is owed; null once announced or dropped.
69
+ */
70
+ const UNANNOUNCED_STATE = "model-breadcrumb";
71
+
72
+ /** A settled switch, as announced. */
73
+ interface Settled {
74
+ provider: string;
75
+ id: string;
76
+ name: string;
77
+ saved: boolean;
78
+ }
79
+
80
+ /** A switch the branch records, as `model_select` or a shutdown left it. */
81
+ type Unannounced = Omit<Settled, "saved"> & { saved?: boolean };
82
+
50
83
  export default function modelDefaultExtension(pi: ExtensionAPI) {
51
84
  let pending: { provider: string; id: string; name: string; notify: (message: string) => void } | undefined;
52
85
  let timer: ReturnType<typeof setTimeout> | undefined;
86
+ const announce = (name: string, saved: boolean) => announceLocalCommand(pi, { name: "model", stdout: modelSwitchStdout(name, saved), key: BREADCRUMB_KEY });
53
87
 
54
- const flush = () => {
55
- if (timer !== undefined) clearTimeout(timer);
56
- timer = undefined;
57
- const choice = pending;
58
- pending = undefined;
59
- if (!choice) return;
88
+ /** Save `provider/id` as the default model, and say whether it was saved. */
89
+ const save = (provider: string, id: string, notify: (message: string) => void): boolean => {
60
90
  const path = join(getAgentDir(), "settings.json");
61
- let saved = true;
62
91
  try {
63
- writeSettings(path, applyDefaultModel(readSettingsForWrite(path), choice.provider, choice.id));
92
+ writeSettings(path, applyDefaultModel(readSettingsForWrite(path), provider, id));
93
+ return true;
64
94
  } catch (error) {
65
95
  // Persisting a preference must not break the model switch itself, but
66
- // say why it will be forgotten (typically a malformed settings.json).
67
- saved = false;
68
- choice.notify(`Could not save ${choice.provider}/${choice.id} as the default model: ${(error as Error).message}`);
96
+ // say why it will be forgotten.
97
+ notify(`Could not save ${provider}/${id} as the default model: ${(error as Error).message}`);
98
+ return false;
69
99
  }
70
- announceLocalCommand(pi, { name: "model", stdout: modelSwitchStdout(choice.name, saved) });
71
100
  };
72
101
 
73
- // Debounced: a ctrl+p cycle emits model_select per step, and only the model
74
- // the user settles on should hit the disk (last one wins).
102
+ /** Write the switch the user settled on, if any, and say whether it was saved. */
103
+ const settle = (): Settled | undefined => {
104
+ if (timer !== undefined) clearTimeout(timer);
105
+ timer = undefined;
106
+ const choice = pending;
107
+ pending = undefined;
108
+ if (!choice) return undefined;
109
+ return { provider: choice.provider, id: choice.id, name: choice.name, saved: save(choice.provider, choice.id, choice.notify) };
110
+ };
111
+ const flush = () => {
112
+ const settled = settle();
113
+ if (!settled) return;
114
+ announce(settled.name, settled.saved);
115
+ recordState(pi, UNANNOUNCED_STATE, null);
116
+ };
117
+
75
118
  pi.on("model_select", (event, ctx) => {
76
119
  const model = event.model as { provider?: string; id?: string; name?: string } | undefined;
77
120
  if (!model?.provider || !model.id) return;
@@ -83,11 +126,35 @@ export default function modelDefaultExtension(pi: ExtensionAPI) {
83
126
  if (ctx.hasUI) ctx.ui.notify(message, "warning");
84
127
  },
85
128
  };
129
+ // Owed from now on, whatever happens to this process: a fresh one on a
130
+ // copy of the branch owes the same breadcrumb (lifecycle-grid-hunt oracle).
131
+ recordState(pi, UNANNOUNCED_STATE, { provider: model.provider, id: model.id, name: pending.name } satisfies Unannounced);
86
132
  if (timer !== undefined) clearTimeout(timer);
87
133
  timer = setTimeout(flush, PERSIST_DEBOUNCE_MS);
88
134
  timer.unref?.();
89
135
  });
90
136
 
91
- // A quit inside the quiet period must not lose the choice.
92
- pi.on("session_shutdown", () => flush());
137
+ // A prompt sent inside the quiet period carries the switch's breadcrumb, not the request after it.
138
+ pi.on("before_agent_start", () => flush());
139
+
140
+ // A quit inside the quiet period must not lose the choice: the write happens
141
+ // now, and the breadcrumb waits on the branch for the next session.
142
+ pi.on("session_shutdown", () => {
143
+ const settled = settle();
144
+ if (settled) recordState(pi, UNANNOUNCED_STATE, settled);
145
+ });
146
+ // A switch the branch still owes a breadcrumb: announced here when the
147
+ // session runs that model, saved first if its own process never got to it.
148
+ pi.on("session_start", (_event, ctx) => {
149
+ const stored = readState(ctx, UNANNOUNCED_STATE);
150
+ if (!isObject(stored)) return;
151
+ recordState(pi, UNANNOUNCED_STATE, null);
152
+ const { provider, id, name, saved } = stored;
153
+ if (typeof provider !== "string" || typeof id !== "string" || typeof name !== "string") return;
154
+ if (ctx.model?.provider !== provider || ctx.model?.id !== id) return;
155
+ const notify = (message: string) => {
156
+ if (ctx.hasUI) ctx.ui.notify(message, "warning");
157
+ };
158
+ announce(name, typeof saved === "boolean" ? saved : save(provider, id, notify));
159
+ });
93
160
  }