@makerstufferupper/flypi 0.0.0-stage → 0.1.3

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 (75) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +64 -2
  3. package/assets/screenshot.png +0 -0
  4. package/bin/flypi.mjs +28 -0
  5. package/dist/ui/assets/index-CG1CuGxu.js +105 -0
  6. package/dist/ui/assets/index-qaKjH5fH.css +1 -0
  7. package/dist/ui/flypi-logo-dark.jpeg +0 -0
  8. package/dist/ui/flypi-logo-light.jpeg +0 -0
  9. package/dist/ui/index.html +17 -0
  10. package/package.json +71 -5
  11. package/scripts/benchmark-nli.ts +273 -0
  12. package/scripts/flypi.mjs +310 -0
  13. package/scripts/probe-turn-boundary.ts +97 -0
  14. package/scripts/probe.ts +119 -0
  15. package/scripts/record-transcript.ts +63 -0
  16. package/scripts/reembed-memory.ts +41 -0
  17. package/scripts/release.mjs +38 -0
  18. package/scripts/secret.ts +24 -0
  19. package/scripts/serve-daemon.ts +101 -0
  20. package/scripts/serve-memory.ts +78 -0
  21. package/scripts/serve.ts +89 -0
  22. package/scripts/smoke-memory.mjs +239 -0
  23. package/scripts/smoke-secrets.mjs +153 -0
  24. package/scripts/smoke.mjs +59 -0
  25. package/scripts/start.mjs +294 -0
  26. package/scripts/turn-boundary.ts +52 -0
  27. package/scripts/update.ts +92 -0
  28. package/scripts/warm-models.ts +41 -0
  29. package/src/agent/config-files.ts +529 -0
  30. package/src/agent/mcp-signin.ts +137 -0
  31. package/src/agent/mcp-status.ts +168 -0
  32. package/src/agent/onboarding.ts +74 -0
  33. package/src/agent/packages.ts +150 -0
  34. package/src/agent/paths.ts +80 -0
  35. package/src/agent/permission-gate.ts +70 -0
  36. package/src/agent/pi-mcp.ts +97 -0
  37. package/src/agent/runtime.ts +347 -0
  38. package/src/daemon/index.ts +442 -0
  39. package/src/daemon/jobs.ts +202 -0
  40. package/src/daemon/update.ts +248 -0
  41. package/src/memory/capture.ts +109 -0
  42. package/src/memory/config.ts +90 -0
  43. package/src/memory/consolidate.ts +218 -0
  44. package/src/memory/db.ts +393 -0
  45. package/src/memory/embed.ts +154 -0
  46. package/src/memory/judge.ts +167 -0
  47. package/src/memory/mcp.ts +496 -0
  48. package/src/memory/media.ts +135 -0
  49. package/src/memory/progress.ts +39 -0
  50. package/src/memory/recall.ts +528 -0
  51. package/src/memory/review.ts +86 -0
  52. package/src/memory/store.ts +1034 -0
  53. package/src/memory/wiki.ts +140 -0
  54. package/src/secrets/cli.ts +248 -0
  55. package/src/secrets/config.ts +133 -0
  56. package/src/secrets/crypto.ts +180 -0
  57. package/src/secrets/extension.ts +253 -0
  58. package/src/secrets/redact.ts +132 -0
  59. package/src/secrets/vault.ts +396 -0
  60. package/src/server/event-log.ts +49 -0
  61. package/src/server/extension-ui.ts +118 -0
  62. package/src/server/index.ts +398 -0
  63. package/src/server/memory-api.ts +158 -0
  64. package/src/server/protocol.ts +947 -0
  65. package/src/server/run-store.ts +136 -0
  66. package/src/server/secrets-api.ts +88 -0
  67. package/src/server/session-registry.ts +593 -0
  68. package/src/shared/attachments.ts +40 -0
  69. package/src/shared/cache-probe.ts +47 -0
  70. package/src/shared/markdown.ts +17 -0
  71. package/src/shared/metrics.ts +212 -0
  72. package/src/shared/sqlite.ts +74 -0
  73. package/src/shared/transcript.ts +404 -0
  74. package/src/shared/turn-boundary.ts +98 -0
  75. package/src/shared/views.ts +31 -0
@@ -0,0 +1,70 @@
1
+ /**
2
+ * The permission gate (docs/architecture.md Phase 5): a tool on flypi's ask-before-use list does not run
3
+ * until the user has put it there. `permissions.askBeforeUse` in `.flypi/agent/settings.json`, next to the
4
+ * `memory` block src/memory/config.ts reads — one place for what flypi asks pi to do.
5
+ *
6
+ * The plan said "using tool annotations". It cannot: pi hands a `tool_call` handler a tool *name* and its
7
+ * arguments, and the MCP annotations a server announces (`destructiveHint`, `openWorldHint`) stay inside
8
+ * pi's MCP extension, which exposes none of its connection state to the host (docs/pi-api.md). So the list
9
+ * is of tool names, and the status panel (src/agent/mcp-status.ts) is where the annotations surface — a
10
+ * user reads "this tool says it is destructive" there and writes its name here.
11
+ *
12
+ * The list is re-read on every call rather than captured at session start, so the loop closes without a
13
+ * restart: the model is told the call was gated, the user adds the name in the file, and the next call runs.
14
+ */
15
+ import { readFileSync } from "node:fs";
16
+ import type { ExtensionFactory } from "@earendil-works/pi-coding-agent";
17
+ import type { FlypiPaths } from "./paths.ts";
18
+
19
+ /** pi's own result shape for a blocked call (`ToolCallEventResult`). */
20
+ export const blockedToolCall = (reason: string) => ({ block: true, reason });
21
+
22
+ const BLOCKED = (toolName: string) =>
23
+ blockedToolCall(
24
+ `flypi did not run "${toolName}": it needs your approval first (it is on permissions.askBeforeUse in ` +
25
+ `.flypi/agent/settings.json). Ask the user whether this call may run; if they agree, they add "${toolName}" to that list and you can call it again.`,
26
+ );
27
+
28
+ /**
29
+ * `permissions.askBeforeUse` out of a parsed settings.json. Anything that is not a non-empty string is
30
+ * dropped: this file is also pi's, and a stray key there must not become a gate that matches nothing.
31
+ */
32
+ export function askBeforeUseOf(settings: unknown): string[] {
33
+ const permissions = (settings as { permissions?: { askBeforeUse?: unknown } } | null)?.permissions;
34
+ const list = Array.isArray(permissions?.askBeforeUse) ? permissions.askBeforeUse : [];
35
+ return list.filter((entry): entry is string => typeof entry === "string" && entry.trim() !== "").map((entry) => entry.trim());
36
+ }
37
+
38
+ /**
39
+ * Whether a tool is on the list. A name matches exactly; a name ending in `*` matches that prefix, which is
40
+ * how a whole server is gated (`mcp__filesystem__*`) without listing every tool it may grow.
41
+ */
42
+ export function needsApproval(list: readonly string[], toolName: string): boolean {
43
+ return list.some((entry) => (entry.endsWith("*") ? toolName.startsWith(entry.slice(0, -1)) : entry === toolName));
44
+ }
45
+
46
+ /**
47
+ * The extension that enforces it. Registered by `createFlypiSession` like the other flypi extensions, so
48
+ * every session — the daemon's, `npm run serve`'s, a test's — is gated the same way.
49
+ */
50
+ export function createPermissionGateExtension(paths: FlypiPaths): ExtensionFactory {
51
+ return (pi) => {
52
+ pi.on("tool_call", (event) => {
53
+ if (!needsApproval(readList(paths.settingsPath), event.toolName)) return;
54
+ return BLOCKED(event.toolName);
55
+ });
56
+ };
57
+ }
58
+
59
+ /**
60
+ * Read the list now, so an edit takes effect on the next call. A settings.json that will not parse asks for
61
+ * nothing: pi itself would have refused to start the conversation over that file, and a gate that guessed at
62
+ * a name would block tools nobody asked to gate.
63
+ */
64
+ function readList(settingsPath: string): string[] {
65
+ try {
66
+ return askBeforeUseOf(JSON.parse(readFileSync(settingsPath, "utf8")));
67
+ } catch {
68
+ return [];
69
+ }
70
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * pi's MCP OAuth machinery, reached at the file pi's own MCP extension loads it from.
3
+ *
4
+ * pi does not export any of this from its package entry: `createMcpExtension()` is the only public door to
5
+ * the code, and it runs sign-in solely through pi's TUI (`/mcp`, which draws a terminal dialog). flypi signs
6
+ * a user in from the settings view of a headless server, so it calls the same function the extension calls —
7
+ * `signInMcpServer` — with a prompt of its own: the URL goes to the browser, the redirect comes back as a
8
+ * paste (docs/architecture.md Phase 5, risk 5).
9
+ *
10
+ * The shapes below are copied from pi's `.d.ts` files, which the package's `exports` map keeps unreachable
11
+ * as well. They describe four pi functions and nothing more; re-check them against
12
+ * `dist/extensions/mcp/oauth.d.ts` after a pi upgrade (docs/pi-api.md).
13
+ */
14
+ import { getPackageDir, type McpExtensionOptions, type McpServerConfig } from "@earendil-works/pi-coding-agent";
15
+ import { join } from "node:path";
16
+ import { pathToFileURL } from "node:url";
17
+ import type { FlypiPaths } from "./paths.ts";
18
+
19
+ /** pi's `McpOAuthCredentialStore`, which `createMcpExtension({ credentials })` takes. */
20
+ export type PiMcpCredentials = NonNullable<McpExtensionOptions["credentials"]>;
21
+
22
+ /** What an authorization needs: pi's `McpOAuthSettings`, i.e. the `oauth` block of an `mcp.json` entry. */
23
+ export interface PiMcpOAuthSettings {
24
+ clientId?: string;
25
+ /** Already resolved — `${NAME}` and `!command` expanded. */
26
+ clientSecret?: string;
27
+ callbackPort?: number;
28
+ callbackUrl?: string;
29
+ scope?: string;
30
+ clientName?: string;
31
+ clientRegistration?: "dcr" | "cimd";
32
+ authServerMetadataUrl?: URL;
33
+ }
34
+
35
+ /** The two ways a sign-in finishes: the browser reaches the loopback callback, or the user pastes what it landed on. */
36
+ export interface PiMcpSignInPrompt {
37
+ showAuthorizationUrl(url: URL): void;
38
+ /** `undefined` or "" cancels. Abandoned once the loopback callback arrives. */
39
+ promptForRedirectUrl(signal: AbortSignal): Promise<string | undefined>;
40
+ }
41
+
42
+ /** pi's `McpOAuthCredentialStore.forServer()` — the per-server state handle `signInMcpServer` takes. */
43
+ export interface PiMcpServerStore {
44
+ load(): unknown;
45
+ save(state: { serverUrl: string; tokens?: { access_token?: string; token_type?: string } }): void | Promise<void>;
46
+ }
47
+
48
+ export interface PiMcpRuntime {
49
+ signInMcpServer(options: { serverUrl: string; store: PiMcpServerStore; settings: PiMcpOAuthSettings; prompt: PiMcpSignInPrompt }): Promise<void>;
50
+ McpOAuthCredentialStore: new (backend: unknown, lockDir: string) => PiMcpCredentials & {
51
+ /** What a sign-in stored for this server, or nothing. Synchronous, like pi's own. */
52
+ tokens(name: string, serverUrl: string): { access_token?: string } | undefined;
53
+ forServer(name: string, serverUrl: string): PiMcpServerStore;
54
+ };
55
+ FileAuthStorageBackend: new (filePath: string) => unknown;
56
+ McpSignInCancelledError: new (...args: never[]) => Error;
57
+ /** `${NAME}` / `!command` → the value, or a thrown error naming what is missing. */
58
+ resolveConfigValueOrThrow: (value: string, description: string) => string;
59
+ }
60
+
61
+ let cached: Promise<PiMcpRuntime> | undefined;
62
+
63
+ /** Loaded once per process, and only when something actually needs it: this pulls in pi's MCP client. */
64
+ export function loadPiMcpRuntime(): Promise<PiMcpRuntime> {
65
+ const dir = getPackageDir();
66
+ return (cached ??= Promise.all([
67
+ import(pathToFileURL(join(dir, "dist/extensions/mcp/runtime.js")).href),
68
+ import(pathToFileURL(join(dir, "dist/core/auth-storage.js")).href),
69
+ import(pathToFileURL(join(dir, "dist/core/resolve-config-value.js")).href),
70
+ ]).then(([runtime, authStorage, configValue]) => ({
71
+ signInMcpServer: runtime.signInMcpServer,
72
+ McpOAuthCredentialStore: runtime.McpOAuthCredentialStore,
73
+ FileAuthStorageBackend: authStorage.FileAuthStorageBackend,
74
+ McpSignInCancelledError: runtime.McpSignInCancelledError,
75
+ resolveConfigValueOrThrow: configValue.resolveConfigValueOrThrow,
76
+ })));
77
+ }
78
+
79
+ /**
80
+ * The credential store, pointed at flypi's own agent dir. pi's default is `mcp-auth.json` in *its* agent dir
81
+ * (`$PI_CODING_AGENT_DIR`, else `~/.pi/agent`), which is the config bleed this repo refuses everywhere else
82
+ * (risk 1) — so every consumer passes this: a session's connections, the status probe, and a sign-in.
83
+ */
84
+ export async function mcpCredentials(paths: FlypiPaths): Promise<PiMcpCredentials & { tokens(name: string, serverUrl: string): { access_token?: string } | undefined }> {
85
+ const pi = await loadPiMcpRuntime();
86
+ return new pi.McpOAuthCredentialStore(new pi.FileAuthStorageBackend(paths.mcpAuthPath), paths.agentDir);
87
+ }
88
+
89
+ /**
90
+ * Whether pi authenticates this entry with OAuth: an HTTP server that sends no `Authorization` header of its
91
+ * own and delegates to no provider. That is pi's own rule, copied because the probe and the sign-in have to
92
+ * agree with what a session will do — otherwise the panel offers a sign-in the file will ignore.
93
+ */
94
+ export function usesMcpOAuth(config: McpServerConfig): boolean {
95
+ if (!("url" in config) || config.auth?.provider) return false;
96
+ return !Object.keys(config.headers ?? {}).some((header) => header.toLowerCase() === "authorization");
97
+ }
@@ -0,0 +1,347 @@
1
+ import { existsSync, mkdirSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import {
4
+ createAgentSession,
5
+ createMcpExtension,
6
+ createToolSearchExtension,
7
+ DefaultResourceLoader,
8
+ type LoadedMcpConfig,
9
+ type McpServerConfig,
10
+ type McpServerEntry,
11
+ ModelRuntime,
12
+ type CreateAgentSessionOptions,
13
+ type ExtensionUIContext,
14
+ SessionManager,
15
+ SettingsManager,
16
+ } from "@earendil-works/pi-coding-agent";
17
+ import { assertFlypiOwned, flypiPaths, type FlypiPaths } from "./paths.ts";
18
+ import { mcpCredentials, usesMcpOAuth } from "./pi-mcp.ts";
19
+ import { importEnvCredential, readAuth, resolveCredential } from "./onboarding.ts";
20
+ import { memoryConfig } from "../memory/config.ts";
21
+ import { createMemoryRecallExtension, memoryInstruction, memoryTool, recallServer } from "../memory/recall.ts";
22
+ import { backgroundMount, reviewPrompt, type ReviewParent } from "../memory/review.ts";
23
+ import { createPermissionGateExtension } from "./permission-gate.ts";
24
+ import { createSecretsExtension } from "../secrets/extension.ts";
25
+ import type { SecretStore } from "../secrets/vault.ts";
26
+
27
+ /** Derived from pi's own option types, so no transitive pi-ai / pi-agent-core import is needed here. */
28
+ export type FlypiModel = NonNullable<CreateAgentSessionOptions["model"]>;
29
+ export type FlypiThinkingLevel = NonNullable<CreateAgentSessionOptions["thinkingLevel"]>;
30
+ export type FlypiSession = NonNullable<Awaited<ReturnType<typeof createAgentSession>>["session"]>;
31
+
32
+ /** A configuration problem the user can act on. probe.ts prints `message`, never a stack trace. */
33
+ export class FlypiConfigError extends Error {}
34
+
35
+ /**
36
+ * The chat renders markdown (ui/Chat.svelte, src/shared/markdown.ts) and nothing in pi's prompt asks
37
+ * for it — pi's only answer-shaping rules are "be concise" and "show file paths clearly" — so the
38
+ * format the renderer was built for is stated once, here. The loader's `appendSystemPrompt` becomes
39
+ * the prompt's addendum section, so it is byte-stable across turns like the rest of the system prompt.
40
+ */
41
+ export const ANSWER_FORMAT_PROMPT =
42
+ "Answer in markdown — this interface renders it: headings for structure, fenced code blocks for code and command output, tables for comparisons, backticks for file paths and identifiers.";
43
+
44
+ /**
45
+ * The one place flypi creates an `AgentSession`: probe, the WS server, and the tests all go
46
+ * through here, so the config-isolation invariant (docs/risks.md risk 1) is enforced once.
47
+ *
48
+ * `modelRuntime`/`model` are overridable so a test can pass the stub model
49
+ * (tests/helpers/stub-model.ts) instead of a real provider.
50
+ */
51
+ export async function createFlypiSession(options: {
52
+ root?: string;
53
+ modelRuntime?: ModelRuntime;
54
+ model?: FlypiModel;
55
+ thinkingLevel?: FlypiThinkingLevel;
56
+ tools?: string[];
57
+ env?: Record<string, string | undefined>;
58
+ /** Supply an existing or opened session instead of starting a new one. */
59
+ sessionManager?: SessionManager;
60
+ /**
61
+ * What MCP servers this session gets, instead of what `.flypi/agent/mcp.json` says. Only the probe's
62
+ * `--no-memory` control needs it (docs/prompt-cache.md C1): renaming the real file aside to run the
63
+ * control left the memory server unconfigured whenever the process was killed mid-run
64
+ * (code-review.md finding 1), and the config is not something a measurement is allowed to edit.
65
+ */
66
+ mcpConfig?: LoadedMcpConfig;
67
+ /**
68
+ * What automatic recall put in front of the model this turn, handed to the caller as it happens. The block
69
+ * itself is a session entry now (Phase 4.7 C2), but this is still how the debug panel learns *which* block
70
+ * the turn in front of it was shown, and the one thing it can never read out of the file is the note a
71
+ * failed search left on the message (docs/memory.md Phase 4).
72
+ */
73
+ onMemoryBlock?: (block: string) => void;
74
+ /**
75
+ * flypi's own id for the conversation, so the turns the memory store records are keyed by the same id the
76
+ * UI and the registry use (docs/memory.md Phase 4). Left out, nothing is recorded about the turns.
77
+ */
78
+ conversationId?: string;
79
+ /**
80
+ * The vault (docs/secrets.md Phase 5.5). Given, the session can reach it and every tool result, memory body
81
+ * and answer in it is redacted against it; omitted, the conversation has no secret tools at all — which is
82
+ * what a process that never opened the vault (a bare `probe.ts`, a unit test) should get.
83
+ */
84
+ vault?: SecretStore;
85
+ /**
86
+ * This is a conversation a human is in, so it may spawn a review fork after every `memory.reviewInterval`
87
+ * delivered answers (docs/memory.md Phase 4.8). Off unless asked for: a probe, a daemon job, a subagent and
88
+ * a fork itself never spawn one, which is the whole suppression rule — there is no runner to fire.
89
+ */
90
+ review?: boolean;
91
+ /**
92
+ * The conversation's extension UI (src/server/extension-ui.ts): what `ctx.ui.notify/select/confirm/input`
93
+ * do. Absent, pi binds its no-op context and an extension command's output goes nowhere — which is what a
94
+ * process with no page attached (a probe, a fork) should get.
95
+ */
96
+ extensionUi?: ExtensionUIContext;
97
+ /**
98
+ * Automatic recall (src/memory/recall.ts). False for a review fork: it inherits the parent's memory block
99
+ * rather than searching, logging a turn of its own, or auto-capturing over a conversation that is not its
100
+ * own. The MCP server is still connected, so the fork can still write what it learned.
101
+ */
102
+ memoryRecall?: boolean;
103
+ } = {}): Promise<{ session: FlypiSession; paths: FlypiPaths; modelRuntime: ModelRuntime }> {
104
+ const paths = flypiPaths(options.root ?? process.cwd());
105
+ assertFlypiOwned(paths);
106
+
107
+ const modelRuntime =
108
+ options.modelRuntime ??
109
+ (await ModelRuntime.create({ authPath: paths.authPath, modelsPath: paths.modelsPath }));
110
+
111
+ const sessionManager = options.sessionManager ?? SessionManager.create(paths.root, paths.sessionsDir);
112
+ // A model the caller injects (the stub-model tests) wins. Otherwise the settings default is read — but
113
+ // it is *passed* only when the session has nothing to restore: createAgentSession restores a resumed
114
+ // session's own model from its `model_change` entries when no model is given, and answering over that
115
+ // is why a reopened chat went back to the default model while pi kept its thinking level. The settings
116
+ // default stays the fallback for a recorded model that can no longer be resolved, and pi's own
117
+ // findInitialModel applies it — so a session with no messages still starts where flypi starts one.
118
+ const resuming = sessionManager.buildSessionContext().messages.length > 0;
119
+ const model = options.model ?? (resuming ? undefined : resolveConfiguredModel(paths, modelRuntime, options.env));
120
+
121
+ // MCP is not part of an SDK session unless the host asks for it (docs/sdk.md), and pi's own MCP
122
+ // config loader reads `$PI_CODING_AGENT_DIR` (else `~/.pi/agent`) rather than the `agentDir` passed
123
+ // below — so both the extension and where it reads from are flypi's job (docs/risks.md risk 1).
124
+ const mcpConfig = options.mcpConfig ?? readFlypiMcpConfig(paths);
125
+ // pi keeps an OAuth server's tokens in `mcp-auth.json` in *its* agent dir, which for flypi would be the
126
+ // host `~/.pi/agent` — the same config bleed as the config file itself (risk 1). Only built when an entry
127
+ // could use it, because loading pi's OAuth machinery means loading its MCP client.
128
+ const mcpCredentialsStore = mcpConfig.servers.some((server) => server.config.enabled !== false && usesMcpOAuth(server.config))
129
+ ? await mcpCredentials(paths)
130
+ : undefined;
131
+ // Resolved once, and used for both halves of the memory wiring: the instruction below and the recall
132
+ // extension have to agree about whether this session has a memory server at all.
133
+ const memoryServer = recallServer(mcpConfig);
134
+ // The review fork (docs/memory.md Phase 4.8): a conversation a human is in, a store to write to, and an
135
+ // interval that is not switched off. `session` is read from inside the closure, which runs turns later.
136
+ const reviewInterval = options.review && memoryServer ? memoryConfig(paths).review.interval : 0;
137
+ const review =
138
+ reviewInterval > 0 && memoryServer
139
+ ? {
140
+ interval: reviewInterval,
141
+ fork: (parent: ReviewParent) =>
142
+ runReviewFork({ paths, modelRuntime, mcpConfig, memoryServer, vault: options.vault, tools: options.tools, parentSession: () => session }, parent),
143
+ }
144
+ : undefined;
145
+ const resourceLoader = new DefaultResourceLoader({
146
+ cwd: paths.root,
147
+ agentDir: paths.agentDir,
148
+ // The loader, not an extension: these say the same thing on every turn, and a per-turn
149
+ // `before_agent_start` handler could only repeat them. The memory sentence used to sit at the top of
150
+ // the recalled block; there it rode in the newest message, the one region a prompt cache re-pays at
151
+ // the turn boundary, while the prompt is byte-stable and at the front of every request
152
+ // (docs/prompt-cache.md Phase 4.6 B1). A session with no memory server is not told about one.
153
+ appendSystemPrompt: [
154
+ ANSWER_FORMAT_PROMPT,
155
+ ...(memoryServer ? [memoryInstruction(memoryServer.name)] : []),
156
+ ],
157
+ extensionFactories: [
158
+ // flypi's gate first: a tool the user has not approved is refused before any other extension
159
+ // gets to act on the call (src/agent/permission-gate.ts).
160
+ createPermissionGateExtension(paths),
161
+ createMcpExtension({
162
+ loadConfig: () => mcpConfig,
163
+ logPath: paths.mcpLogPath,
164
+ // Where a signed-in server's token comes from: `.flypi/agent/mcp-auth.json`, the file the settings
165
+ // view's sign-in writes (src/agent/mcp-signin.ts). Left out, pi would read the host's.
166
+ ...(mcpCredentialsStore ? { credentials: mcpCredentialsStore } : {}),
167
+ }),
168
+ // The only route to an MCP server the model was not given its tools for: `exposure: "deferred"`,
169
+ // and pi's own default, `codemode` — flypi loads no codemode extension, so without this tool such
170
+ // a server is connected, answers, and can never be used (docs/mcp.md, "Control tool exposure").
171
+ // The settings view offers those exposures, so the sessions have to be able to load them.
172
+ createToolSearchExtension(),
173
+ ...(options.memoryRecall === false
174
+ ? []
175
+ : [createMemoryRecallExtension(memoryServer, options.onMemoryBlock, { conversationId: options.conversationId, ...(review ? { review } : {}) })]),
176
+ // Registered only where a vault was opened, and last: the redaction it installs rewrites what the
177
+ // other extensions produced on the way to the transcript (docs/secrets.md Phase 5.5).
178
+ ...(options.vault ? [createSecretsExtension(options.vault, { paths })] : []),
179
+ ],
180
+ });
181
+ await resourceLoader.reload();
182
+
183
+ mkdirSync(paths.sessionsDir, { recursive: true });
184
+ const { session } = await createAgentSession({
185
+ cwd: paths.root,
186
+ agentDir: paths.agentDir,
187
+ modelRuntime,
188
+ model,
189
+ thinkingLevel: options.thinkingLevel,
190
+ resourceLoader,
191
+ sessionManager,
192
+ settingsManager: SettingsManager.create(paths.root, paths.agentDir),
193
+ tools: options.tools,
194
+ });
195
+ // Emits `session_start`, which is what connects the MCP servers; without it the tools never arrive.
196
+ // Bound once: a second `bindExtensions` would emit `session_start` again and reconnect every server.
197
+ // `rpc`, not `tui`: there is a UI, but it is not a terminal, and extensions guard terminal-only calls
198
+ // (`ctx.ui.custom`, widgets, themes) on that value.
199
+ await session.bindExtensions(options.extensionUi ? { uiContext: options.extensionUi, mode: "rpc" } : {});
200
+ // Registering it is not enough: `tool_search` comes up inactive. A `deferred` server's tools happen to
201
+ // activate it when they register, and a default-exposure (`codemode`) server's do not — the first version
202
+ // of this line was deleted as redundant and only the second case in mcp-tool-search.test.ts noticed.
203
+ // An add-only loadout, so pi keeps the MCP tools still connecting pending and declares them when they
204
+ // arrive (`setActiveToolsByName` in pi's own comment).
205
+ session.setActiveToolsByName([...session.getActiveToolNames(), "tool_search"]);
206
+ return { session, paths, modelRuntime };
207
+ }
208
+
209
+ /**
210
+ * One review fork (docs/memory.md Phase 4.8): the parent's conversation replayed into a session that writes
211
+ * nothing to disk, asked one question — save what is worth saving, or say so — and disposed.
212
+ *
213
+ * It goes through `createFlypiSession` rather than `createAgentSession` so the config-isolation invariant
214
+ * (risk 1) is still enforced once, and so the request is built the same way the parent's was: same provider,
215
+ * model and thinking level, same system-prompt bytes, same advertised tools. That is what lets the fork extend
216
+ * a prefix the route has already read instead of paying ~30K tokens cold (docs/prompt-cache.md).
217
+ *
218
+ * What makes it safe is the session manager: `SessionManager.inMemory` seeded with the parent's entries is
219
+ * pi's own non-persisting session, so the fork can write memories and cannot write a line of the conversation
220
+ * a human is reading (risk 4). Its writes land `session_kind='background'` because its memory server is on the
221
+ * background mount, where the store refuses supersede, forget and `owner` (src/memory/mcp.ts).
222
+ */
223
+ async function runReviewFork(
224
+ deps: {
225
+ paths: FlypiPaths;
226
+ modelRuntime: ModelRuntime;
227
+ mcpConfig: LoadedMcpConfig;
228
+ memoryServer: McpServerEntry;
229
+ vault?: SecretStore;
230
+ tools?: string[];
231
+ parentSession: () => FlypiSession;
232
+ },
233
+ parent: ReviewParent,
234
+ ): Promise<void> {
235
+ const source = deps.parentSession();
236
+ const { session } = await createFlypiSession({
237
+ root: deps.paths.root,
238
+ modelRuntime: deps.modelRuntime,
239
+ // The parent's own model and thinking level, not the settings default: a fork on another model is a
240
+ // different request, and extending the parent's prefix is the whole cost argument.
241
+ ...(source.model ? { model: source.model } : {}),
242
+ ...(source.thinkingLevel ? { thinkingLevel: source.thinkingLevel } : {}),
243
+ ...(deps.tools ? { tools: deps.tools } : {}),
244
+ ...(deps.vault ? { vault: deps.vault } : {}),
245
+ // Same servers, names and exposures — only the memory server moved to the background mount.
246
+ mcpConfig: backgroundMount(deps.mcpConfig, deps.memoryServer.name),
247
+ sessionManager: SessionManager.inMemory(deps.paths.root, undefined, parent.entries),
248
+ memoryRecall: false,
249
+ });
250
+ try {
251
+ // MCP tools arrive asynchronously, and asking before they land would advertise a smaller `tools[]` than
252
+ // the parent's: a different request, and a cold cache. Bounded, because a server that never answers must
253
+ // not hang a fork nobody is waiting on.
254
+ if (await toolsArrived(session, memoryTool(deps.memoryServer.name, "memory_write"))) {
255
+ await session.prompt(reviewPrompt(deps.memoryServer.name));
256
+ }
257
+ } finally {
258
+ session.dispose();
259
+ }
260
+ }
261
+
262
+ /** Whether the memory tools are declared yet. A fork with no memory tools has nothing to be for. */
263
+ async function toolsArrived(session: FlypiSession, writeTool: string, deadlineMs = 10_000): Promise<boolean> {
264
+ const until = Date.now() + deadlineMs;
265
+ for (;;) {
266
+ if (session.getActiveToolNames().includes(writeTool)) return true;
267
+ if (Date.now() > until) return false;
268
+ await new Promise((resolve) => setTimeout(resolve, 50));
269
+ }
270
+ }
271
+
272
+ /**
273
+ * `.flypi/agent/mcp.json` as the MCP extension wants it. Values stay exactly as written: pi expands
274
+ * `${NAME}` in `env`/`headers` itself when it builds a transport, and the settings panel writes the
275
+ * same file (src/agent/config-files.ts), so there is one source of truth for the servers.
276
+ */
277
+ export function readFlypiMcpConfig(paths: FlypiPaths): LoadedMcpConfig {
278
+ const servers: McpServerEntry[] = [];
279
+ const errors: string[] = [];
280
+ if (!existsSync(paths.mcpPath)) return { servers, errors };
281
+ let parsed: unknown;
282
+ try {
283
+ parsed = JSON.parse(readFileSync(paths.mcpPath, "utf8"));
284
+ } catch (error) {
285
+ return { servers, errors: [`${paths.mcpPath}: ${(error as Error).message}`] };
286
+ }
287
+ const entries = (parsed as { mcpServers?: Record<string, Record<string, unknown>> } | undefined)?.mcpServers ?? {};
288
+ for (const [name, config] of Object.entries(entries)) {
289
+ // The rule pi applies itself (docs/mcp.md): an invalid entry is reported, it does not take the
290
+ // rest of the file down with it.
291
+ if (!config || (config.url === undefined && config.command === undefined)) {
292
+ errors.push(`${paths.mcpPath}: "${name}" needs a url (HTTP) or a command (stdio)`);
293
+ continue;
294
+ }
295
+ servers.push({ name, config: config as unknown as McpServerConfig, source: paths.mcpPath, scope: "global" });
296
+ }
297
+ return { servers, errors };
298
+ }
299
+
300
+ /**
301
+ * Read `.flypi/agent/settings.json` for the default provider/model and check the credential,
302
+ * with the same actionable messages probe.ts prints. Imported env keys land in auth.json once.
303
+ */
304
+ export function resolveConfiguredModel(
305
+ paths: FlypiPaths,
306
+ modelRuntime: ModelRuntime,
307
+ env: Record<string, string | undefined> = process.env,
308
+ ): FlypiModel {
309
+ let settings: Record<string, unknown>;
310
+ let models: Record<string, unknown>;
311
+ try {
312
+ settings = JSON.parse(readFileSync(paths.settingsPath, "utf8")) as Record<string, unknown>;
313
+ models = JSON.parse(readFileSync(paths.modelsPath, "utf8")) as Record<string, unknown>;
314
+ } catch (error) {
315
+ throw new FlypiConfigError(
316
+ `could not read ${paths.settingsPath} or ${paths.modelsPath}: ${(error as Error).message}`,
317
+ );
318
+ }
319
+ const provider = settings.defaultProvider as string | undefined;
320
+ const modelId = settings.defaultModel as string | undefined;
321
+ if (!provider || !modelId) {
322
+ throw new FlypiConfigError(
323
+ `${paths.settingsPath} must set defaultProvider and defaultModel (flypi has no built-in default).`,
324
+ );
325
+ }
326
+
327
+ importEnvCredential(paths.authPath, provider, env);
328
+ const credential = resolveCredential({
329
+ provider,
330
+ models: models as never,
331
+ auth: readAuth(paths.authPath),
332
+ env,
333
+ });
334
+ if (!credential.ok) throw new FlypiConfigError(credential.message);
335
+
336
+ const model = modelRuntime.getModel(provider, modelId);
337
+ if (!model) {
338
+ const known = modelRuntime
339
+ .getModels()
340
+ .map((m) => `${m.provider}/${m.id}`)
341
+ .join(", ");
342
+ throw new FlypiConfigError(
343
+ `model "${provider}/${modelId}" is not defined in ${paths.modelsPath}. Defined: ${known || "(none)"}`,
344
+ );
345
+ }
346
+ return model;
347
+ }