pi-orchestrator-config 4.24.2 → 4.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +35 -0
- package/README.md +2 -2
- package/extensions/orchestrator/async-agents.ts +79 -19
- package/extensions/orchestrator/index.ts +5 -0
- package/extensions/orchestrator/subagent-tool.ts +187 -12
- package/extensions/orchestrator/tmp-cleanup.ts +428 -0
- package/extensions/orchestrator/tool-arg-guard.ts +84 -0
- package/extensions/orchestrator/utils.ts +23 -3
- package/extensions/pidiff/pidiff.ts +60 -35
- package/extensions/pidiff/spawn-lock.ts +34 -0
- package/extensions/pitasks/index.ts +23 -16
- package/extensions/pitasks/reminders.ts +70 -0
- package/extensions/reload-flag/index.ts +99 -29
- package/extensions/shared/daemon-manager.ts +44 -23
- package/extensions/shared/ui-bridge.ts +4 -1
- package/package.json +1 -1
- package/prompts/review-handler.md +12 -0
- package/scripts/pidiff-server.ts +26 -7
- package/settings-keys.json +16 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,40 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [4.26.0] - 2026-11-06
|
|
4
|
+
|
|
5
|
+
- Async `chain` mode: steps run in sequence and each step's result is substituted into `{previous}`,
|
|
6
|
+
with per-step `taskId`/`cwd`, the step position in the status line and result header, a durable
|
|
7
|
+
`chain.json` for reload reporting, and stop-on-failure. `onComplete` receives `{ text, isError }` and
|
|
8
|
+
fires exactly once, so a retried reconciliation pass cannot spawn the next step twice.
|
|
9
|
+
- A tool call pi rejects for invalid arguments now forces a retry. Such a call skips `tool_call` and
|
|
10
|
+
`tool_result`, so nothing told the model its action never ran and a turn could end silently. The guard
|
|
11
|
+
matches pi's own rejection preamble, keeps only the parameter diagnostics (argument values can hold
|
|
12
|
+
credentials and the system note is a provenance-marked channel), and names the likely mistake.
|
|
13
|
+
- The UI bridge degrade path honours an approval again: without `ctx.ui.custom`, `confirm` returned a
|
|
14
|
+
boolean while the wrapper compared `"__confirmed__"`, so every prompt in that runtime cancelled.
|
|
15
|
+
|
|
16
|
+
## [4.25.0] - 2026-11-06
|
|
17
|
+
|
|
18
|
+
- `.pi/tmp` is cleaned at session start: above `project_tmp_max_size` (default `2G`), entries older than
|
|
19
|
+
`project_tmp_retention_days` (default 7 days) are deleted oldest-first until the tree fits, and `0`
|
|
20
|
+
disables it entirely. The sweep lock lives in `.pi/run` rather than in the directory being swept, a run
|
|
21
|
+
that cannot take the lock deletes nothing, an entry replaced mid-scan is skipped, and oneshot sessions
|
|
22
|
+
skip the whole thing. A bad size value deletes nothing rather than falling back to a deleting default.
|
|
23
|
+
- Daemon control files moved to `.pi/run` — `pidiff.pid`, `pidiff.port`, and the `reload.flag` /
|
|
24
|
+
`reload.done` / `reload.pending` handshake. Readers fall back to `.pi/tmp` and `reload.done` is mirrored
|
|
25
|
+
back, so a daemon started before the upgrade is still found instead of a second one spawning on its
|
|
26
|
+
port. **The migration release needs exactly one manual `/reload`**: a session whose watcher was loaded
|
|
27
|
+
before the move cannot observe the new flag path. The old temp-dir reload files stay protected until no
|
|
28
|
+
pre-upgrade daemon remains.
|
|
29
|
+
- The pending-task reminder keys on how long a task has actually been pending rather than on the first
|
|
30
|
+
60-second tick, so it no longer fires about a minute after creation. It measures the oldest active task,
|
|
31
|
+
treats an undated task as old so a fresh sibling cannot mask it, and covers `agentBusy` in tests.
|
|
32
|
+
- `reviews fetch` approves on a clean findings catalog at the PR head instead of requiring a `completed`
|
|
33
|
+
Qodo session, so a legitimately skipped auto-run can approve. A run that is still in progress never does.
|
|
34
|
+
`reason` reports `no_findings`, `open_findings`, `not_at_head`, or `all_resolved` instead of always
|
|
35
|
+
claiming `all_resolved`, and the review-loop exit names which reviewer actually approved — a CodeRabbit
|
|
36
|
+
approval is no longer reported as Qodo's.
|
|
37
|
+
|
|
3
38
|
## [4.24.2] - 2026-11-04
|
|
4
39
|
|
|
5
40
|
- Async work is tracked even when an agent starts it: the task trigger now covers work set in
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
723
|
-
|
|
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
|
|
990
|
-
|
|
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,9 +19,11 @@ 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";
|
|
26
|
+
import { registerTmpCleanup } from "./tmp-cleanup.js";
|
|
25
27
|
import { registerStatusLine } from "./status-line.js";
|
|
26
28
|
import { registerSubagentTool } from "./subagent-tool.js";
|
|
27
29
|
import { registerGithubAutocomplete } from "./github-autocomplete.js";
|
|
@@ -131,6 +133,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
131
133
|
registerProjectSettings(pi);
|
|
132
134
|
registerTrail(pi);
|
|
133
135
|
registerEnforcement(pi, IN_CONTAINER);
|
|
136
|
+
// A rejected-before-run tool call must force a retry, not end the turn silently.
|
|
137
|
+
registerToolArgGuard(pi);
|
|
134
138
|
registerReviewUI(pi);
|
|
135
139
|
registerRules(pi, getAsyncJobs);
|
|
136
140
|
|
|
@@ -144,6 +148,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
144
148
|
registerPreferenceExtractor(pi);
|
|
145
149
|
registerMemoryTools(pi);
|
|
146
150
|
registerReviewLearning(pi);
|
|
151
|
+
registerTmpCleanup(pi);
|
|
147
152
|
registerSessionSearch(pi);
|
|
148
153
|
registerSettingsTui(pi);
|
|
149
154
|
registerMarkdownTransformer(pi);
|
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
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: ${
|
|
1855
|
+
`Agents: ${projAgents.map((a) => a.name).join(", ")}\nSource: ${uniqueSources.join(", ")}`,
|
|
1681
1856
|
);
|
|
1682
1857
|
if (!ok)
|
|
1683
1858
|
return {
|