pi-orchestrator-config 4.25.0 → 4.26.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/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
1
1
  # Changelog
2
2
 
3
+ ## [4.26.1] - 2026-11-06
4
+
5
+ - The tmp sweep carries no hardcoded control-file name list: it asks
6
+ `daemon-manager.isLiveLegacyDaemonFile()`, which owns the two legacy names, so a future daemon
7
+ writing to `.pi/run` can never require the sweep to be edited. A legacy `pidiff.port`/`pidiff.pid`
8
+ is kept only while its recorded PID is alive (`EPERM` counts as alive — another user's running
9
+ process), and a dead daemon's leftovers plus every `reload.*` marker are swept like any other
10
+ entry. Deleting a live daemon's lockfiles would make the next connect() spawn a duplicate on its
11
+ port.
12
+ - A control file can no longer block or exhaust a session: `readLockfile` accepts only regular
13
+ files, so a project FIFO at `pidiff.port`/`pidiff.pid` is ignored, and the pid read is capped at
14
+ 64 bytes so an oversized file is rejected without being read.
15
+
16
+ ## [4.26.0] - 2026-11-06
17
+
18
+ - Async `chain` mode: steps run in sequence and each step's result is substituted into `{previous}`,
19
+ with per-step `taskId`/`cwd`, the step position in the status line and result header, a durable
20
+ `chain.json` for reload reporting, and stop-on-failure. `onComplete` receives `{ text, isError }` and
21
+ fires exactly once, so a retried reconciliation pass cannot spawn the next step twice.
22
+ - A tool call pi rejects for invalid arguments now forces a retry. Such a call skips `tool_call` and
23
+ `tool_result`, so nothing told the model its action never ran and a turn could end silently. The guard
24
+ matches pi's own rejection preamble, keeps only the parameter diagnostics (argument values can hold
25
+ credentials and the system note is a provenance-marked channel), and names the likely mistake.
26
+ - The UI bridge degrade path honours an approval again: without `ctx.ui.custom`, `confirm` returned a
27
+ boolean while the wrapper compared `"__confirmed__"`, so every prompt in that runtime cancelled.
28
+
3
29
  ## [4.25.0] - 2026-11-06
4
30
 
5
31
  - `.pi/tmp` is cleaned at session start: above `project_tmp_max_size` (default `2G`), entries older than
package/README.md CHANGED
@@ -12,9 +12,9 @@ Single extension that provides:
12
12
 
13
13
  | Feature | Description |
14
14
  | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
15
- | **Subagent tool** | Delegate tasks to specialist agents (single, parallel, chain, async modes); optional `model` param for model override |
15
+ | **Subagent tool** | Delegate tasks to specialist agents (single, parallel, chain, async, and async-chain modes); optional `model` param for model override. Chains also run async: every step needs its own `taskId` and `cwd`, each step receives the previous step's output as `{previous}`, and a failed step stops the chain |
16
16
  | **`list_models`** | List available models and providers for subagent model override |
17
- | **Async background agents** | Spawn agents in background with `async: true` — results surface automatically when complete. Fullscreen overlay dashboard with live output, keyboard nav, and kill support (`/async-status`: `x` kills, `Space` multi-selects, `a` kills all). On **acpx** parents, optional async is coerced to sync; dream/cron need `internal_operations_provider` + `internal_operations_model`. `cli-*` providers support async natively (see `docs/cli-provider.md`) |
17
+ | **Async background agents** | Spawn agents in background with `async: true` — results surface automatically when complete. Fullscreen overlay dashboard with live output, keyboard nav, and kill support (`/async-status`: `x` kills, `Space` multi-selects, `a` kills all). Async applies to chains too: step 1 spawns immediately and each step starts when the previous one ends. On **acpx** parents, optional async is coerced to sync; dream/cron need `internal_operations_provider` + `internal_operations_model`. `cli-*` providers support async natively (see `docs/cli-provider.md`) |
18
18
  | **CLI / ACPX providers** | Optional `cli_agents` / `acpx_agents` register `cli-*` / `acpx-*` via native `createProvider` (pi ≥ 0.84): `/login cli-<agent>` or `/login acpx-<agent>`, model refresh, filter when unavailable. Missing context window / maxTokens / cost filled from models.dev (`~/.pi/pi-config/models.dev.json`, 1-day refresh). Cold-start default restore (#753): gates `startup`\|`resume` (`new` only when no model is selected), empty `enabledModels`, trusted project merge — see `docs/cli-provider.md`. Headless Cursor `--approve-mcps` when `CLI_APPROVE_MCPS` is set (`false` opts out) or the process is a sidecar (`SIDECAR_PORT`; `startSidecar()` stamps it while running and restores it on `close()`). Executable sidecar consumers should call `bindSidecarListenExit()` so bind/`stopped` fatals exit 1. Headless Gemini defaults `GEMINI_CLI_TRUST_WORKSPACE=true` and preserves an explicit parent value (including `false`). |
19
19
  | **`/async-status` command** | Show status of background agents — select one for live output streaming, multi-select and kill (`x`), or kill everything (`a`) |
20
20
  | **`ask_user` tool** | Structured user input with options and free-text — used by workflows |
@@ -35,6 +35,7 @@ import { setSlot } from "./status-bar.js";
35
35
  import { getSetting } from "./project-settings.js";
36
36
  import { substituteSettingsPlaceholders } from "./rule-placeholders.js";
37
37
  import { isLiveExtensionCtx } from "../shared/live-ctx.js";
38
+ import { sendSystemNote } from "../shared/system-note.js";
38
39
 
39
40
  const SETTINGS_KEYS: Record<string, unknown> = JSON.parse(
40
41
  fs.readFileSync(
@@ -66,7 +67,8 @@ export interface AsyncJob {
66
67
  delivered?: boolean;
67
68
  sideEffectsApplied?: boolean;
68
69
  fireAndForget?: boolean;
69
- onComplete?: () => void;
70
+ /** One-shot: fires once with the ingested result text. Used by async chains to feed {previous}. */
71
+ onComplete?: (result: { text: string; isError: boolean }) => void;
70
72
  groupId?: string;
71
73
  taskId?: string;
72
74
  cwd?: string;
@@ -87,6 +89,26 @@ export interface AsyncJob {
87
89
  */
88
90
  export type AsyncKillOrigin = "user" | "orchestrator" | "task-system";
89
91
 
92
+ /** Durable record of an async chain's position, written into each step's workerDir
93
+ * as chain.json. The step's continuation lives in the onComplete closure, which a
94
+ * reload destroys — this file is what lets restoration report the chain instead of
95
+ * dropping it silently. */
96
+ export interface AsyncChainState {
97
+ steps: Array<{ agent: string; task: string }>;
98
+ /** Index (0-based) of the step being spawned when this was written. */
99
+ index: number;
100
+ previous: string;
101
+ /** Set once restoration has reported this chain as interrupted. */
102
+ interruptedAt?: number;
103
+ }
104
+
105
+ function readChainState(workerDir: string): AsyncChainState | null {
106
+ try {
107
+ const parsed = JSON.parse(fs.readFileSync(path.join(workerDir, "chain.json"), "utf-8"));
108
+ return Array.isArray(parsed?.steps) ? parsed as AsyncChainState : null;
109
+ } catch { return null; }
110
+ }
111
+
90
112
  /** Human-readable attribution per origin. The single source for every surface. */
91
113
  export const KILL_ORIGIN_LABELS: Record<AsyncKillOrigin, string> = {
92
114
  user: "Killed by user",
@@ -99,6 +121,32 @@ function jobCwd(job: { cwd?: string; projectCwd?: string }): string {
99
121
  return job.cwd || job.projectCwd || process.cwd();
100
122
  }
101
123
 
124
+ /** Fire the job's completion hook exactly once, with the already-ingested result.
125
+ * Clearing it first stops the reconciliation pass from re-firing it (which would
126
+ * spawn the next async-chain step twice after a failed delivery). */
127
+ export function fireOnComplete(job: AsyncJob): void {
128
+ const cb = job.onComplete;
129
+ job.onComplete = undefined;
130
+ if (!cb) return;
131
+ try {
132
+ cb({ text: typeof job.output === "string" ? job.output : "", isError: job.status === "failed" });
133
+ } catch (e: any) { log.error(`onComplete callback failed for ${job.id}: ${e?.message}`); }
134
+ }
135
+
136
+ /** Fire every pending completion hook in one reconciliation pass.
137
+ * Returns how many fired. Exported so the PASS is testable, not just the
138
+ * single-job helper — the hook-once guarantee is a property of the loop. */
139
+ export function firePendingCompletions(jobs: Iterable<AsyncJob>): number {
140
+ let fired = 0;
141
+ for (const job of jobs) {
142
+ if (!job.onComplete) continue;
143
+ fireOnComplete(job);
144
+ fired++;
145
+ }
146
+ if (fired > 0) log.info("fire_pending_completions", { fired });
147
+ return fired;
148
+ }
149
+
102
150
  interface AsyncState {
103
151
  jobs: Map<string, AsyncJob>;
104
152
  poller: ReturnType<typeof setInterval> | null;
@@ -145,7 +193,7 @@ export function registerAsyncAgents(
145
193
  discoverAgents?: (cwd: string, scope: "both") => Pick<AgentDiscoveryResult, "agents">;
146
194
  } = {},
147
195
  ): {
148
- spawnAsyncAgent: (agentName: string, task: string, cwd: string, agents: AgentConfig[], options?: { fireAndForget?: boolean; name?: string; parentModelId?: string; parentProvider?: string; groupId?: string; taskId?: string; onComplete?: () => void; persistSession?: boolean; explicit?: { model?: string; provider?: string } }) => { id: string; error?: string; model?: string };
196
+ spawnAsyncAgent: (agentName: string, task: string, cwd: string, agents: AgentConfig[], options?: { fireAndForget?: boolean; name?: string; parentModelId?: string; parentProvider?: string; groupId?: string; taskId?: string; onComplete?: (result: { text: string; isError: boolean }) => void; persistSession?: boolean; explicit?: { model?: string; provider?: string }; chain?: AsyncChainState }) => { id: string; error?: string; model?: string };
149
197
  killAsyncAgent: (target: string, origin?: AsyncKillOrigin) => { killed: string[]; errors: string[] };
150
198
  getAsyncJobs: () => Array<{ id: string; agent: string; name?: string; task: string; status: string; startedAt: number }>;
151
199
  } {
@@ -625,9 +673,7 @@ export function registerAsyncAgents(
625
673
  content: msgContent,
626
674
  display: true,
627
675
  }, { triggerTurn: true, deliverAs: "followUp" });
628
- if (job.onComplete) {
629
- try { job.onComplete(); } catch (e: any) { log.error(`onComplete callback failed for ${job.id}: ${e?.message}`); }
630
- }
676
+ fireOnComplete(job);
631
677
  job.delivered = true;
632
678
  recordDelivered(job.id);
633
679
  log.debug("reconcile_deliver_ok", job.id);
@@ -646,9 +692,7 @@ export function registerAsyncAgents(
646
692
  } catch {}
647
693
  }
648
694
  } else if (job.fireAndForget) {
649
- if (job.onComplete) {
650
- try { job.onComplete(); } catch (e: any) { log.error(`onComplete callback failed for ${job.id}: ${e?.message}`); }
651
- }
695
+ fireOnComplete(job);
652
696
  job.delivered = true;
653
697
  }
654
698
  }
@@ -719,12 +763,8 @@ export function registerAsyncAgents(
719
763
 
720
764
  // Skip delivery if ALL jobs in group are fire-and-forget
721
765
  if (groupJobs.every(j => j.fireAndForget)) {
722
- for (const j of groupJobs) {
723
- if (j.onComplete) {
724
- try { j.onComplete(); } catch (e: any) { log.error(`onComplete callback failed for ${j.id}: ${e?.message}`); }
725
- }
726
- j.delivered = true;
727
- }
766
+ firePendingCompletions(groupJobs);
767
+ for (const j of groupJobs) j.delivered = true;
728
768
  // Clean up result files for fire-and-forget groups
729
769
  for (const j of groupJobs) {
730
770
  const rp = path.join(ASYNC_RESULTS_DIR, `${j.id}.json`);
@@ -986,10 +1026,8 @@ export function registerAsyncAgents(
986
1026
  sendSucceeded = true; // fire-and-forget doesn't need sendMessage
987
1027
  }
988
1028
 
989
- // Invoke onComplete callback if registered (e.g., dreaming → rebuildAndOrganize)
990
- if (job.onComplete) {
991
- try { job.onComplete(); } catch (e: any) { log.error(`onComplete callback failed for ${job.id}: ${e?.message}`); }
992
- }
1029
+ // Invoke onComplete callback if registered (e.g., dreaming, async chain step)
1030
+ fireOnComplete(job);
993
1031
  if (sendSucceeded) {
994
1032
  job.delivered = true;
995
1033
  // Persist delivered state so restore skips this job
@@ -1035,7 +1073,7 @@ export function registerAsyncAgents(
1035
1073
  task: string,
1036
1074
  cwd: string,
1037
1075
  agents: AgentConfig[],
1038
- options?: { fireAndForget?: boolean; name?: string; parentModelId?: string; parentProvider?: string; groupId?: string; taskId?: string; onComplete?: () => void; persistSession?: boolean; explicit?: { model?: string; provider?: string } },
1076
+ options?: { fireAndForget?: boolean; name?: string; parentModelId?: string; parentProvider?: string; groupId?: string; taskId?: string; onComplete?: (result: { text: string; isError: boolean }) => void; persistSession?: boolean; explicit?: { model?: string; provider?: string }; chain?: AsyncChainState },
1039
1077
  ): { id: string; error?: string; model?: string } {
1040
1078
  const agent = agents.find(a => a.name === agentName);
1041
1079
  if (!agent) return { id: "", error: `Unknown agent: "${agentName}"` };
@@ -1070,6 +1108,12 @@ export function registerAsyncAgents(
1070
1108
  sessionId: getLiveAsyncCtx()?.sessionManager?.getSessionId?.() || null,
1071
1109
  }), { mode: 0o600 });
1072
1110
 
1111
+ // Chain position — a reload drops the onComplete continuation, so restore reads
1112
+ // this file to report the chain it can no longer resume instead of losing it.
1113
+ if (options?.chain) {
1114
+ fs.writeFileSync(path.join(workerDir, "chain.json"), JSON.stringify(options.chain), { mode: 0o600 });
1115
+ }
1116
+
1073
1117
  // Track reviewer spawn BEFORE building args — addReviewerPending sets status to
1074
1118
  // in_progress, so all reviewers in the same batch see consistent state.
1075
1119
  if (agentName.startsWith("code-reviewer-")) {
@@ -1516,6 +1560,22 @@ export function registerAsyncAgents(
1516
1560
  };
1517
1561
  asyncState.jobs.set(id, job);
1518
1562
  log.info(`restored job: ${id} state=${job.status}`);
1563
+
1564
+ // A chain's continuation lived in the onComplete closure this reload destroyed.
1565
+ // chain.json records what was still pending — report it, never drop it silently.
1566
+ const chain = readChainState(job.workerDir);
1567
+ if (chain && chain.interruptedAt === undefined && chain.index + 1 < chain.steps.length) {
1568
+ const remaining = [...new Set(chain.steps.slice(chain.index + 1).map(s => s.agent))];
1569
+ log.error(`async_chain_interrupted: chain interrupted at step ${chain.index + 1} of ${chain.steps.length} (${job.agent}), remaining agents: ${remaining.join(", ")}`);
1570
+ void sendSystemNote(
1571
+ pi,
1572
+ `🚨 Async chain interrupted at step ${chain.index + 1} of ${chain.steps.length} — the extension reloaded, so the chain cannot continue. Step ${chain.index + 1} (${job.agent}) may still be running; steps ${chain.index + 2}-${chain.steps.length} were never spawned. Remaining agents: ${remaining.join(", ")}. Re-run them with subagent(chain: ...).`,
1573
+ { customType: "subagent-chain-error", triggerTurn: true, deliverAs: "followUp" },
1574
+ ).catch((e: any) => log.error(`chain_interrupted note failed: ${e?.message || e}`));
1575
+ // Durable so a second reload does not repeat the note.
1576
+ chain.interruptedAt = Date.now();
1577
+ try { fs.writeFileSync(path.join(job.workerDir, "chain.json"), JSON.stringify(chain), { mode: 0o600 }); } catch {}
1578
+ }
1519
1579
  }
1520
1580
  }
1521
1581
  } catch (e: any) { log.error(`job restore failed: ${e?.message || e}`); }
@@ -19,6 +19,7 @@ import { registerAskUser } from "./ask-user.js";
19
19
  import { registerAsyncAgents } from "./async-agents.js";
20
20
  import { registerDreaming } from "./dreaming.js";
21
21
  import { registerEnforcement } from "./enforcement.js";
22
+ import { registerToolArgGuard } from "./tool-arg-guard.js";
22
23
  import { registerProjectSettings } from "./project-settings.js";
23
24
  import { registerRules } from "./rules.js";
24
25
  import { registerSessionValidation } from "./session-validation.js";
@@ -132,6 +133,8 @@ export default function (pi: ExtensionAPI) {
132
133
  registerProjectSettings(pi);
133
134
  registerTrail(pi);
134
135
  registerEnforcement(pi, IN_CONTAINER);
136
+ // A rejected-before-run tool call must force a retry, not end the turn silently.
137
+ registerToolArgGuard(pi);
135
138
  registerReviewUI(pi);
136
139
  registerRules(pi, getAsyncJobs);
137
140
 
@@ -31,6 +31,7 @@ import {
31
31
  } from "@earendil-works/pi-tui";
32
32
  import { Type } from "typebox";
33
33
  import { type AgentConfig, type AgentScope, discoverAgents } from "./agents.js";
34
+ import type { AsyncChainState } from "./async-agents.js";
34
35
 
35
36
  // Task store access via the pitasks module.
36
37
  // `taskStore` is `export let` — a live binding the pitasks extension assigns when it
@@ -107,6 +108,23 @@ const SYNC_TIME_EXCEEDED_ERROR = (seconds: number, limit: number, cwd: string) =
107
108
  };
108
109
  const MISSING_ESTIMATE_ERROR = "Missing required parameter: estimatedSeconds. Provide an estimated duration in seconds for sync agent tasks.";
109
110
 
111
+ /** Cap for the {previous} substitution: the whole task becomes ONE argv entry, so a
112
+ * large result would blow past ARG_MAX and fail the next step's spawn. */
113
+ const MAX_PREVIOUS_CHARS = 32_000;
114
+
115
+ /** Truncate a step result for the next step, saying how much was dropped. */
116
+ function capPrevious(previous: string): string {
117
+ const dropped = previous.length - MAX_PREVIOUS_CHARS;
118
+ if (dropped <= 0) return previous;
119
+ return `${previous.slice(0, MAX_PREVIOUS_CHARS)}\n\n[truncated: ${dropped} of ${previous.length} chars dropped to keep the next step's task argument under the OS limit]`;
120
+ }
121
+
122
+ /** Substitute {previous} literally — String.replace would expand $&, $$, $' and $` in the result. */
123
+ function withPrevious(task: string, previous: string): string {
124
+ if (!task.includes("{previous}")) return task;
125
+ return task.split("{previous}").join(capPrevious(previous));
126
+ }
127
+
110
128
  let syncPenalty = 0;
111
129
  let lastSyncAgent = "";
112
130
  let lastSyncEstimate = 0;
@@ -139,6 +157,7 @@ const ChainItem = Type.Object({
139
157
  }),
140
158
  cwd: Type.String({ description: "Working directory" }),
141
159
  estimatedSeconds: Type.Number({ description: "Estimated step duration in seconds" }),
160
+ taskId: Type.Optional(Type.String({ description: "Task ID to auto-complete when this async chain step finishes" })),
142
161
  });
143
162
  // pi 1.5.0's StringEnum returns a pi-schema Schema, which TypeBox's Static cannot
144
163
  // read — params.agentScope degraded to `unknown`. Unsafe keeps the JSON-Schema
@@ -638,7 +657,7 @@ async function validateTaskId(taskId: string, _cwd: string, _sessionId?: string)
638
657
 
639
658
  export function registerSubagentTool(
640
659
  pi: ExtensionAPI,
641
- spawnAsyncAgent: (agentName: string, task: string, cwd: string, agents: AgentConfig[], options?: { fireAndForget?: boolean; name?: string; parentModelId?: string; parentProvider?: string; groupId?: string; taskId?: string; persistSession?: boolean; explicit?: { model?: string; provider?: string } }) => { id: string; error?: string; model?: string },
660
+ spawnAsyncAgent: (agentName: string, task: string, cwd: string, agents: AgentConfig[], options?: { fireAndForget?: boolean; name?: string; parentModelId?: string; parentProvider?: string; groupId?: string; taskId?: string; persistSession?: boolean; explicit?: { model?: string; provider?: string }; chain?: AsyncChainState; onComplete?: (result: { text: string; isError: boolean }) => void }) => { id: string; error?: string; model?: string },
642
661
  killAsyncAgent: (target: string, origin?: AsyncKillOrigin) => { killed: string[]; errors: string[] },
643
662
  ): void {
644
663
  // Only the orchestrator (top-level pi) can spawn subagents.
@@ -668,6 +687,130 @@ export function registerSubagentTool(
668
687
  };
669
688
  }
670
689
 
690
+ /** Async chain — spawns step 1, and each step's completion hook spawns the next
691
+ * with {previous} replaced by that step's result text. */
692
+ async function executeAsyncChain(
693
+ params: any,
694
+ agents: AgentConfig[],
695
+ mkd: (mode: "single" | "parallel" | "chain") => (results: SingleResult[]) => SubagentDetails,
696
+ parentModelId: string | undefined,
697
+ parentProvider: string | undefined,
698
+ ctx: any,
699
+ explicit?: { model?: string; provider?: string },
700
+ ) {
701
+ const steps = (params.chain ?? []) as Array<{ agent: string; task: string; cwd: string; taskId?: string }>;
702
+ const total = steps.length;
703
+ const fail = (text: string) => ({
704
+ content: [{ type: "text" as const, text }],
705
+ details: mkd("chain")([]),
706
+ isError: true,
707
+ });
708
+
709
+ if (total === 0) return fail("Error: chain array is empty — at least one step is required.");
710
+ for (let i = 0; i < total; i++) {
711
+ const s = steps[i];
712
+ if (!s.agent || !s.task) return fail(`Async chain step ${i + 1} requires both agent and task.`);
713
+ if (!s.cwd) return fail(`${MISSING_CWD_ERROR} (chain step ${i + 1}, agent: ${s.agent})`);
714
+ if (s.taskId == null) {
715
+ // Force a follow-up turn so the AI MUST deal with this error, as the other async paths do
716
+ await sendSystemNote(
717
+ pi,
718
+ `🚨 CRITICAL: Async chain call was REJECTED — missing taskId for step ${i + 1} (${s.agent}). NO agents were spawned. You MUST retry with a taskId on every chain step. Use TaskList to find task IDs; "-1" is only for a single read-only check.`,
719
+ { customType: "subagent-taskid-error", triggerTurn: true, deliverAs: "followUp" },
720
+ );
721
+ return fail(`Missing required parameter: taskId. Every async agent MUST have a taskId (chain step ${i + 1}, agent: ${s.agent}).\n\nIf this step is working on a task: pass the task ID (e.g., taskId: "5")\nIf it is only doing a single read-only check: pass taskId: "-1" (editing or PR work needs a real task id)`);
722
+ }
723
+ const taskErr = await validateTaskId(s.taskId, ctx.cwd, ctx.sessionManager?.getSessionId?.());
724
+ if (taskErr) {
725
+ await sendSystemNote(
726
+ pi,
727
+ `🚨 CRITICAL: ${taskErr} (chain step ${i + 1}, agent: ${s.agent}). NO agents were spawned. Fix the taskId and retry.`,
728
+ { customType: "subagent-taskid-error", triggerTurn: true, deliverAs: "followUp" },
729
+ );
730
+ return fail(`${taskErr} (chain step ${i + 1}, agent: ${s.agent})`);
731
+ }
732
+ }
733
+ if (syncPenalty > 0) {
734
+ log.info("sync_penalty_reset", "async chain used");
735
+ syncPenalty = 0;
736
+ }
737
+
738
+ /** Step names carry the position so the status line and result header say which step ran. */
739
+ const stepName = (i: number): string =>
740
+ i === 0 && params.name ? params.name : `${steps[i].agent} (chain ${i + 1}/${total})`;
741
+
742
+ /** A step's cwd may hold project agents the parent's cwd does not — discover per step. */
743
+ const stepAgents = (cwd: string): AgentConfig[] => discoverAgents(cwd, params.agentScope ?? "both").agents;
744
+
745
+ const spawnStep = (i: number, prev: string): { id: string; error?: string; model?: string } => {
746
+ const s = steps[i];
747
+ const name = stepName(i);
748
+ log.info("async_chain_step_spawn", { step: i + 1, total, agent: s.agent, name });
749
+ return spawnAsyncAgent(s.agent, withPrevious(s.task, prev), s.cwd, stepAgents(s.cwd), {
750
+ name,
751
+ parentModelId,
752
+ parentProvider,
753
+ taskId: s.taskId,
754
+ persistSession: params.persistSession,
755
+ explicit,
756
+ // Durable so a reload can report the chain it can no longer continue.
757
+ chain: {
758
+ steps: steps.map(st => ({ agent: st.agent, task: st.task })),
759
+ index: i,
760
+ previous: capPrevious(prev),
761
+ },
762
+ onComplete: (result) => {
763
+ if (result.isError) {
764
+ // Chain stops here — the delivered async result header names this step.
765
+ log.error("async_chain_step_failed", { step: i + 1, total, agent: s.agent, name, output: result.text.slice(0, 200) });
766
+ return;
767
+ }
768
+ log.info("async_chain_step_complete", { step: i + 1, total, agent: s.agent, name, output_chars: result.text.length });
769
+ if (i + 1 >= total) {
770
+ log.info("async_chain_done", { steps: total });
771
+ return;
772
+ }
773
+ log.info("async_chain_next_step", { from: i + 1, to: i + 2, agent: steps[i + 1].agent });
774
+ let next: { id: string; error?: string; model?: string };
775
+ try {
776
+ next = spawnStep(i + 1, result.text);
777
+ } catch (err) {
778
+ // A synchronous throw must not escape the completion hook — the chain is dead either way.
779
+ const reason = String((err as Error)?.message ?? err);
780
+ log.error("async_chain_next_step_spawn_failed", { step: i + 2, agent: steps[i + 1].agent, error: reason });
781
+ void sendSystemNote(
782
+ pi,
783
+ `🚨 Async chain stopped: step ${i + 2} (${steps[i + 1].agent}) could not be spawned after step ${i + 1} completed — ${reason}`,
784
+ { customType: "subagent-chain-error", triggerTurn: true, deliverAs: "followUp" },
785
+ );
786
+ return;
787
+ }
788
+ if (next.error) {
789
+ log.error("async_chain_next_step_spawn_failed", { step: i + 2, agent: steps[i + 1].agent, error: next.error });
790
+ void sendSystemNote(
791
+ pi,
792
+ `🚨 Async chain stopped: step ${i + 2} (${steps[i + 1].agent}) could not be spawned after step ${i + 1} completed — ${next.error}`,
793
+ { customType: "subagent-chain-error", triggerTurn: true, deliverAs: "followUp" },
794
+ );
795
+ } else {
796
+ log.info("async_chain_next_step_spawned", { step: i + 2, id: next.id });
797
+ }
798
+ },
799
+ });
800
+ };
801
+
802
+ const first = spawnStep(0, "");
803
+ if (first.error) return fail(`Async chain step 1 (${steps[0].agent}) failed to spawn: ${first.error}`);
804
+ const modelInfo = first.model ? ` (${first.model})` : "";
805
+ return {
806
+ content: [{
807
+ type: "text" as const,
808
+ text: `Async chain started: ${stepName(0)} [${first.id}]${modelInfo}\n${total} step(s) run in sequence — each step receives the previous step's output as {previous}.\nA failed step stops the chain and is named in its result.\nUse /async-status to check progress.`,
809
+ }],
810
+ details: mkd("chain")([]),
811
+ };
812
+ }
813
+
671
814
  async function executeAsync(
672
815
  params: any,
673
816
  agents: AgentConfig[],
@@ -678,11 +821,7 @@ export function registerSubagentTool(
678
821
  explicit?: { model?: string; provider?: string },
679
822
  ) {
680
823
  if (params.chain) {
681
- return {
682
- content: [{ type: "text" as const, text: "Async mode does not support chain. Use agent + task or tasks array." }],
683
- details: mkd("single")([]),
684
- isError: true,
685
- };
824
+ return executeAsyncChain(params, agents, mkd, parentModelId, parentProvider, ctx, explicit);
686
825
  }
687
826
 
688
827
  // Parallel async — spawn each task as a separate async agent
@@ -1628,9 +1767,14 @@ export function registerSubagentTool(
1628
1767
  const violators = [...new Set(requested.filter(n => ASYNC_ONLY_AGENTS.has(n)))];
1629
1768
  if (violators.length > 0) {
1630
1769
  const inChain = params.chain?.some(s => ASYNC_ONLY_AGENTS.has(s.agent));
1631
- if (inChain) {
1770
+ // An async chain satisfies the async-only requirement; anything that would run the
1771
+ // steps in the foreground (sync chain, or an acpx parent that coerces async→sync) cannot.
1772
+ if (inChain && (params.async !== true || !asyncOk)) {
1773
+ const why = params.async !== true
1774
+ ? "a sync chain runs its steps in the foreground"
1775
+ : `the parent provider cannot spawn async agents (${parentProvider ?? "unknown"})`;
1632
1776
  return {
1633
- content: [{ type: "text", text: `These agents cannot be used in chain mode: ${violators.join(", ")}. Dispatch them as separate async tasks instead.` }],
1777
+ content: [{ type: "text", text: `These agents can only run async, and ${why}: ${violators.join(", ")}. Async chain needs async: true on a provider that supports async agents.` }],
1634
1778
  details: mkd("single")([]),
1635
1779
  isError: true,
1636
1780
  };
@@ -1670,14 +1814,45 @@ export function registerSubagentTool(
1670
1814
  if (params.chain) for (const s of params.chain) requested.add(s.agent);
1671
1815
  if (params.tasks) for (const t of params.tasks) requested.add(t.agent);
1672
1816
  if (params.agent) requested.add(params.agent);
1673
- const projAgents = Array.from(requested)
1674
- .map((n) => agents.find((a) => a.name === n))
1675
- .filter((a): a is AgentConfig => a?.source === "project");
1817
+ // A chain step carries its own cwd, so it can resolve a DIFFERENT project agent set
1818
+ // than the parent's — including a project agent that shadows a user agent of the
1819
+ // same name. Resolve every step against its own cwd or the approval is bypassed.
1820
+ const projAgents: AgentConfig[] = [];
1821
+ const sources: string[] = [];
1822
+ for (const name of requested) {
1823
+ const parentAgent = agents.find((a) => a.name === name);
1824
+ if (parentAgent?.source === "project") {
1825
+ projAgents.push(parentAgent);
1826
+ if (discovery.projectAgentsDir) sources.push(discovery.projectAgentsDir);
1827
+ }
1828
+ }
1829
+ // Async chains resolve agents per step, so a step's own cwd can contribute a
1830
+ // project agent — including one that SHADOWS a parent user agent of the same name.
1831
+ // Sync chains still resolve against the parent list, so asking about their steps
1832
+ // would prompt for an agent that never runs.
1833
+ if (params.async === true) {
1834
+ // Structured key: a "@" join is ambiguous when a name or path contains "@".
1835
+ const seen = new Set(projAgents.map((a) => JSON.stringify([a.name, discovery.projectAgentsDir ?? ""])));
1836
+ for (const step of params.chain ?? []) {
1837
+ if (!step.cwd || step.cwd === ctx.cwd) continue;
1838
+ const stepDiscovery = discoverAgents(step.cwd, scope);
1839
+ const stepAgent = stepDiscovery.agents.find((a) => a.name === step.agent);
1840
+ if (stepAgent?.source !== "project") continue;
1841
+ // Dedupe on name+directory: the same name in a second project dir is a
1842
+ // DIFFERENT agent and its source must appear in the prompt.
1843
+ const key = JSON.stringify([stepAgent.name, stepDiscovery.projectAgentsDir ?? ""]);
1844
+ if (seen.has(key)) continue;
1845
+ seen.add(key);
1846
+ projAgents.push(stepAgent);
1847
+ if (stepDiscovery.projectAgentsDir) sources.push(stepDiscovery.projectAgentsDir);
1848
+ }
1849
+ }
1676
1850
  if (projAgents.length > 0) {
1851
+ const uniqueSources = [...new Set(sources)];
1677
1852
  const ok = await bridgedUi.confirm(
1678
1853
  ctx,
1679
1854
  "Run project-local agents?",
1680
- `Agents: ${projAgents.map((a) => a.name).join(", ")}\nSource: ${discovery.projectAgentsDir}`,
1855
+ `Agents: ${projAgents.map((a) => a.name).join(", ")}\nSource: ${uniqueSources.join(", ")}`,
1681
1856
  );
1682
1857
  if (!ok)
1683
1858
  return {
@@ -15,6 +15,7 @@ import * as fs from "node:fs";
15
15
  import { promises as fsp } from "node:fs";
16
16
  import * as path from "node:path";
17
17
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
18
+ import { isLiveLegacyDaemonFile } from "../shared/daemon-manager.js";
18
19
  import { createLogger } from "../shared/logger.js";
19
20
  import { isPiOneshotInvocation } from "../shared/oneshot.js";
20
21
  import { getGlobalSettingsPath, getSettingDynamic, getSettingsPath, SETTINGS_KEYS } from "./project-settings.js";
@@ -29,21 +30,6 @@ const DAY_MS = 24 * 60 * 60 * 1000;
29
30
  /** Defaults live in `settings-keys.json` only — never restated here. */
30
31
  export const DEFAULT_MAX_SIZE = String(SETTINGS_KEYS.project_tmp_max_size.default);
31
32
  export const DEFAULT_RETENTION_DAYS = Number(SETTINGS_KEYS.project_tmp_retention_days.default);
32
- /**
33
- * Transitional debris from pre-#1020 daemons: `.pi/tmp` was the control-file home
34
- * before `.pi/run`, so a daemon started before the upgrade still holds its pid/port and
35
- * reload handshake here, and the read-both fallback (#1025) needs these files to find it.
36
- * Sweeping them would make the next connect() miss the live daemon and spawn a duplicate
37
- * on the same port. Never deleted, whatever their age; the set disappears once no
38
- * pre-upgrade daemon remains.
39
- */
40
- export const TMP_PROTECTED_NAMES = new Set([
41
- "pidiff.pid",
42
- "pidiff.port",
43
- "reload.flag",
44
- "reload.done",
45
- "reload.pending",
46
- ]);
47
33
  /** Lives in `.pi/run`, never in the swept dir. */
48
34
  export const TMP_SWEEP_LOCK = ".tmp-sweep.lock";
49
35
  /** A crashed sweep would otherwise block every later session forever. */
@@ -171,7 +157,14 @@ export async function sweepProjectTmp(opts: TmpSweepOptions): Promise<TmpSweepRe
171
157
  let total = 0;
172
158
  for (const name of names) {
173
159
  const target = path.join(base, name);
174
- if (!isInside(base, target) || TMP_PROTECTED_NAMES.has(name)) continue;
160
+ if (!isInside(base, target)) continue;
161
+ // One exemption, decided by the daemon layer: a pre-`.pi/run` daemon that is still ALIVE
162
+ // owns its lockfile here, and sweeping it makes the next connect() spawn a duplicate on
163
+ // that port. A dead daemon's leftovers are throwaway like everything else (#1020).
164
+ if (isLiveLegacyDaemonFile(base, name)) {
165
+ log.debug("tmp_sweep_kept_live_legacy_daemon", { tmpDir: base, name });
166
+ continue;
167
+ }
175
168
  let stat: fs.Stats;
176
169
  try {
177
170
  stat = await fsp.lstat(target);
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Turn a rejected tool call back into a forced retry.
3
+ *
4
+ * Pi validates arguments BEFORE the lifecycle hooks: a call with invalid
5
+ * arguments skips `tool_call` and `tool_result` entirely (docs: "An unknown
6
+ * tool or invalid arguments skip `tool_call`"). Only `tool_execution_end`
7
+ * fires, with the error result and no `durationMs` — so nothing in the
8
+ * extension surface told the model its call never ran, and a turn could simply
9
+ * end with the intended action silently not performed (passing `path` to
10
+ * `bash`, or a `write` call with no `content`).
11
+ *
12
+ * This guard watches for exactly that shape and raises a system note with
13
+ * `triggerTurn`, so the agent must retry instead of stopping.
14
+ */
15
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
16
+ import { createLogger } from "../shared/logger.js";
17
+ import { sendSystemNote } from "../shared/system-note.js";
18
+
19
+ const log = createLogger("tool-arg-guard");
20
+
21
+ /** Pi's own rejection preamble, anchored at the START of the result. An executed tool
22
+ * that merely *prints* this phrase cannot match, which stops false retries. */
23
+ const VALIDATION_FAILURE = /^\s*(?:Validation failed for tool\s+"[^"]+":|Unknown tool:)/i;
24
+
25
+ /** Keep only the parameter diagnostics — never the caller-controlled argument values.
26
+ * Those can hold credentials, and a system note is a provenance-marked channel. */
27
+ export function rejectionReason(text: string): string {
28
+ const cut = text.split(/Received arguments:/i)[0];
29
+ return cut.trim().slice(0, 300);
30
+ }
31
+
32
+ /** Flatten a tool result into text without assuming one shape. */
33
+ export function resultText(result: unknown): string {
34
+ if (typeof result === "string") return result;
35
+ if (Array.isArray(result)) {
36
+ return result
37
+ .map((part: any) => (typeof part === "string" ? part : typeof part?.text === "string" ? part.text : ""))
38
+ .filter(Boolean)
39
+ .join("\n");
40
+ }
41
+ if (result && typeof result === "object") {
42
+ const text = (result as any).text;
43
+ if (typeof text === "string") return text;
44
+ // pi's rejected-call result: { content: [{ type: "text", text }], details }
45
+ if (Array.isArray((result as any).content)) return resultText((result as any).content);
46
+ try {
47
+ return JSON.stringify(result);
48
+ } catch {
49
+ return "";
50
+ }
51
+ }
52
+ return "";
53
+ }
54
+
55
+ /** True only for pi's argument/unknown-tool rejection, never for a policy block.
56
+ * The "never ran" signal (absent durationMs) proved unreliable — bash rejections can
57
+ * carry it — so the wording is the contract: pi emits this text only when it rejected
58
+ * the call before execution, while policy blocks use their own message. */
59
+ export function isArgumentRejection(isError: boolean, text: string): boolean {
60
+ return isError && VALIDATION_FAILURE.test(text);
61
+ }
62
+
63
+ export function registerToolArgGuard(pi: ExtensionAPI): void {
64
+ pi.on("tool_execution_end", async (event: any) => {
65
+ // durationMs is absent when the tool never executed.
66
+ const text = resultText(event?.result).trim();
67
+ if (!isArgumentRejection(Boolean(event?.isError), text)) return;
68
+
69
+ const reason = rejectionReason(text);
70
+ log.warn("tool_call_rejected_before_run", {
71
+ tool: event?.toolName,
72
+ toolCallId: event?.toolCallId,
73
+ // Diagnostic only: recorded, not used to decide.
74
+ durationMs: event?.durationMs,
75
+ // Parameter diagnostics only — received argument values are deliberately dropped.
76
+ reason,
77
+ });
78
+ await sendSystemNote(
79
+ pi,
80
+ `🚨 Your ${event?.toolName} call was REJECTED before it ran — the tool did NOT execute, so the action you intended did not happen:\n\n${reason}\n\nRetry now with corrected arguments. A frequent cause is a wrong parameter name (e.g. sending "path" to bash instead of "command", or "write" with no "content").`,
81
+ { customType: "tool-arg-rejected", triggerTurn: true, deliverAs: "followUp" },
82
+ );
83
+ });
84
+ }
@@ -193,19 +193,36 @@ export function writeLockfile(lockDir: string, port: number, pid: number | null,
193
193
  } catch (e: any) { log(`writeLockfile error: ${e.message}`); }
194
194
  }
195
195
 
196
+ /** A pid file holds digits and a newline; anything bigger is not one. */
197
+ const MAX_PID_FILE_BYTES = 64;
198
+
199
+ /** True for a path that exists and is a regular file. Lockfiles are never FIFOs, and reading
200
+ * one would block the event loop, so every control-file read goes through this. */
201
+ function isRegularFile(target: string): boolean {
202
+ try {
203
+ return fs.lstatSync(target).isFile();
204
+ } catch {
205
+ return false;
206
+ }
207
+ }
208
+
196
209
  /** Read port from lockfile. Returns port number or null.
197
210
  * `legacyDir` (`.pi/tmp`) is consulted only when the new dir has no lockfile:
198
- * a daemon already running at upgrade time wrote its lockfile there (#1020). */
211
+ * a daemon already running at upgrade time wrote its lockfile there (#1020).
212
+ * Reading never moves or deletes anything — moving a live daemon's record is what the
213
+ * sweep's liveness check exists to avoid. */
199
214
  export function readLockfile(lockDir: string, legacyDir?: string): { port: number; pid: number | null } | null {
200
215
  const read = (dir: string): { port: number; pid: number | null } | null => {
201
216
  try {
202
217
  const portFile = path.join(dir, "pidiff.port");
203
- if (!fs.existsSync(portFile)) return null;
218
+ // Only a regular file is a lockfile: the dirs are project-controlled, and reading a
219
+ // FIFO here would block the event loop for the whole session.
220
+ if (!isRegularFile(portFile)) return null;
204
221
  const port = parseInt(fs.readFileSync(portFile, "utf-8").trim(), 10);
205
222
  if (isNaN(port)) return null;
206
223
  let pid: number | null = null;
207
224
  const pidFile = path.join(dir, "pidiff.pid");
208
- if (fs.existsSync(pidFile)) {
225
+ if (isRegularFile(pidFile)) {
209
226
  pid = parseInt(fs.readFileSync(pidFile, "utf-8").trim(), 10);
210
227
  if (isNaN(pid)) pid = null;
211
228
  }
@@ -215,6 +232,63 @@ export function readLockfile(lockDir: string, legacyDir?: string): { port: numbe
215
232
  return read(lockDir) ?? (legacyDir ? read(legacyDir) : null);
216
233
  }
217
234
 
235
+ /** Read the pid from a lockfile, refusing anything but a regular file: the legacy dir is
236
+ * project-controlled, and reading a FIFO there would block session startup. */
237
+ function readLegacyPid(pidFile: string): number | null {
238
+ try {
239
+ const st = fs.lstatSync(pidFile);
240
+ if (!st.isFile()) {
241
+ log.debug("legacy_pid_not_a_regular_file", { pidFile });
242
+ return null;
243
+ }
244
+ // The file is project-controlled: a pid is a few bytes, so refuse anything larger rather
245
+ // than reading an arbitrary file into memory during session start.
246
+ if (st.size > MAX_PID_FILE_BYTES) {
247
+ log.debug("legacy_pid_file_too_large", { pidFile, size: st.size });
248
+ return null;
249
+ }
250
+ const pid = parseInt(fs.readFileSync(pidFile, "utf-8").trim(), 10);
251
+ if (isNaN(pid) || pid <= 0) {
252
+ log.debug("legacy_pid_unparseable", { pidFile });
253
+ return null;
254
+ }
255
+ return pid;
256
+ } catch (e: any) {
257
+ log.debug("legacy_pid_unreadable", { pidFile, error: e?.message });
258
+ return null;
259
+ }
260
+ }
261
+
262
+ /** True when the legacy `.pi/tmp` lockfile belongs to a LIVE process. `EPERM` means the
263
+ * process exists under another user, so it counts as alive — treating it as dead would let
264
+ * the sweep delete a running daemon's files. */
265
+ export function legacyLockfileOwnerAlive(legacyDir: string): boolean {
266
+ const pid = readLegacyPid(path.join(legacyDir, "pidiff.pid"));
267
+ if (pid === null) return false;
268
+ try {
269
+ process.kill(pid, 0);
270
+ return true;
271
+ } catch (e: any) {
272
+ if (e?.code === "EPERM") {
273
+ log.debug("legacy_daemon_owned_by_another_user", { pid });
274
+ return true;
275
+ }
276
+ log.debug("legacy_daemon_not_running", { pid, code: e?.code });
277
+ return false;
278
+ }
279
+ }
280
+
281
+ /** Control files a pre-`.pi/run` daemon owns inside the legacy dir. Private: the sweep asks
282
+ * `isLiveLegacyDaemonFile`, so neither module keeps a second copy of the names (#1020). */
283
+ const LEGACY_CONTROL_FILES = new Set(["pidiff.port", "pidiff.pid"]);
284
+
285
+ /** True when `name` is a legacy control file whose owner is still alive — the sweep's only
286
+ * exemption. A dead daemon's leftovers are ordinary throwaway. */
287
+ export function isLiveLegacyDaemonFile(legacyDir: string, name: string): boolean {
288
+ if (!LEGACY_CONTROL_FILES.has(name)) return false;
289
+ return legacyLockfileOwnerAlive(legacyDir);
290
+ }
291
+
218
292
  /** First lockfile port (in `dirs` order) whose daemon answers /api/health, or
219
293
  * null. Exported for testing. */
220
294
  export async function findLiveLockfile(dirs: string[]): Promise<number | null> {
@@ -59,7 +59,10 @@ export function createBridgedUi(pi: ExtensionAPI): BridgedUi {
59
59
  if (typeof ctx.ui?.custom !== "function") {
60
60
  log.debug("bridged prompt degraded — no ui.custom", { method });
61
61
  if (method === "select") return await ctx.ui.select(title, options) as T | undefined;
62
- if (method === "confirm") return (await ctx.ui.confirm(title, message)) as T | undefined;
62
+ // The confirm wrapper compares against the "__confirmed__"/"Yes" sentinels, so a raw
63
+ // boolean here was always rejected — every prompt without ui.custom cancelled, and an
64
+ // approval could never succeed. Translate the answer to the sentinel the caller expects.
65
+ if (method === "confirm") return ((await ctx.ui.confirm(title, message)) ? "__confirmed__" : "__denied__") as T | undefined;
63
66
  return await ctx.ui.input(title) as T | undefined;
64
67
  }
65
68
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-orchestrator-config",
3
- "version": "4.25.0",
3
+ "version": "4.26.1",
4
4
  "description": "Orchestrator pattern for pi: specialist subagents, enforcement, code review loop, workflow commands",
5
5
  "keywords": [
6
6
  "pi-package"