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