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 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`, `/mcpc connect` — with autocomplete argument hints |
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** — mcpc, acpx, agent-browser, graft
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.87.0**). Pidash uses Pi's `ui_prompt_start` and `ui_prompt_end` events to show prompt wait separately from agent work.
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
- const { model: effectiveModel, provider: effectiveProvider } = resolveAgentModelProvider(agentName, agent, options?.parentModelId, options?.parentProvider, cwd, options?.explicit);
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
- const freshness = state === "syncing" ? fg("warning", "syncing…") : state === "stale" ? fg("warning", "⚠ stale") : state === "failed" ? fg("error", "failed") : fg("success", "✓ synced");
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
- 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 = "failed"; state.gate = { active: false, retrieved: false, pointers: new Set<string>() };
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
- setState("failed");
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" : "failed"); if (!child) void refresh(); }
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
- export function resolveWorktreeRoot(cwd: string): string {
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
- console.debug("[utils] resolveWorktreeRoot failed:", e?.message);
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
- worktreeRootCache.set(key, cwd);
112
- return cwd;
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
- /** Minimum pi version required by this pi-config version. */
151
- export const MIN_PI_VERSION = "0.87.0";
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 {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pidiff-ui",
3
3
  "private": true,
4
- "version": "4.6.4",
4
+ "version": "4.7.0",
5
5
  "type": "module",
6
6
  "scripts": {
7
7
  "dev": "vite",
@@ -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.87.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.6.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 `mcpc`, ask questions, analyze, plan,
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 `mcpc`) — delegate to specialists.
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)