pi-orchestrator-config 4.6.4 → 4.7.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 +3 -6
- package/extensions/orchestrator/async-agents.ts +1 -1
- package/extensions/orchestrator/extended-autocomplete.ts +0 -3
- package/extensions/orchestrator/graft.ts +22 -7
- package/extensions/orchestrator/index.ts +6 -0
- package/extensions/orchestrator/session-validation.ts +0 -6
- package/extensions/orchestrator/utils.ts +33 -12
- package/extensions/pidiff/pidiff-ui/package.json +1 -1
- package/extensions/shared/create-runtime-provider.ts +1 -1
- package/extensions/shared/mcp-config-migration.ts +59 -0
- package/extensions/shared/provider-stream-log.ts +94 -0
- package/package.json +5 -1
- package/rules/00-orchestrator-core.md +2 -2
- package/extensions/orchestrator/mcpc.ts +0 -172
- package/rules/15-mcpc.md +0 -35
package/README.md
CHANGED
|
@@ -37,7 +37,7 @@ Single extension that provides:
|
|
|
37
37
|
| **Task tracking** | Structured task lists for multi-step workflows — live widget, progress tracking, reminder nudges (owned, based on [@tintinweb/pi-tasks](https://github.com/tintinweb/pi-tasks) MIT) |
|
|
38
38
|
| **Neovim integration** | Send changed files and review findings to nvim's quickfix list — only active when running inside nvim |
|
|
39
39
|
| **Inter-agent communication** | P2P (`/coms`) agent communication — on-demand activation via slash command |
|
|
40
|
-
| **Slash commands** | `/pr-review`, `/issue-review`, `/release`, `/review-local`, `/review-status`, `/query-db`, `/btw`, `/async-status`, `/async-kill`, `/status`, `/dream`, `/remember`, `/coms`, `/pi-config-settings
|
|
40
|
+
| **Slash commands** | `/pr-review`, `/issue-review`, `/release`, `/review-local`, `/review-status`, `/query-db`, `/btw`, `/async-status`, `/async-kill`, `/status`, `/dream`, `/remember`, `/coms`, `/pi-config-settings` — with autocomplete argument hints |
|
|
41
41
|
| **GitHub autocomplete** | Type `#` in the editor to get issue/PR suggestions from the current repo — lazy-loaded, 5min cache |
|
|
42
42
|
| **Command arg completions** | Tab-complete arguments for slash commands — providers and models for `/external-ai`, branches for `/review-local`, PR numbers for `/pr-review`, and more |
|
|
43
43
|
| **Discord bot** | Control pi sessions from your phone via Discord DMs — send prompts, answer ask_user dialogs, switch sessions |
|
|
@@ -84,7 +84,6 @@ Single extension that provides:
|
|
|
84
84
|
| `/nvim-changed-files` | Send git changed files to nvim's quickfix list (only inside nvim) |
|
|
85
85
|
| `/pidiff start\|stop\|restart\|status` | Manage the pidiff diff viewer server (per-project) |
|
|
86
86
|
| `/coms start\|stop\|status` | P2P local agent communication (Unix socket) |
|
|
87
|
-
| `/mcpc connect` | Connect MCP servers from `~/.pi/pi-config/mcp.json` (`mcpc connect --stdio`). Run after editing that file. |
|
|
88
87
|
|
|
89
88
|
### Inter-Agent Communication (coms)
|
|
90
89
|
|
|
@@ -159,7 +158,7 @@ The installer covers:
|
|
|
159
158
|
|
|
160
159
|
- **Pi Packages** — pi-config, pi-vertex-claude, pi-web-access, myk-pi-tools, bun
|
|
161
160
|
- **Python Tools** — prek
|
|
162
|
-
- **npm Packages** —
|
|
161
|
+
- **npm Packages** — acpx, agent-browser, graft
|
|
163
162
|
- **Browser Automation** — playwright + chromium
|
|
164
163
|
- **Environment Setup** — gitignore configuration
|
|
165
164
|
|
|
@@ -215,7 +214,6 @@ Add retry logic to the HTTP client in src/api.py
|
|
|
215
214
|
/release --dry-run
|
|
216
215
|
/review-local main
|
|
217
216
|
/query-db stats
|
|
218
|
-
/mcpc connect
|
|
219
217
|
```
|
|
220
218
|
|
|
221
219
|
### Direct subagent usage
|
|
@@ -735,7 +733,6 @@ PI_PIDIFF_ENABLE=false pi
|
|
|
735
733
|
| `uv` / `uvx` | Python execution (enforced by orchestrator) |
|
|
736
734
|
| `go` | Go development and code review |
|
|
737
735
|
| `libxml2-dev` | libxml2 headers — needed for building Python C extensions (e.g. ovirt-engine-sdk-python) |
|
|
738
|
-
| `mcpc` | MCP CLI (`npm: @apify/mcpc`). Put servers in `~/.pi/pi-config/mcp.json` (already on the `~/.pi` mount). Pi runs `mcpc connect … --stdio` on start. After editing the file, `/mcpc connect`. `entrypoint.sh` reinstalls `@apify/mcpc` on every start so `PI_HOST_USER` host `.npm-global` mapping does not hide the image copy. |
|
|
739
736
|
| `myk-pi-tools` | PR review, release, and other CLI utilities |
|
|
740
737
|
| `prek` | Pre-commit hook runner |
|
|
741
738
|
| `acpx` | Agent proxy for remote models |
|
|
@@ -896,7 +893,7 @@ See [DEVELOPMENT.md](DEVELOPMENT.md) for tips on testing extensions locally, run
|
|
|
896
893
|
|
|
897
894
|
## Prerequisites
|
|
898
895
|
|
|
899
|
-
- [pi](https://github.com/badlogic/pi-mono) (minimum version: **0.
|
|
896
|
+
- [pi](https://github.com/badlogic/pi-mono) (minimum version: **0.99.0**). Pidash uses Pi's `ui_prompt_start` and `ui_prompt_end` events to show prompt wait separately from agent work.
|
|
900
897
|
- `gh` CLI (for GitHub operations)
|
|
901
898
|
- `uv` (for Python execution)
|
|
902
899
|
- `myk-pi-tools` (optional, for `/pr-review` and `/release`)
|
|
@@ -886,7 +886,7 @@ export function registerAsyncAgents(
|
|
|
886
886
|
if (!parentStartTime) {
|
|
887
887
|
log.warn("async-spawn: could not read /proc/self/stat starttime after 3 attempts");
|
|
888
888
|
}
|
|
889
|
-
|
|
889
|
+
let { model: effectiveModel, provider: effectiveProvider } = resolveAgentModelProvider(agentName, agent, options?.parentModelId, options?.parentProvider, cwd, options?.explicit);
|
|
890
890
|
fs.writeFileSync(path.join(workerDir, "session.json"), JSON.stringify({
|
|
891
891
|
resultsDir: ASYNC_RESULTS_DIR,
|
|
892
892
|
fireAndForget: options?.fireAndForget || false,
|
|
@@ -20,7 +20,6 @@
|
|
|
20
20
|
* /cron <Tab> → add, list, remove
|
|
21
21
|
* /dream-auto <Tab> → on, off
|
|
22
22
|
* /async-kill <Tab> → all (or type name / id prefix)
|
|
23
|
-
* /mcpc <Tab> → connect
|
|
24
23
|
*/
|
|
25
24
|
|
|
26
25
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
@@ -30,7 +29,6 @@ import { execFileSync } from "node:child_process";
|
|
|
30
29
|
import * as path from "node:path";
|
|
31
30
|
import { createLogger } from "../shared/logger.js";
|
|
32
31
|
import { getCronRemoveAutocompleteItems } from "./cron.js";
|
|
33
|
-
import { mcpcArgumentCompletions } from "./mcpc.js";
|
|
34
32
|
|
|
35
33
|
const log = createLogger("autocomplete");
|
|
36
34
|
|
|
@@ -295,7 +293,6 @@ function registerCompletions(
|
|
|
295
293
|
}
|
|
296
294
|
},
|
|
297
295
|
|
|
298
|
-
"mcpc": mcpcArgumentCompletions,
|
|
299
296
|
|
|
300
297
|
"dream-auto": (prefix: string) => {
|
|
301
298
|
return filter([
|
|
@@ -10,7 +10,7 @@ import { createLogger } from "../shared/logger.js";
|
|
|
10
10
|
import { isPiOneshotInvocation } from "../shared/oneshot.js";
|
|
11
11
|
import { getSetting } from "./project-settings.js";
|
|
12
12
|
import { clearSlot, setSlot } from "./status-bar.js";
|
|
13
|
-
import { getProjectTmpDir, resolveWorktreeRoot } from "./utils.js";
|
|
13
|
+
import { getProjectTmpDir, resolveWorktreeRoot, tryResolveWorktreeRoot } from "./utils.js";
|
|
14
14
|
import { formatCompactTotal } from "../shared/format-total.js";
|
|
15
15
|
|
|
16
16
|
const exec = promisify(execFile);
|
|
@@ -24,10 +24,10 @@ export function withGraftTools(tools: readonly string[] | undefined, enabled: bo
|
|
|
24
24
|
if (!tools?.length || !enabled) return tools ? [...tools] : undefined;
|
|
25
25
|
return [...new Set([...tools, ...GRAFT_QUERY_TOOLS])];
|
|
26
26
|
}
|
|
27
|
-
export type GraftState = "syncing" | "ready" | "stale" | "failed";
|
|
27
|
+
export type GraftState = "syncing" | "ready" | "stale" | "absent" | "failed";
|
|
28
28
|
export type GraftRun = (args: readonly string[], options: { cwd: string; timeoutMs?: number }) => Promise<{ stdout: string; stderr: string; code: number }>;
|
|
29
29
|
type GraphStatus = "fresh" | "stale" | "absent";
|
|
30
|
-
type Options = { enabled: boolean; setting?: (cwd: string) => boolean; executable?: () => Promise<boolean>; graph?: (cwd: string) => Promise<GraphStatus>; retrieve?: (query: string, cwd: string) => Promise<{ pointers: string[] }>; run?: GraftRun; interactiveTimeoutMs?: number };
|
|
30
|
+
type Options = { enabled: boolean; setting?: (cwd: string) => boolean; executable?: () => Promise<boolean>; graph?: (cwd: string) => Promise<GraphStatus>; retrieve?: (query: string, cwd: string) => Promise<{ pointers: string[] }>; worktree?: (cwd: string) => string | null; run?: GraftRun; interactiveTimeoutMs?: number };
|
|
31
31
|
type StatusTheme = { fg: (color: string, value: string) => string };
|
|
32
32
|
type Hit = { title?: string; pointer?: string; snippet?: string };
|
|
33
33
|
type Ask = { coverage?: number; coverageStrong?: number; hits?: Hit[] };
|
|
@@ -35,7 +35,11 @@ type Ask = { coverage?: number; coverageStrong?: number; hits?: Hit[] };
|
|
|
35
35
|
/** Graft's statusline shape, adapted to Pi's theme API. */
|
|
36
36
|
export function formatGraftFooter({ state, nodeCount, tokenSavings }: { state: GraftState; nodeCount: number; tokenSavings: number }, theme?: StatusTheme): string {
|
|
37
37
|
const fg = (color: string, value: string) => theme?.fg(color, value) ?? value;
|
|
38
|
-
|
|
38
|
+
// "absent" is deliberately dim, not error-red: the cwd is not a git worktree,
|
|
39
|
+
// so no graph is possible. Reporting that as "failed" made an ordinary
|
|
40
|
+
// directory look like a broken install.
|
|
41
|
+
const freshness = state === "syncing" ? fg("warning", "syncing…") : state === "stale" ? fg("warning", "⚠ stale") : state === "failed" ? fg("error", "failed") : state === "absent" ? fg("dim", "n/a") : fg("success", "✓ synced");
|
|
42
|
+
if (state === "absent") return fg("dim", "◤ graft · n/a");
|
|
39
43
|
const parts = [fg("dim", "◤ ") + fg("accent", "graft"), fg("dim", `${nodeCount} nodes`), freshness];
|
|
40
44
|
if (tokenSavings > 0) parts.push(fg("success", `~${formatCompactTotal(tokenSavings)} tok saved`));
|
|
41
45
|
return parts.join(fg("dim", " · "));
|
|
@@ -172,6 +176,7 @@ export function createGraftIntegration(options: Options) {
|
|
|
172
176
|
return result;
|
|
173
177
|
};
|
|
174
178
|
const available = options.executable ?? (async () => (await run(["--version"], { cwd: state.root })).code === 0);
|
|
179
|
+
const worktree = options.worktree ?? tryResolveWorktreeRoot;
|
|
175
180
|
const graph = options.graph ?? (async cwd => (await run(["check", "--json"], { cwd })).code === 0 ? "fresh" : nodes(cwd) ? "stale" : "absent");
|
|
176
181
|
const refresh = async (): Promise<boolean> => {
|
|
177
182
|
if (child) return false;
|
|
@@ -235,15 +240,25 @@ export function createGraftIntegration(options: Options) {
|
|
|
235
240
|
if (!trusted(ctx)) { invalidate(ctx, "untrusted"); log.info("session_start_exit", { decision: "untrusted" }); return; }
|
|
236
241
|
const enabled = options.setting?.(ctx.cwd) ?? options.enabled;
|
|
237
242
|
if (!enabled) { invalidate(ctx, "disabled"); log.info("session_start_exit", { decision: "disabled" }); return; }
|
|
238
|
-
|
|
243
|
+
// Check for a worktree BEFORE anything goes live. This has to sit above
|
|
244
|
+
// registerTools() and `state.enabled = true`: with those already set, the
|
|
245
|
+
// guards compare resolveWorktreeRoot(cwd) against state.root (both the
|
|
246
|
+
// cwd, since resolution falls back to it) and pass, so the integration
|
|
247
|
+
// stayed live outside a repo -- a read flipped the footer to "synced" and
|
|
248
|
+
// every prompt ran a graft check subprocess in a plain directory.
|
|
249
|
+
if (!worktree(ctx.cwd)) { invalidate(ctx, "not_a_worktree"); setState("absent"); log.info("session_start_exit", { decision: "not_a_worktree", cwd: ctx.cwd }); return; }
|
|
250
|
+
registerTools(); const generation = ++state.generation; state.enabled = true; state.refresh = undefined; state.dirty = false; state.root = resolveWorktreeRoot(ctx.cwd); const root = state.root; state.nodeCount = nodes(root); state.value = "syncing"; state.gate = { active: false, retrieved: false, pointers: new Set<string>() };
|
|
239
251
|
try { state.store = savingsStore(ctx, root); state.dirtySignal = state.store ? `${state.store}.dirty` : ""; if (state.dirtySignal) { mkdirSync(state.dirtySignal, { recursive: true, mode: 0o700 }); chmodSync(state.dirtySignal, 0o700); } state.tokenSavings = readSavings(state.store); } catch (error: any) { state.store = ""; state.dirtySignal = ""; state.tokenSavings = 0; log.warn("savings_restore_failed", { code: error?.code }); }
|
|
240
|
-
|
|
252
|
+
// Provisional state while we probe. This used to be "failed", which made
|
|
253
|
+
// every session start — including healthy ones — flash the error-red state
|
|
254
|
+
// for a moment before settling.
|
|
255
|
+
setState("syncing");
|
|
241
256
|
if (!child && !isEligibleGraftStartup(event, ctx) && !["new", "reload", "resume", "fork"].includes(event?.reason)) { log.info("session_start_exit", { decision: "ineligible" }); return; }
|
|
242
257
|
if (!child) { const executable = await available(); if (state.generation !== generation || state.root !== root) return; if (!executable) { setState("failed"); log.info("session_start_exit", { decision: "unavailable" }); return; } }
|
|
243
258
|
const status = await graph(root);
|
|
244
259
|
if (state.generation !== generation || state.root !== root) return;
|
|
245
260
|
if (status === "fresh") setState("ready");
|
|
246
|
-
else { setState(state.nodeCount ? "stale" : "
|
|
261
|
+
else { setState(state.nodeCount ? "stale" : "absent"); if (!child) void refresh(); }
|
|
247
262
|
log.info("session_start_exit", { decision: status, nodes: state.nodeCount });
|
|
248
263
|
});
|
|
249
264
|
pi.on("tool_call", (event: any, ctx: any) => {
|
|
@@ -39,6 +39,8 @@ import { registerMarkdownTransformer } from "./markdown-transformer.js";
|
|
|
39
39
|
import { registerGraft } from "./graft.js";
|
|
40
40
|
import { ensureGitSshTimeout, isRunningInContainer, terminalNotify } from "./utils.js";
|
|
41
41
|
import { createLogger } from "../shared/logger.js";
|
|
42
|
+
import { registerProviderStreamLog } from "../shared/provider-stream-log.js";
|
|
43
|
+
import { migrateMcpConfig } from "../shared/mcp-config-migration.js";
|
|
42
44
|
import { setGlobalSessionId } from "../shared/file-logger.js";
|
|
43
45
|
|
|
44
46
|
const IN_CONTAINER = isRunningInContainer();
|
|
@@ -113,6 +115,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
113
115
|
registerSessionSearch(pi);
|
|
114
116
|
registerSettingsTui(pi);
|
|
115
117
|
registerMarkdownTransformer(pi);
|
|
118
|
+
// Raw provider stream logging — gated on `log_providers=debug` (#848).
|
|
119
|
+
registerProviderStreamLog(pi);
|
|
120
|
+
// Copy a pre-#848 `~/.pi/pi-config/mcp.json` to where builtin:mcp reads it (#848).
|
|
121
|
+
migrateMcpConfig();
|
|
116
122
|
|
|
117
123
|
// ── list_models tool — LLM-callable model discovery ──
|
|
118
124
|
pi.registerTool({
|
|
@@ -9,7 +9,6 @@ import * as path from "node:path";
|
|
|
9
9
|
import { fileURLToPath } from "node:url";
|
|
10
10
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
11
11
|
import { setUvAvailable, isUvAvailable } from "./enforcement-helpers.js";
|
|
12
|
-
import { registerMcpc } from "./mcpc.js";
|
|
13
12
|
import { checkMinPiVersion } from "./utils.js";
|
|
14
13
|
import { createLogger } from "../shared/logger.js";
|
|
15
14
|
|
|
@@ -216,10 +215,6 @@ async function checkSessionTools(ctx: any): Promise<void> {
|
|
|
216
215
|
// Optional
|
|
217
216
|
if (!hasCmd("gh"))
|
|
218
217
|
optional.push("gh — GitHub CLI. Install: https://cli.github.com/");
|
|
219
|
-
if (!hasCmd("mcpc"))
|
|
220
|
-
optional.push(
|
|
221
|
-
"mcpc — MCP CLI. Install: npm install -g @apify/mcpc",
|
|
222
|
-
);
|
|
223
218
|
if (!hasCmd("myk-pi-tools"))
|
|
224
219
|
optional.push(
|
|
225
220
|
"myk-pi-tools — PR/release/review CLI. Install: uv tool install myk-pi-tools",
|
|
@@ -355,7 +350,6 @@ async function checkUpgradeChangelog(ctx: any): Promise<void> {
|
|
|
355
350
|
|
|
356
351
|
export function registerSessionValidation(pi: ExtensionAPI): void {
|
|
357
352
|
registerRepairCommand(pi);
|
|
358
|
-
registerMcpc(pi);
|
|
359
353
|
|
|
360
354
|
pi.on("session_start", async (_event, ctx) => {
|
|
361
355
|
// Always check uv availability — even in headless mode.
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import { execFile, execFileSync } from "node:child_process";
|
|
6
6
|
import * as fs from "node:fs";
|
|
7
7
|
import * as path from "node:path";
|
|
8
|
+
import { createLogger } from "../shared/logger.js";
|
|
8
9
|
|
|
9
10
|
/** Whether notify-send is available (false = ENOENT, never retry) */
|
|
10
11
|
let notifyAvailable: boolean | undefined;
|
|
@@ -94,22 +95,36 @@ export function resolveRepoRoot(cwd: string): string {
|
|
|
94
95
|
* @returns The worktree's top-level directory, or `cwd` as fallback */
|
|
95
96
|
const worktreeRootCache = new Map<string, string>();
|
|
96
97
|
|
|
97
|
-
|
|
98
|
+
const log = createLogger("utils");
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Resolve the worktree root, or null when `cwd` is not inside a git worktree.
|
|
102
|
+
*
|
|
103
|
+
* Prefer this over {@link resolveWorktreeRoot} when the difference matters: that
|
|
104
|
+
* function deliberately falls back to `cwd`, so a plain directory and a repo
|
|
105
|
+
* root are indistinguishable in its return value, and a caller cannot tell
|
|
106
|
+
* "no graph possible here" from "the graph build failed".
|
|
107
|
+
*/
|
|
108
|
+
export function tryResolveWorktreeRoot(cwd: string): string | null {
|
|
98
109
|
const key = path.resolve(cwd);
|
|
99
110
|
const cached = worktreeRootCache.get(key);
|
|
100
|
-
if (cached !== undefined) return cached;
|
|
111
|
+
if (cached !== undefined) return cached === "" ? null : cached;
|
|
112
|
+
let root: string | null = null;
|
|
101
113
|
try {
|
|
102
114
|
const toplevel = execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd, encoding: "utf-8", timeout: 3000, stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
103
|
-
if (toplevel && !toplevel.startsWith("fatal"))
|
|
104
|
-
const root = path.resolve(toplevel);
|
|
105
|
-
worktreeRootCache.set(key, root);
|
|
106
|
-
return root;
|
|
107
|
-
}
|
|
115
|
+
if (toplevel && !toplevel.startsWith("fatal")) root = path.resolve(toplevel);
|
|
108
116
|
} catch (e: any) {
|
|
109
|
-
|
|
117
|
+
// Expected whenever the cwd is not a git repo, so this is a debug line in a
|
|
118
|
+
// file log — never console.*, which would dump it into the chat transcript.
|
|
119
|
+
log.debug("resolveWorktreeRoot", { cwd: key, reason: e?.message?.slice(0, 200) });
|
|
110
120
|
}
|
|
111
|
-
|
|
112
|
-
|
|
121
|
+
// "" is the not-a-worktree sentinel; resolveWorktreeRoot maps it back to cwd.
|
|
122
|
+
worktreeRootCache.set(key, root ?? "");
|
|
123
|
+
return root;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function resolveWorktreeRoot(cwd: string): string {
|
|
127
|
+
return tryResolveWorktreeRoot(cwd) ?? cwd;
|
|
113
128
|
}
|
|
114
129
|
|
|
115
130
|
/** Get project-scoped temp dir under <cwd>/.pi/tmp/ */
|
|
@@ -147,8 +162,14 @@ export function tryGetSystemPromptOptions(ctx: any): { contextFiles?: any[]; ski
|
|
|
147
162
|
}
|
|
148
163
|
}
|
|
149
164
|
|
|
150
|
-
/**
|
|
151
|
-
|
|
165
|
+
/**
|
|
166
|
+
* Minimum pi version required by this pi-config version.
|
|
167
|
+
*
|
|
168
|
+
* 0.99.0: `builtin:mcp` replaces the external `mcpc` binary (so MCP servers are
|
|
169
|
+
* read from ~/.pi/agent/mcp.json by pi itself), and the built-in MCP/codemode/
|
|
170
|
+
* tool-search extensions become the ones we must not collide with.
|
|
171
|
+
*/
|
|
172
|
+
export const MIN_PI_VERSION = "0.99.0";
|
|
152
173
|
|
|
153
174
|
/** Get the installed pi version from its package.json. */
|
|
154
175
|
export function getPiVersion(): string | null {
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Prefer this over legacy `pi.registerProvider(name, { streamSimple })` bags so
|
|
5
5
|
* providers get /login auth, fetchModels refresh, filterModels, and native
|
|
6
|
-
* ProviderStreams (requires pi >= 0.
|
|
6
|
+
* ProviderStreams (requires pi >= 0.99.0).
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { createLogger } from "./logger.js";
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One-time MCP config migration (#848).
|
|
3
|
+
*
|
|
4
|
+
* MCP used to be reached through the external `mcpc` binary, which read
|
|
5
|
+
* `~/.pi/pi-config/mcp.json`. It is now pi's built-in `builtin:mcp`, which reads
|
|
6
|
+
* `~/.pi/agent/mcp.json`. Without this, an existing install upgrades to a pi
|
|
7
|
+
* that finds no MCP config at all — every server silently disappears, with no
|
|
8
|
+
* error anywhere to look at.
|
|
9
|
+
*
|
|
10
|
+
* Deliberately a copy, not a move, and only when the new file is absent: the
|
|
11
|
+
* old file stays as the user's to delete, and an existing `~/.pi/agent/mcp.json`
|
|
12
|
+
* is never touched. Nothing is written when either file is missing.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { copyFileSync, existsSync, mkdirSync } from "node:fs";
|
|
16
|
+
import { homedir } from "node:os";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
import { createLogger } from "./logger.js";
|
|
19
|
+
|
|
20
|
+
const log = createLogger("mcp-config-migration");
|
|
21
|
+
|
|
22
|
+
/** Where `mcpc` kept servers, and where `builtin:mcp` reads them from. */
|
|
23
|
+
export function mcpConfigPaths(home: string = homedir()): { legacy: string; current: string } {
|
|
24
|
+
return {
|
|
25
|
+
legacy: join(home, ".pi", "pi-config", "mcp.json"),
|
|
26
|
+
current: join(home, ".pi", "agent", "mcp.json"),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Copy the legacy config into place when it is safe to do so.
|
|
32
|
+
* Returns the path written, or undefined when nothing was done.
|
|
33
|
+
*/
|
|
34
|
+
export function migrateMcpConfig(home: string = homedir()): string | undefined {
|
|
35
|
+
const { legacy, current } = mcpConfigPaths(home);
|
|
36
|
+
if (existsSync(current)) {
|
|
37
|
+
log.debug("mcp config present, nothing to do", { path: current });
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
if (!existsSync(legacy)) {
|
|
41
|
+
log.debug("no legacy mcp config", { path: legacy });
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
try {
|
|
45
|
+
mkdirSync(join(home, ".pi", "agent"), { recursive: true });
|
|
46
|
+
copyFileSync(legacy, current);
|
|
47
|
+
log.info("migrated MCP config for builtin:mcp", { from: legacy, to: current });
|
|
48
|
+
return current;
|
|
49
|
+
} catch (error) {
|
|
50
|
+
// A failed migration must not stop pi from starting — worst case the user
|
|
51
|
+
// copies the file by hand, which the message tells them how to do.
|
|
52
|
+
log.warn("MCP config migration failed; copy it by hand", {
|
|
53
|
+
from: legacy,
|
|
54
|
+
to: current,
|
|
55
|
+
error: error instanceof Error ? error.message : String(error),
|
|
56
|
+
});
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider stream diagnostics — the shared hook for raw provider stream logging
|
|
3
|
+
* (issue #848, pi 0.99 `provider_stream_event`).
|
|
4
|
+
*
|
|
5
|
+
* GATING IS MANDATORY. `provider_stream_event` fires once per parsed chunk and
|
|
6
|
+
* pi AWAITS handlers inline in the stream loop
|
|
7
|
+
* (`await options?.onProviderStreamEvent?.(chunk, model)`), so registering a
|
|
8
|
+
* slow handler delays stream consumption. We only register when the user opts
|
|
9
|
+
* into provider debug logging.
|
|
10
|
+
*
|
|
11
|
+
* Gate: the existing `log_providers` setting (default `info`), so `debug` is off
|
|
12
|
+
* by default and no new settings key is needed. Reuses the `providers` log
|
|
13
|
+
* namespace → ~/.pi/logs/providers/<session-id>/main.log. Never console.*
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { ExtensionAPI, ProviderStreamEvent } from "@earendil-works/pi-coding-agent";
|
|
17
|
+
import { createLogger } from "./logger.js";
|
|
18
|
+
|
|
19
|
+
const LOG_NAME = "providers";
|
|
20
|
+
const PREFIX = "provider-stream";
|
|
21
|
+
|
|
22
|
+
const MAX_VALUE_LEN = 200;
|
|
23
|
+
const MAX_ITEMS = 8;
|
|
24
|
+
const MAX_DEPTH = 4;
|
|
25
|
+
// ponytail: per-turn cap; one runaway stream writes 2k lines, not 2M. Raise if
|
|
26
|
+
// a real diagnosis ever needs more than that within a single turn.
|
|
27
|
+
const MAX_EVENTS_PER_TURN = 2000;
|
|
28
|
+
|
|
29
|
+
const SECRET_KEY = /api[-_]?key|authorization|bearer|token|secret|password|cookie/i;
|
|
30
|
+
|
|
31
|
+
function truncate(text: string): string {
|
|
32
|
+
return text.length > MAX_VALUE_LEN ? `${text.slice(0, MAX_VALUE_LEN)}…(+${text.length - MAX_VALUE_LEN})` : text;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Depth-bounded, size-bounded, secret-redacting preview of an arbitrary stream
|
|
37
|
+
* chunk. Depth bounding also makes cycles terminate — no `seen` set needed.
|
|
38
|
+
*/
|
|
39
|
+
export function previewStreamData(value: unknown, depth = 0): string {
|
|
40
|
+
if (value === null || value === undefined) return String(value);
|
|
41
|
+
if (typeof value === "string") return truncate(value);
|
|
42
|
+
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
43
|
+
if (typeof value === "bigint") return `${value.toString()}n`;
|
|
44
|
+
if (typeof value === "function" || typeof value === "symbol") return `[${typeof value}]`;
|
|
45
|
+
if (typeof value !== "object") return truncate(String(value));
|
|
46
|
+
if (depth >= MAX_DEPTH) return Array.isArray(value) ? `[array(${value.length})]` : "{…}";
|
|
47
|
+
if (Array.isArray(value)) {
|
|
48
|
+
const head = value.slice(0, MAX_ITEMS).map(item => previewStreamData(item, depth + 1));
|
|
49
|
+
return `[${head.join(",")}${value.length > MAX_ITEMS ? ",…" : ""}]`;
|
|
50
|
+
}
|
|
51
|
+
const entries = Object.entries(value as Record<string, unknown>);
|
|
52
|
+
const body = entries
|
|
53
|
+
.slice(0, MAX_ITEMS)
|
|
54
|
+
.map(([key, item]) => `${truncate(key)}:${SECRET_KEY.test(key) ? "…redacted" : previewStreamData(item, depth + 1)}`)
|
|
55
|
+
.join(",");
|
|
56
|
+
return `{${body}${entries.length > MAX_ITEMS ? ",…" : ""}}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Register the provider stream log hook. No-op unless provider debug logging is
|
|
61
|
+
* enabled — see the gating note above.
|
|
62
|
+
*/
|
|
63
|
+
export function registerProviderStreamLog(pi: ExtensionAPI): void {
|
|
64
|
+
const log = createLogger(LOG_NAME, PREFIX);
|
|
65
|
+
if (!log.isDebugEnabled()) return;
|
|
66
|
+
|
|
67
|
+
let seen = 0;
|
|
68
|
+
let dropped = 0;
|
|
69
|
+
pi.on("turn_start", () => {
|
|
70
|
+
if (dropped) log.warn("provider stream events dropped this turn", { dropped });
|
|
71
|
+
seen = 0;
|
|
72
|
+
dropped = 0;
|
|
73
|
+
});
|
|
74
|
+
pi.on("provider_stream_event", (event: ProviderStreamEvent) => {
|
|
75
|
+
try {
|
|
76
|
+
if (++seen > MAX_EVENTS_PER_TURN) {
|
|
77
|
+
dropped++;
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
let data: unknown = event.data;
|
|
81
|
+
try {
|
|
82
|
+
// data is read-only to extensions — clone before retaining/logging.
|
|
83
|
+
data = structuredClone(event.data);
|
|
84
|
+
} catch {
|
|
85
|
+
log.warn("stream event data not cloneable, logged un-cloned", { provider: event.provider, api: event.api });
|
|
86
|
+
}
|
|
87
|
+
log.debug("stream_event", `${event.provider}/${event.api}/${event.model}`, previewStreamData(data));
|
|
88
|
+
} catch (err) {
|
|
89
|
+
// Never throw: pi reports handler errors, but a hot-path handler must not
|
|
90
|
+
// interfere with stream consumption.
|
|
91
|
+
log.error("stream event logging failed", err);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-orchestrator-config",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.7.0",
|
|
4
4
|
"description": "Orchestrator pattern for pi: specialist subagents, enforcement, code review loop, workflow commands",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package"
|
|
@@ -75,6 +75,10 @@
|
|
|
75
75
|
"ws": "^8.0.0"
|
|
76
76
|
},
|
|
77
77
|
"devDependencies": {
|
|
78
|
+
"@earendil-works/pi-agent-core": "^0.99.1",
|
|
79
|
+
"@earendil-works/pi-ai": "^0.99.1",
|
|
80
|
+
"@earendil-works/pi-coding-agent": "^0.99.1",
|
|
81
|
+
"@earendil-works/pi-tui": "^0.99.1",
|
|
78
82
|
"@types/node": "^22.20.1",
|
|
79
83
|
"tsx": "^4.0.0"
|
|
80
84
|
},
|
|
@@ -15,10 +15,10 @@
|
|
|
15
15
|
The orchestrator delegates — never implements directly.
|
|
16
16
|
⚠️ Pi does not enforce these restrictions — you SHOULD NOT violate them.
|
|
17
17
|
|
|
18
|
-
**Allowed direct actions:** read files, run `
|
|
18
|
+
**Allowed direct actions:** read files, run `codemode` scripts, ask questions, analyze, plan,
|
|
19
19
|
route to agents via `subagent`, execute slash commands AND all their internal operations.
|
|
20
20
|
|
|
21
|
-
Outside slash commands, never use edit, write, or bash directly (except `
|
|
21
|
+
Outside slash commands, never use edit, write, or bash directly (except `codemode` scripts) — delegate to specialists.
|
|
22
22
|
|
|
23
23
|
**Always delegate via `subagent`:**
|
|
24
24
|
|
|
@@ -1,172 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* mcpc — connect MCP servers from ~/.pi/pi-config/mcp.json.
|
|
3
|
-
*
|
|
4
|
-
* Auto-connect on pi process start (session_start reason "startup").
|
|
5
|
-
* /mcpc connect reconnects after the user edits that file.
|
|
6
|
-
* Connect failure is logged; it never fails the pi session.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import { execFile } from "node:child_process";
|
|
10
|
-
import * as fs from "node:fs";
|
|
11
|
-
import * as os from "node:os";
|
|
12
|
-
import * as path from "node:path";
|
|
13
|
-
import { promisify } from "node:util";
|
|
14
|
-
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
15
|
-
import { isPiOneshotInvocation } from "../shared/oneshot.js";
|
|
16
|
-
import { createLogger } from "../shared/logger.js";
|
|
17
|
-
|
|
18
|
-
const execFileAsync = promisify(execFile);
|
|
19
|
-
const log = createLogger("orchestrator", "mcpc");
|
|
20
|
-
|
|
21
|
-
const CONNECT_TIMEOUT_MS = 120_000;
|
|
22
|
-
const MAX_BUFFER = 10 * 1024 * 1024;
|
|
23
|
-
|
|
24
|
-
export type McpcExecFile = (
|
|
25
|
-
file: string,
|
|
26
|
-
args: readonly string[],
|
|
27
|
-
options: { timeout: number; maxBuffer: number; env?: NodeJS.ProcessEnv },
|
|
28
|
-
) => Promise<{ stdout: string | Buffer; stderr: string | Buffer }>;
|
|
29
|
-
|
|
30
|
-
let execMcpcConnect: McpcExecFile = execFileAsync as McpcExecFile;
|
|
31
|
-
|
|
32
|
-
/** Replace the mcpc subprocess (tests). Pass undefined to restore. */
|
|
33
|
-
export function setMcpcExecFile(fn: McpcExecFile | undefined): void {
|
|
34
|
-
execMcpcConnect = fn ?? (execFileAsync as McpcExecFile);
|
|
35
|
-
log.debug("setMcpcExecFile", fn ? "override" : "restore");
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export function mcpcConfigPath(): string {
|
|
39
|
-
const home = process.env.HOME || os.homedir();
|
|
40
|
-
const configPath = path.join(home, ".pi", "pi-config", "mcp.json");
|
|
41
|
-
log.debug("mcpcConfigPath", configPath);
|
|
42
|
-
return configPath;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
export type McpcConnectResult = {
|
|
46
|
-
ok: boolean;
|
|
47
|
-
skipped: boolean;
|
|
48
|
-
message: string;
|
|
49
|
-
};
|
|
50
|
-
|
|
51
|
-
export type McpcCompletionItem = {
|
|
52
|
-
value: string;
|
|
53
|
-
label: string;
|
|
54
|
-
description: string;
|
|
55
|
-
};
|
|
56
|
-
|
|
57
|
-
const MCPC_CONNECT_COMPLETION: McpcCompletionItem = {
|
|
58
|
-
value: "connect",
|
|
59
|
-
label: "connect",
|
|
60
|
-
description: "Connect ~/.pi/pi-config/mcp.json (--stdio)",
|
|
61
|
-
};
|
|
62
|
-
|
|
63
|
-
export function mcpcArgumentCompletions(prefix: string): McpcCompletionItem[] | null {
|
|
64
|
-
log.debug("mcpc argument completions", prefix);
|
|
65
|
-
const needle = prefix.trim().toLowerCase();
|
|
66
|
-
const haystack = `${MCPC_CONNECT_COMPLETION.label} ${MCPC_CONNECT_COMPLETION.description}`.toLowerCase();
|
|
67
|
-
if (!needle || haystack.includes(needle) || MCPC_CONNECT_COMPLETION.value.startsWith(needle)) {
|
|
68
|
-
return [MCPC_CONNECT_COMPLETION];
|
|
69
|
-
}
|
|
70
|
-
return null;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
function formatExecError(err: unknown): string {
|
|
74
|
-
if (!err || typeof err !== "object") {
|
|
75
|
-
log.debug("formatExecError non-object", String(err));
|
|
76
|
-
return String(err);
|
|
77
|
-
}
|
|
78
|
-
const e = err as {
|
|
79
|
-
code?: string;
|
|
80
|
-
killed?: boolean;
|
|
81
|
-
message?: string;
|
|
82
|
-
stdout?: string;
|
|
83
|
-
stderr?: string;
|
|
84
|
-
};
|
|
85
|
-
log.debug("formatExecError", { code: e.code ?? "none", killed: Boolean(e.killed) });
|
|
86
|
-
const parts: string[] = [];
|
|
87
|
-
if (e.code === "ENOENT") {
|
|
88
|
-
parts.push("mcpc not on PATH. Install: npm install -g @apify/mcpc");
|
|
89
|
-
} else if (e.killed) {
|
|
90
|
-
parts.push(`mcpc connect timed out after ${CONNECT_TIMEOUT_MS / 1000}s`);
|
|
91
|
-
} else if (e.message) {
|
|
92
|
-
parts.push(e.message);
|
|
93
|
-
}
|
|
94
|
-
const out = [e.stdout, e.stderr].filter(Boolean).join("\n").trim();
|
|
95
|
-
if (out) parts.push(out);
|
|
96
|
-
return parts.join("\n") || "mcpc connect failed";
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/** Run `mcpc connect <mcp.json> --stdio`. Never throws. */
|
|
100
|
-
export async function connectMcpc(): Promise<McpcConnectResult> {
|
|
101
|
-
const configPath = mcpcConfigPath();
|
|
102
|
-
log.debug("connectMcpc entry", configPath);
|
|
103
|
-
|
|
104
|
-
if (!fs.existsSync(configPath)) {
|
|
105
|
-
log.debug("skip: mcp.json missing", configPath);
|
|
106
|
-
return {
|
|
107
|
-
ok: true,
|
|
108
|
-
skipped: true,
|
|
109
|
-
message: `No MCP config at ${configPath} — skip connect (create that file to use mcpc).`,
|
|
110
|
-
};
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
log.info("mcpc connect start", configPath);
|
|
114
|
-
try {
|
|
115
|
-
const { stdout, stderr } = await execMcpcConnect(
|
|
116
|
-
"mcpc",
|
|
117
|
-
["connect", configPath, "--stdio"],
|
|
118
|
-
{
|
|
119
|
-
timeout: CONNECT_TIMEOUT_MS,
|
|
120
|
-
maxBuffer: MAX_BUFFER,
|
|
121
|
-
env: process.env,
|
|
122
|
-
},
|
|
123
|
-
);
|
|
124
|
-
const out = [stdout, stderr].filter(Boolean).join("\n").trim();
|
|
125
|
-
log.info("mcpc connect finished", configPath);
|
|
126
|
-
log.debug("mcpc connect output", out);
|
|
127
|
-
return {
|
|
128
|
-
ok: true,
|
|
129
|
-
skipped: false,
|
|
130
|
-
message: out || `mcpc connect finished (${configPath} --stdio)`,
|
|
131
|
-
};
|
|
132
|
-
} catch (err) {
|
|
133
|
-
const message = formatExecError(err);
|
|
134
|
-
log.warn("mcpc connect failed; continuing", message);
|
|
135
|
-
return { ok: false, skipped: false, message };
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
export function registerMcpc(pi: ExtensionAPI): void {
|
|
140
|
-
log.info("registerMcpc");
|
|
141
|
-
pi.registerCommand("mcpc", {
|
|
142
|
-
description:
|
|
143
|
-
"Reconnect MCP servers from ~/.pi/pi-config/mcp.json (mcpc connect --stdio). Run after editing that file.",
|
|
144
|
-
handler: async (args, ctx) => {
|
|
145
|
-
const sub = (args || "").trim();
|
|
146
|
-
if (sub && sub !== "connect") {
|
|
147
|
-
log.debug("/mcpc ignored unknown args", sub);
|
|
148
|
-
if (ctx.hasUI) ctx.ui.notify("Usage: /mcpc connect", "warning");
|
|
149
|
-
return;
|
|
150
|
-
}
|
|
151
|
-
log.info("/mcpc connect");
|
|
152
|
-
const result = await connectMcpc();
|
|
153
|
-
if (!ctx.hasUI) return;
|
|
154
|
-
if (result.skipped) ctx.ui.notify(result.message, "info");
|
|
155
|
-
else if (result.ok) ctx.ui.notify(result.message, "info");
|
|
156
|
-
else ctx.ui.notify(result.message, "warning");
|
|
157
|
-
},
|
|
158
|
-
});
|
|
159
|
-
|
|
160
|
-
pi.on("session_start", async (event, _ctx) => {
|
|
161
|
-
const reason = (event as { reason?: string })?.reason ?? "unknown";
|
|
162
|
-
if (reason !== "startup") {
|
|
163
|
-
log.debug("skip auto-connect: session_start reason", reason);
|
|
164
|
-
return;
|
|
165
|
-
}
|
|
166
|
-
if (isPiOneshotInvocation()) {
|
|
167
|
-
log.debug("skip auto-connect: oneshot");
|
|
168
|
-
return;
|
|
169
|
-
}
|
|
170
|
-
await connectMcpc();
|
|
171
|
-
});
|
|
172
|
-
}
|
package/rules/15-mcpc.md
DELETED
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
# MCP client (mcpc)
|
|
2
|
-
|
|
3
|
-
Use the `mcpc` command for all MCP server interactions.
|
|
4
|
-
If a task needs a tool outside current capabilities, check `mcpc` for available tools.
|
|
5
|
-
Orchestrator discovers tools via `mcpc grep` / `mcpc @<session> tools-list` and delegates execution to agents.
|
|
6
|
-
|
|
7
|
-
Config file (user-owned): `~/.pi/pi-config/mcp.json`.
|
|
8
|
-
Pi runs `mcpc connect ~/.pi/pi-config/mcp.json --stdio` on process start when that file exists.
|
|
9
|
-
After you edit `mcp.json`, run `/mcpc connect` (or restart pi). Already-active servers stay as `mcpc` reports them.
|
|
10
|
-
|
|
11
|
-
## mcpc commands
|
|
12
|
-
|
|
13
|
-
| Command | Purpose |
|
|
14
|
-
|---|---|
|
|
15
|
-
| `mcpc` / `mcpc --json` | Active sessions and OAuth profiles |
|
|
16
|
-
| `mcpc grep "<query>"` | Search tools across sessions |
|
|
17
|
-
| `mcpc @<session>` | Server info and tools overview |
|
|
18
|
-
| `mcpc @<session> tools-list` | List tools |
|
|
19
|
-
| `mcpc @<session> tools-get <name>` | Tool schema |
|
|
20
|
-
| `mcpc @<session> tools-call <name> …` | Call a tool (`arg:=value` or JSON) |
|
|
21
|
-
| `mcpc @<session> restart` | Restart that session bridge |
|
|
22
|
-
|
|
23
|
-
`--stdio` is required on connect. Do not omit it.
|
|
24
|
-
|
|
25
|
-
## Workflow
|
|
26
|
-
|
|
27
|
-
1. **Search** → `mcpc grep "list projects"` (or `mcpc @<session> tools-list`)
|
|
28
|
-
2. **Inspect** → `mcpc @<session> tools-get <tool>`
|
|
29
|
-
3. **Call** → `mcpc @<session> tools-call <tool> '{"param":"v"}'`
|
|
30
|
-
|
|
31
|
-
## Troubleshooting
|
|
32
|
-
|
|
33
|
-
- **Connect failed at pi start:** session still works; check `~/.pi/logs/orchestrator/` and `/mcpc connect`
|
|
34
|
-
- **Missing binary:** `npm install -g @apify/mcpc`
|
|
35
|
-
- **No config file:** create `~/.pi/pi-config/mcp.json` (standard `mcpServers` JSON)
|