pi-orchestrator-config 4.7.0 → 4.7.2

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
@@ -77,7 +77,7 @@ 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 |
@@ -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) => {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pidiff-ui",
3
3
  "private": true,
4
- "version": "4.7.0",
4
+ "version": "4.7.2",
5
5
  "type": "module",
6
6
  "scripts": {
7
7
  "dev": "vite",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-orchestrator-config",
3
- "version": "4.7.0",
3
+ "version": "4.7.2",
4
4
  "description": "Orchestrator pattern for pi: specialist subagents, enforcement, code review loop, workflow commands",
5
5
  "keywords": [
6
6
  "pi-package"