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 +26 -0
- package/README.md +2 -2
- package/extensions/orchestrator/async-agents.ts +79 -19
- package/extensions/orchestrator/index.ts +3 -0
- package/extensions/orchestrator/subagent-tool.ts +187 -12
- package/extensions/orchestrator/tmp-cleanup.ts +9 -16
- package/extensions/orchestrator/tool-arg-guard.ts +84 -0
- package/extensions/shared/daemon-manager.ts +77 -3
- package/extensions/shared/ui-bridge.ts +4 -1
- package/package.json +1 -1
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
|
-
|
|
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,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
|
-
|
|
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 {
|
|
@@ -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)
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
|