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.
- package/README.md +1 -1
- package/docs/guide/differences-from-claude-code.md +9 -6
- package/docs/guide/installation.md +3 -4
- package/docs/guide/troubleshooting.md +1 -1
- package/extensions/auto-mode/denials.ts +3 -2
- package/extensions/background/cron.ts +127 -0
- package/extensions/background/index.ts +135 -47
- package/extensions/background/loop-fire.ts +39 -2
- package/extensions/background/switch-guard.ts +10 -6
- package/extensions/background/wakeup.ts +21 -2
- package/extensions/bash/index.ts +8 -30
- package/extensions/btw/index.ts +32 -18
- package/extensions/cache-marks/index.ts +42 -0
- package/extensions/claude-context/index.ts +157 -172
- package/extensions/compaction/index.ts +11 -8
- package/extensions/context-budget/budget.ts +16 -2
- package/extensions/context-budget/index.ts +15 -14
- package/extensions/context-management/index.ts +11 -28
- package/extensions/effort/index.ts +70 -7
- package/extensions/file-tracker/index.ts +111 -44
- package/extensions/file-tracker/replay.ts +76 -49
- package/extensions/file-tracker/restore.ts +12 -9
- package/extensions/file-tracker/tracker.ts +14 -1
- package/extensions/hooks/index.ts +60 -76
- package/extensions/hooks/prompt-context.ts +59 -0
- package/extensions/hooks/trust.ts +13 -7
- package/extensions/ledger/index.ts +46 -0
- package/extensions/ledger/wire.ts +154 -0
- package/extensions/lib/agent-cron.ts +17 -1
- package/extensions/lib/agent-loader.ts +3 -2
- package/extensions/lib/anthropic-auth.ts +98 -0
- package/extensions/lib/child-extensions.ts +14 -4
- package/extensions/lib/context-facts.ts +75 -56
- package/extensions/lib/covered-messages.ts +23 -0
- package/extensions/lib/deferred.ts +152 -12
- package/extensions/lib/ledger/fold-memo.ts +49 -0
- package/extensions/lib/ledger/fold.ts +265 -0
- package/extensions/lib/ledger/layout.ts +72 -0
- package/extensions/lib/ledger/plan.ts +102 -0
- package/extensions/lib/ledger/records.ts +276 -0
- package/extensions/lib/ledger/state.ts +47 -0
- package/extensions/lib/ledger/validate.ts +141 -0
- package/extensions/lib/ledger/wire.ts +632 -0
- package/extensions/lib/local-command.ts +24 -6
- package/extensions/lib/model-catalog/huggingface.json +5 -1
- package/extensions/lib/model-catalog/models-dev.json +10535 -10299
- package/extensions/lib/model-catalog/openrouter.json +191 -393
- package/extensions/lib/notifications.ts +57 -17
- package/extensions/lib/pi-version.ts +25 -8
- package/extensions/lib/plan-mode-channels.ts +29 -7
- package/extensions/lib/previous-mark.ts +101 -0
- package/extensions/lib/process-state.ts +48 -0
- package/extensions/lib/process-tree.ts +3 -2
- package/extensions/lib/reminders.ts +327 -621
- package/extensions/lib/request-replay.ts +186 -78
- package/extensions/lib/side-call.ts +11 -0
- package/extensions/lib/subagent-handback.ts +0 -1
- package/extensions/lib/tool-additions.ts +2 -22
- package/extensions/lib/turn-budget-layout.ts +18 -2
- package/extensions/lsp/index.ts +6 -8
- package/extensions/mcp/announce.ts +63 -98
- package/extensions/mcp/index.ts +51 -90
- package/extensions/mcp/trust.ts +13 -6
- package/extensions/model-default/index.ts +86 -19
- package/extensions/permissions/index.ts +157 -48
- package/extensions/permissions/modes.ts +5 -0
- package/extensions/plan-mode/index.ts +142 -45
- package/extensions/plan-mode/run-state.ts +26 -0
- package/extensions/plugins/index.ts +4 -1
- package/extensions/powershell/index.ts +2 -0
- package/extensions/recap/index.ts +11 -14
- package/extensions/search-tools/index.ts +2 -0
- package/extensions/skill/announce.ts +30 -0
- package/extensions/skill/index.ts +65 -48
- package/extensions/skill/invoke.ts +0 -67
- package/extensions/subagents/agents.ts +30 -8
- package/extensions/subagents/fork-cache.ts +9 -3
- package/extensions/subagents/index.ts +180 -107
- package/extensions/subagents/outcome.ts +3 -0
- package/extensions/subagents/parent-mode.ts +50 -0
- package/extensions/subagents/pending-claim.ts +18 -1
- package/extensions/subagents/runner.ts +29 -2
- package/extensions/subagents/runs.ts +5 -0
- package/extensions/system-prompt/index.ts +40 -6
- package/extensions/system-prompt/template.ts +10 -1
- package/extensions/system-reminder/index.ts +174 -202
- package/extensions/tool-search/index.ts +241 -167
- package/extensions/workflow/agent-session.ts +4 -1
- package/extensions/workflow/index.ts +16 -4
- package/extensions/workflow/run-manager.ts +4 -0
- package/extensions/worktree/index.ts +50 -14
- package/package.json +5 -2
- package/extensions/lib/context-stack.ts +0 -81
- package/extensions/subagents/announce.ts +0 -69
- package/extensions/tool-search/announce.ts +0 -58
|
@@ -1,24 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* What to tell the model when MCP state changes
|
|
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.
|
|
6
|
-
* message 1 is
|
|
7
|
-
*
|
|
8
|
-
* deferred-tools listing
|
|
9
|
-
*
|
|
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
|
-
|
|
39
|
-
|
|
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
|
|
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
|
-
|
|
57
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
/**
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
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
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
.map((
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
124
|
+
add(mcpFailuresReminder(delta.newFailures));
|
|
160
125
|
}
|
|
161
126
|
if (delta.dropped.length > 0) {
|
|
162
|
-
|
|
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
|
);
|
package/extensions/mcp/index.ts
CHANGED
|
@@ -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 {
|
|
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.
|
|
321
|
-
* message 1
|
|
322
|
-
*
|
|
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
|
|
325
|
-
|
|
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
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
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
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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
|
-
//
|
|
426
|
-
//
|
|
427
|
-
|
|
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
|
package/extensions/mcp/trust.ts
CHANGED
|
@@ -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
|
-
/**
|
|
317
|
-
|
|
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
|
|
25
|
-
*
|
|
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
|
-
|
|
55
|
-
|
|
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),
|
|
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
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
74
|
-
|
|
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
|
|
92
|
-
pi.on("
|
|
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
|
}
|