pi-orchestrator-config 4.6.5 → 4.7.1

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 |
@@ -77,14 +77,13 @@ Single extension that provides:
77
77
  | `/dream` | Run memory consolidation — extract, deduplicate, maintain topic-based memory |
78
78
  | `/remember <what>` | Save a memory for future sessions |
79
79
  | `/dream-auto` | Toggle automatic memory dreaming (every 3h + session end) |
80
- | `/cron add\|list\|list-all\|remove [--persist]` | Schedule recurring tasks. Session jobs end with the session; `--persist` keeps a job across Pi sessions in this project. Add/remove mutations still work when leader election fails closed on an unsupported platform. For example: `/cron add every 2h check for new issues --persist` or `/cron remove persist:<uuid>`. `/cron list` opens an overlay for the selected jobs (or both when omitted). Persistent delivery is local at-least-once: a crash or leader recovery can repeat a run. Project cron writes use a stable OS `flock` (included in Docker via `util-linux`) across the entire transaction; native installations need `flock` and a shared filesystem that supports advisory locks. A busy or unavailable lock fails closed after two seconds. **Upgrade gate (enforced):** stop and drain every old Pi process writing crons on the shared mount before starting a version with this kernel lock; old directory-lock writers do not honor `flock` and must not resume. If a legacy `crons.json.mutation.lock` directory exists, new writes fail closed even if its PID is invisible or its heartbeat expired. Only after verifying every old writer cannot resume may an operator remove that stale directory (not `crons.json` or cron tasks). Never remove it merely because a write timed out. |
80
+ | `/cron add\|list\|list-all\|remove [--persist]` | Schedule recurring tasks. Session jobs end with the session; `--persist` keeps a job across Pi sessions in this project. Add/remove mutations still work when leader election fails closed on an unsupported platform. For example: `/cron add every 2h check for new issues --persist` or `/cron remove persist:<uuid>`. Pin one job's model by naming it in the request: `/cron add every 2h check for new issues using openrouter/anthropic/claude-opus-5`. A bare `model-id` (e.g. `claude-opus-5`) keeps the normal provider resolution. An unpinned job uses the worker's normal resolution chain (`agent_overrides[worker]`, worker frontmatter, then `agent_provider`/`agent_model`) — cron does not pass the session model down, so a worker-level setting wins over the session model. An `acpx-*` provider cannot be pinned here: async children cannot register those providers, so the job runs on `internal_operations_provider`/`internal_operations_model` when both are set to a non-`acpx-*` pair, and is skipped with a logged error when they are not. `/cron list` opens an overlay for the selected jobs (or both when omitted). Persistent delivery is local at-least-once: a crash or leader recovery can repeat a run. Project cron writes use a stable OS `flock` (included in Docker via `util-linux`) across the entire transaction; native installations need `flock` and a shared filesystem that supports advisory locks. A busy or unavailable lock fails closed after two seconds. **Upgrade gate (enforced):** stop and drain every old Pi process writing crons on the shared mount before starting a version with this kernel lock; old directory-lock writers do not honor `flock` and must not resume. If a legacy `crons.json.mutation.lock` directory exists, new writes fail closed even if its PID is invisible or its heartbeat expired. Only after verifying every old writer cannot resume may an operator remove that stale directory (not `crons.json` or cron tasks). Never remove it merely because a write timed out. |
81
81
  | `/async-kill [name\|id\|all]` | Kill async agents (overlay picker or by name/id) |
82
82
  | `/pi-config-settings [project\|global]` | Interactive TUI settings editor — browse all pi-config settings with scope indicators, smart pickers for providers/models, and immediate save |
83
83
  | `/status` | Unified session snapshot — async agents, cron tasks, git branch, context usage |
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,
@@ -1,6 +1,9 @@
1
1
  /**
2
2
  * Pure cron status display helpers — no TUI deps (unit-testable).
3
3
  */
4
+ import { createLogger } from "../shared/logger.js";
5
+
6
+ const log = createLogger("cron_status");
4
7
 
5
8
  export interface CronStatusTaskView {
6
9
  /** Unique scope-qualified overlay row id (e.g. "session:<uuid>"). */
@@ -12,6 +15,8 @@ export interface CronStatusTaskView {
12
15
  intervalMs?: number;
13
16
  atHour?: number;
14
17
  atMinute?: number;
18
+ /** "provider/model-id" or bare "model-id"; absent means the session default. */
19
+ model?: string;
15
20
  createdAt: number;
16
21
  lastRun?: number;
17
22
  nextRun?: number;
@@ -88,6 +93,28 @@ export function formatNextRunLabel(
88
93
  return `in ${formatRelativeMs(delta)}`;
89
94
  }
90
95
 
96
+ /** Detail rows rendered by the cron task overlay, uncolored (label, value). */
97
+ export function cronDetailRows(
98
+ task: CronStatusTaskView,
99
+ now = Date.now(),
100
+ ): Array<[string, string]> {
101
+ const rows: Array<[string, string]> = [
102
+ ["Schedule", formatCronSchedule(task)],
103
+ [
104
+ "Last run",
105
+ `${formatLastRunLabel(task, now)}${
106
+ task.lastRun ? ` · ${new Date(task.lastRun).toLocaleString()}` : ""
107
+ }`,
108
+ ],
109
+ ["Next run", formatNextRunLabel(task, now)],
110
+ ["Created", new Date(task.createdAt).toLocaleString()],
111
+ ];
112
+ if (task.sessionLabel) rows.push(["Session", task.sessionLabel]);
113
+ if (task.model) rows.push(["Model", task.model]);
114
+ log.debug("cron_detail_rows", { id: task.id, rows: rows.length, model: task.model || "inherit" });
115
+ return rows;
116
+ }
117
+
91
118
  /** Map a CronTask-like object into an overlay row view. */
92
119
  export function toCronStatusTaskView(
93
120
  task: {
@@ -97,6 +124,7 @@ export function toCronStatusTaskView(
97
124
  intervalMs?: number;
98
125
  atHour?: number;
99
126
  atMinute?: number;
127
+ model?: string;
100
128
  createdAt: number;
101
129
  lastRun?: number;
102
130
  nextRun?: number;
@@ -116,6 +144,7 @@ export function toCronStatusTaskView(
116
144
  intervalMs: task.intervalMs,
117
145
  atHour: task.atHour,
118
146
  atMinute: task.atMinute,
147
+ model: task.model,
119
148
  createdAt: task.createdAt,
120
149
  lastRun: task.lastRun,
121
150
  nextRun: task.nextRun,
@@ -4,6 +4,7 @@
4
4
 
5
5
  import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
6
6
  import {
7
+ cronDetailRows,
7
8
  formatCronSchedule,
8
9
  formatLastRunLabel,
9
10
  formatNextRunLabel,
@@ -16,6 +17,7 @@ import {
16
17
 
17
18
  export type { CronStatusTaskView } from "./cron-status-format.js";
18
19
  export {
20
+ cronDetailRows,
19
21
  formatCronSchedule,
20
22
  formatLastRunLabel,
21
23
  formatNextRunLabel,
@@ -103,24 +105,9 @@ export async function openCronStatusOverlay(
103
105
  if (!current) {
104
106
  return [t.fg("error", "Task removed."), t.fg("dim", "Esc back")];
105
107
  }
106
- const now = Date.now();
107
- const lines = [
108
- `${t.fg("muted", "Schedule")} ${formatCronSchedule(current)}`,
109
- `${t.fg("muted", "Last run")} ${formatLastRunLabel(current, now)}` +
110
- (current.lastRun
111
- ? t.fg(
112
- "dim",
113
- ` · ${new Date(current.lastRun).toLocaleString()}`,
114
- )
115
- : ""),
116
- `${t.fg("muted", "Next run")} ${formatNextRunLabel(current, now)}`,
117
- `${t.fg("muted", "Created")} ${new Date(current.createdAt).toLocaleString()}`,
118
- ];
119
- if (current.sessionLabel) {
120
- lines.push(
121
- `${t.fg("muted", "Session")} ${current.sessionLabel}`,
122
- );
123
- }
108
+ const lines = cronDetailRows(current).map(
109
+ ([label, value]) => `${t.fg("muted", label.padEnd(9))} ${value}`,
110
+ );
124
111
  lines.push("", t.fg("muted", "Task"), current.task);
125
112
  return lines;
126
113
  },
@@ -4,6 +4,7 @@ import * as path from "node:path";
4
4
  import { execFileSync, spawnSync } from "node:child_process";
5
5
  import { randomUUID } from "node:crypto";
6
6
  import { parseProcStartTime } from "./utils.js";
7
+ import { parseModelOverride } from "./parse-model-override.js";
7
8
  import { createLogger } from "../shared/logger.js";
8
9
 
9
10
  export type CronScope = "session" | "project";
@@ -16,6 +17,8 @@ export interface DurableCronTask {
16
17
  intervalMs?: number;
17
18
  atHour?: number;
18
19
  atMinute?: number;
20
+ /** "provider/model-id" or bare "model-id"; absent means inherit. */
21
+ model?: string;
19
22
  createdAt: number;
20
23
  lastRun?: number;
21
24
  nextRun?: number;
@@ -182,6 +185,8 @@ export function validateDurableCronTask(task: unknown): asserts task is DurableC
182
185
  if (value.atHour !== undefined && (!Number.isInteger(value.atHour) || value.atHour < 0 || value.atHour > 23)) throw new Error("Invalid durable cron hour");
183
186
  if (value.atMinute !== undefined && (!Number.isInteger(value.atMinute) || value.atMinute < 0 || value.atMinute > 59 || value.atHour === undefined)) throw new Error("Invalid durable cron minute");
184
187
  if (value.intervalMs === undefined && value.atHour === undefined) throw new Error("Invalid durable cron schedule");
188
+ // Disk data is untrusted: guard the type before parseModelOverride, which assumes a string.
189
+ if (value.model !== undefined && (typeof value.model !== "string" || !parseModelOverride(value.model))) throw new Error("Invalid durable cron model");
185
190
  if ((value.lastRun !== undefined && !Number.isFinite(value.lastRun)) || (value.nextRun !== undefined && !Number.isFinite(value.nextRun))) throw new Error("Invalid durable cron timestamp");
186
191
  }
187
192
  function validate(tasks: DurableCronTask[]) { for (const task of tasks) validateDurableCronTask(task); }
@@ -8,12 +8,13 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
8
8
  import { createLogger } from "../shared/logger.js";
9
9
  import { decideAsyncLlmDispatch } from "./async-capability.js";
10
10
  import { formatCronSchedule, toCronStatusTaskView } from "./cron-status-format.js";
11
+ import { parseModelOverride } from "./parse-model-override.js";
11
12
  import { openCronStatusOverlay } from "./cron-status-ui.js";
12
13
  import { setSlot } from "./status-bar.js";
13
14
  import { acquireLeaderLock, durableCronSupported, isLeaderLockCurrent, mutateDurableCronStore, readDurableCronStore, refreshLeaderLock, releaseLeaderLock, validateDurableCronTask, type CronLockOwner, type CronScope, type DurableCronTask } from "./cron-store.js";
14
15
 
15
16
  const log = createLogger("cron");
16
- export interface CronTask { id: string; scope: CronScope; cwd: string; description: string; task: string; intervalMs?: number; atHour?: number; atMinute?: number; createdAt: number; lastRun?: number; nextRun?: number; leader?: boolean; }
17
+ export interface CronTask { id: string; scope: CronScope; cwd: string; description: string; task: string; intervalMs?: number; atHour?: number; atMinute?: number; /** "provider/model-id" or bare "model-id"; undefined inherits the normal resolution chain. */ model?: string; createdAt: number; lastRun?: number; nextRun?: number; leader?: boolean; }
17
18
  type Timer = ReturnType<typeof setTimeout> | ReturnType<typeof setInterval>;
18
19
  const durable = (scope: CronScope) => scope === "project";
19
20
  let sessionStore = "";
@@ -30,6 +31,7 @@ function validateSessionCronTask(task: unknown): asserts task is CronTask {
30
31
  const value = task as CronTask;
31
32
  if (!value || typeof value !== "object" || typeof value.id !== "string" || !value.id || typeof value.description !== "string" || typeof value.task !== "string" || !value.task.trim() || !Number.isFinite(value.createdAt)) throw new Error("Invalid session cron task");
32
33
  validateSchedule({ interval_seconds: value.intervalMs === undefined ? undefined : value.intervalMs / 1000, at_hour: value.atHour, at_minute: value.atMinute });
34
+ if (value.model !== undefined && (typeof value.model !== "string" || !parseModelOverride(value.model))) throw new Error("Invalid session cron model");
33
35
  if ((value.lastRun !== undefined && !Number.isFinite(value.lastRun)) || (value.nextRun !== undefined && !Number.isFinite(value.nextRun))) throw new Error("Invalid session cron timestamp");
34
36
  }
35
37
  function writeSessionTasks(file: string, tasks: Iterable<CronTask>) {
@@ -67,6 +69,14 @@ function validateSchedule(details: any) {
67
69
  if (minute !== undefined && (!Number.isInteger(minute) || minute < 0 || minute > 59)) throw new Error("at_minute must be an integer from 0 to 59");
68
70
  if (minute !== undefined && hour === undefined) throw new Error("at_minute requires at_hour");
69
71
  }
72
+ /** Normalize a "provider/model-id" or "model-id" override; undefined means inherit. */
73
+ function normalizeModel(spec: unknown): string | undefined {
74
+ if (typeof spec !== "string" || !spec.trim()) { log.debug("cron_model_absent", { reason: typeof spec === "string" ? "blank" : "missing" }); return undefined; }
75
+ const parsed = parseModelOverride(spec);
76
+ if (!parsed) { log.warn("cron_model_rejected", { spec: spec.slice(0, 120) }); throw new Error('model must be "provider/model-id" or "model-id"'); }
77
+ log.debug("cron_model_normalized", { spec: spec.trim().slice(0, 120), provider: parsed.provider, model: parsed.model });
78
+ return parsed.provider ? `${parsed.provider}/${parsed.model}` : parsed.model!;
79
+ }
70
80
  function nextDelay(task: CronTask) { if (task.intervalMs) return task.intervalMs; const target = new Date(); target.setHours(task.atHour!, task.atMinute!, 0, 0); if (+target <= Date.now()) target.setDate(target.getDate() + 1); return +target - Date.now(); }
71
81
 
72
82
  export function registerCron(pi: ExtensionAPI, spawnAsyncAgent: any): { getCronTasks: () => CronTask[] } {
@@ -115,7 +125,9 @@ export function registerCron(pi: ExtensionAPI, spawnAsyncAgent: any): { getCronT
115
125
  }
116
126
  pi.sendUserMessage(cmd, { deliverAs: "followUp" }); return;
117
127
  }
118
- const dispatch = decideAsyncLlmDispatch({ parentProvider: ctx?.model?.provider, cwd: task.cwd, mustAsync: true });
128
+ const explicit = parseModelOverride(task.model);
129
+ const dispatch = decideAsyncLlmDispatch({ parentProvider: explicit?.provider ?? ctx?.model?.provider, cwd: task.cwd, mustAsync: true });
130
+ log.debug("cron_dispatch", { id: qualifyCronId(task), model: task.model || "inherit", action: dispatch.action });
119
131
  if (dispatch.action === "skip") { log.error("cron_error", qualifyCronId(task), dispatch.note); return; }
120
132
  const { discoverAgents } = await import("./agents.js");
121
133
  const { agents } = discoverAgents(task.cwd, "user");
@@ -123,7 +135,11 @@ export function registerCron(pi: ExtensionAPI, spawnAsyncAgent: any): { getCronT
123
135
  log.warn("cron_execution_fenced", { id: qualifyCronId(task), reason: "leader_lease_lost_before_dispatch" });
124
136
  election(); return;
125
137
  }
126
- spawnAsyncAgent("worker", cmd, task.cwd, agents, { name: `Cron: ${task.description.slice(0, 40)}`, ...(dispatch.action === "sidecar-async" ? { parentProvider: dispatch.sidecar.provider, parentModelId: dispatch.sidecar.model } : {}) });
138
+ // The sidecar must be authoritative: passed as parent fields it could lose to worker
139
+ // overrides/frontmatter, which async children cannot always register. An unusable
140
+ // acpx-* override is discarded rather than passed at all.
141
+ const override = dispatch.action === "sidecar-async" ? dispatch.sidecar : explicit;
142
+ spawnAsyncAgent("worker", cmd, task.cwd, agents, { name: `Cron: ${task.description.slice(0, 40)}`, ...(override ? { explicit: override } : {}) });
127
143
  } catch (error: any) { log.error("cron_execute_failed", qualifyCronId(task), error?.message || error); }
128
144
  }
129
145
  function stop(id: string) { const timer = timers.get(id); if (timer) { clearTimeout(timer as any); clearInterval(timer as any); timers.delete(id); } }
@@ -162,7 +178,7 @@ export function registerCron(pi: ExtensionAPI, spawnAsyncAgent: any): { getCronT
162
178
  if (scope !== "session" && scope !== "project") throw new Error("scope must be session or project");
163
179
  if (durable(scope) && !durableCronSupported()) throw new Error("Persistent cron scheduling is unavailable on this platform");
164
180
  validateSchedule(details);
165
- const task: CronTask = { id: randomUUID(), scope, cwd: ctx?.cwd || process.cwd(), description: details.description || details.task.slice(0, 60), task: details.task, intervalMs: details.interval_seconds !== undefined ? details.interval_seconds * 1000 : undefined, atHour: details.at_hour, atMinute: details.at_minute ?? (details.at_hour !== undefined ? 0 : undefined), createdAt: Date.now(), leader: scope === "session" };
181
+ const task: CronTask = { id: randomUUID(), scope, cwd: ctx?.cwd || process.cwd(), description: details.description || details.task.slice(0, 60), task: details.task, intervalMs: details.interval_seconds !== undefined ? details.interval_seconds * 1000 : undefined, atHour: details.at_hour, atMinute: details.at_minute ?? (details.at_hour !== undefined ? 0 : undefined), model: normalizeModel(details.model), createdAt: Date.now(), leader: scope === "session" };
166
182
  if (!task.task || (!task.intervalMs && task.atHour === undefined)) throw new Error("task and a schedule are required");
167
183
  if (durable(scope)) mutateDurableCronStore(projectStore(task.cwd), old => [...old, task as DurableCronTask]);
168
184
  tasks.set(task.id, task);
@@ -177,9 +193,9 @@ export function registerCron(pi: ExtensionAPI, spawnAsyncAgent: any): { getCronT
177
193
  const task = tasks.get(id); if (scope === "session") { if (!task) return false; stop(id); tasks.delete(id); if (sessionStore) writeSessionTasks(sessionStore, [...tasks.values()].filter(t => t.scope === "session")); } else { const file = projectStore(ctx.cwd); let found = false; mutateDurableCronStore(file, old => { found = old.some(t => t.id === id); return old.filter(t => t.id !== id); }); if (!found) return false; if (task) { stop(id); tasks.delete(id); } }
178
194
  updateStatus(); return true;
179
195
  }
180
- pi.registerTool({ name: "cron_manage", description: "Manage scheduled tasks. Set persist=true to keep a task across Pi sessions in this project.", parameters: Type.Object({ action: Type.Union([Type.Literal("add"), Type.Literal("list"), Type.Literal("remove")]), persist: Type.Optional(Type.Boolean()), description: Type.Optional(Type.String()), task: Type.Optional(Type.String()), interval_seconds: Type.Optional(Type.Number()), at_hour: Type.Optional(Type.Number()), at_minute: Type.Optional(Type.Number()), id: Type.Optional(Type.String()) }), async execute(_id, params: any) {
196
+ pi.registerTool({ name: "cron_manage", description: "Manage scheduled tasks. Set persist=true to keep a task across Pi sessions in this project. Set model to \"provider/model-id\" (or a bare \"model-id\") to pin that cron's model.", parameters: Type.Object({ action: Type.Union([Type.Literal("add"), Type.Literal("list"), Type.Literal("remove")]), persist: Type.Optional(Type.Boolean()), description: Type.Optional(Type.String()), task: Type.Optional(Type.String()), model: Type.Optional(Type.String()), interval_seconds: Type.Optional(Type.Number()), at_hour: Type.Optional(Type.Number()), at_minute: Type.Optional(Type.Number()), id: Type.Optional(Type.String()) }), async execute(_id, params: any) {
181
197
  const scope: CronScope = params.persist ? "project" : "session";
182
- try { if (params.action === "add" && (ctx?.mode === "print" || ctx?.mode === "json")) return { content: [{ type: "text", text: "Error: Cron scheduling is unavailable in one-shot mode." }] }; if (params.action === "add") { const task = add(scope, params); return { content: [{ type: "text", text: `Cron ${qualifyCronId(task)} created: ${formatCronSchedule(task)} → ${task.description}` }] }; } if (params.action === "remove") return { content: [{ type: "text", text: remove(params.id || "") ? "Cron removed." : "Task not found." }] }; const selected = [...tasks.values()].filter(t => !params.persist || t.scope === "project"); return { content: [{ type: "text", text: selected.length ? selected.map(t => `${qualifyCronId(t)} | ${formatCronSchedule(t)} | ${t.description} | ${t.leader === false ? "waiting for leader" : "active"}`).join("\n") : "No scheduled tasks." }] }; } catch (error: any) { return { content: [{ type: "text", text: `Error: ${error.message}` }] }; }
198
+ try { if (params.action === "add" && (ctx?.mode === "print" || ctx?.mode === "json")) return { content: [{ type: "text", text: "Error: Cron scheduling is unavailable in one-shot mode." }] }; if (params.action === "add") { const task = add(scope, params); return { content: [{ type: "text", text: `Cron ${qualifyCronId(task)} created: ${formatCronSchedule(task)} → ${task.description}${task.model ? ` [${task.model}]` : ""}` }] }; } if (params.action === "remove") return { content: [{ type: "text", text: remove(params.id || "") ? "Cron removed." : "Task not found." }] }; const selected = [...tasks.values()].filter(t => !params.persist || t.scope === "project"); return { content: [{ type: "text", text: selected.length ? selected.map(t => `${qualifyCronId(t)} | ${formatCronSchedule(t)} | ${t.description} | ${t.model || "inherited model"} | ${t.leader === false ? "waiting for leader" : "active"}`).join("\n") : "No scheduled tasks." }] }; } catch (error: any) { return { content: [{ type: "text", text: `Error: ${error.message}` }] }; }
183
199
  }, } as any);
184
200
  pi.registerCommand("cron", { description: "Schedule tasks: add, list, list-all, remove; use --persist to keep a cron", handler: async (args, commandCtx) => { ctx = commandCtx; const parsed = parseCronScope((args || "").trim().split(/\s+/).filter(Boolean)); if (parsed.error) { ctx.ui?.notify(parsed.error, "warning"); return; } const [sub, ...rest] = parsed.rest; if (sub === "list" || sub === "list-all") { const showAll = sub === "list-all"; if (ctx.hasUI) await openCronStatusOverlay(ctx, { title: showAll ? "All cron tasks" : "Cron tasks", listTasks: () => [...tasks.values()].filter(t => showAll || !args.includes("--persist") || t.scope === "project").map(t => toCronStatusTaskView(t, { overlayId: qualifyCronId(t), isLocal: t.scope === "session", sessionLabel: t.leader === false ? `${t.scope} (waiting for leader)` : t.scope })), removeTask: remove }); return; } if (["remove", "rm", "delete"].includes(sub) && rest[0]) { ctx.ui?.notify(remove(rest[0]) ? "Cron removed." : "Task not found.", "info"); return; } pi.sendUserMessage(`Schedule this cron request using cron_manage. Set persist to ${parsed.scope === "project"}. Use persistence only when the user requests it. User request: "${parsed.rest.join(" ")}"`, { deliverAs: "followUp" }); }});
185
201
  pi.events.on("pidash:cron-kill", (target: unknown) => {
@@ -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.5",
4
+ "version": "4.7.1",
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.5",
3
+ "version": "4.7.1",
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)