pi-subagents 0.34.0 → 0.35.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 +78 -9
- package/README.md +213 -32
- package/index.ts +1 -0
- package/install.mjs +1 -1
- package/package.json +23 -8
- package/prompts/review-loop.md +3 -1
- package/skills/pi-subagents/SKILL.md +87 -25
- package/src/agents/agent-management.ts +82 -15
- package/src/agents/agent-serializer.ts +19 -0
- package/src/agents/agents.ts +91 -49
- package/src/agents/frontmatter.ts +67 -13
- package/src/agents/skills.ts +25 -12
- package/src/api/background-work.ts +197 -0
- package/src/api/delegation.ts +158 -0
- package/src/extension/chain-validation.ts +165 -0
- package/src/extension/doctor.ts +15 -0
- package/src/extension/fanout-child.ts +3 -1
- package/src/extension/index.ts +65 -124
- package/src/extension/rpc.ts +10 -2
- package/src/extension/schemas.ts +18 -14
- package/src/extension/steering-notices.ts +35 -0
- package/src/extension/tool-description.ts +20 -9
- package/src/intercom/intercom-bridge.ts +3 -2
- package/src/intercom/native-supervisor-channel.ts +9 -1
- package/src/intercom/result-intercom.ts +4 -0
- package/src/runs/background/async-execution.ts +293 -44
- package/src/runs/background/async-job-tracker.ts +56 -9
- package/src/runs/background/async-resume.ts +159 -52
- package/src/runs/background/async-status.ts +25 -18
- package/src/runs/background/auto-drain.ts +67 -0
- package/src/runs/background/chain-root-attachment.ts +16 -8
- package/src/runs/background/control-channel.ts +260 -13
- package/src/runs/background/fleet-view.ts +23 -2
- package/src/runs/background/notify.ts +79 -10
- package/src/runs/background/result-watcher.ts +12 -9
- package/src/runs/background/run-id-resolver.ts +14 -2
- package/src/runs/background/run-status.ts +23 -15
- package/src/runs/background/scheduled-runs.ts +3 -0
- package/src/runs/background/stale-run-reconciler.ts +32 -10
- package/src/runs/background/steering.ts +237 -0
- package/src/runs/background/subagent-runner.ts +898 -236
- package/src/runs/background/subagent-wait.ts +484 -0
- package/src/runs/background/top-level-async.ts +2 -1
- package/src/runs/background/wait-config.ts +36 -0
- package/src/runs/background/wait-tool.ts +26 -0
- package/src/runs/foreground/async-steering-action.ts +230 -0
- package/src/runs/foreground/chain-clarify.ts +22 -6
- package/src/runs/foreground/chain-execution.ts +50 -32
- package/src/runs/foreground/execution.ts +308 -94
- package/src/runs/foreground/subagent-executor.ts +592 -268
- package/src/runs/shared/acceptance.ts +355 -97
- package/src/runs/shared/child-protocol.ts +121 -0
- package/src/runs/shared/completion-guard.ts +8 -127
- package/src/runs/shared/dynamic-fanout.ts +6 -4
- package/src/runs/shared/model-fallback.ts +36 -0
- package/src/runs/shared/nested-events.ts +9 -4
- package/src/runs/shared/nested-render.ts +4 -1
- package/src/runs/shared/parallel-utils.ts +7 -0
- package/src/runs/shared/pi-args.ts +34 -7
- package/src/runs/shared/pi-spawn.ts +18 -12
- package/src/runs/shared/session-lease.ts +279 -0
- package/src/runs/shared/single-output.ts +61 -6
- package/src/runs/shared/spawn-budget.ts +128 -0
- package/src/runs/shared/subagent-control.ts +10 -6
- package/src/runs/shared/subagent-prompt-runtime.ts +127 -26
- package/src/runs/shared/task-intent.ts +176 -0
- package/src/runs/shared/tool-availability.ts +65 -0
- package/src/runs/shared/turn-budget.ts +49 -4
- package/src/shared/atomic-json.ts +4 -1
- package/src/shared/fork-context.ts +28 -3
- package/src/shared/model-info.ts +7 -4
- package/src/shared/status-format.ts +7 -1
- package/src/shared/types.ts +203 -25
- package/src/shared/utils.ts +35 -7
- package/src/slash/delegation-adapters.ts +457 -0
- package/src/slash/delegation-request.ts +103 -0
- package/src/slash/prompt-template-bridge.ts +167 -344
- package/src/slash/slash-commands.ts +239 -6
- package/src/slash/subagents-admin.ts +428 -0
- package/src/slash/subagents-editor.ts +86 -0
- package/src/tui/fleet.ts +405 -0
- package/src/tui/render.ts +90 -16
- package/src/watchdog/change-signature.ts +127 -0
- package/src/watchdog/child-status.ts +205 -0
- package/src/watchdog/emission-guard.ts +123 -0
- package/src/watchdog/lsp-diagnostics.ts +532 -0
- package/src/watchdog/model-selection.ts +167 -0
- package/src/watchdog/register-child.ts +117 -0
- package/src/watchdog/register-main.ts +433 -0
- package/src/watchdog/render.ts +54 -0
- package/src/watchdog/review.ts +293 -0
- package/src/watchdog/runtime.ts +712 -0
- package/src/watchdog/settings.ts +528 -0
- package/src/watchdog/tool-actions.ts +155 -0
- package/src/watchdog/turn-delta.ts +161 -0
- package/src/watchdog/types.ts +188 -0
- package/src/watchdog/warning-format.ts +73 -0
- package/src/runs/background/wait.ts +0 -394
|
@@ -2,11 +2,25 @@ import * as fs from "node:fs";
|
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
4
4
|
import { registerNativeSupervisorClient } from "../../intercom/native-supervisor-channel.ts";
|
|
5
|
-
import { consumeSteerRequestsFromDir, writeSteerRequestToDir, type SteerRequest } from "../background/control-channel.ts";
|
|
6
|
-
import { SUBAGENT_FANOUT_CHILD_ENV, SUBAGENT_STEER_INBOX_ENV } from "./pi-args.ts";
|
|
5
|
+
import { consumeSteerRequestsFromDir, steerAckPathFromDir, writeSteerAckAt, writeSteerCapabilityAt, writeSteerRequestToDir, type SteerRequest } from "../background/control-channel.ts";
|
|
6
|
+
import { SUBAGENT_CHILD_AGENT_ENV, SUBAGENT_CHILD_INDEX_ENV, SUBAGENT_FANOUT_CHILD_ENV, SUBAGENT_STEER_ACK_DIR_ENV, SUBAGENT_STEER_CAPABILITY_ENV, SUBAGENT_STEER_INBOX_ENV } from "./pi-args.ts";
|
|
7
7
|
import { STRUCTURED_OUTPUT_CAPTURE_ENV, STRUCTURED_OUTPUT_SCHEMA_ENV, validateStructuredOutputValue } from "./structured-output.ts";
|
|
8
|
+
import {
|
|
9
|
+
CHILD_TOOL_DIAGNOSTIC_PATH_ENV,
|
|
10
|
+
formatChildToolDiagnostic,
|
|
11
|
+
REQUIRED_CHILD_TOOLS_ENV,
|
|
12
|
+
writeChildToolDiagnostic,
|
|
13
|
+
type ChildToolDiagnostic,
|
|
14
|
+
} from "./tool-availability.ts";
|
|
8
15
|
import { TOOL_BUDGET_ENV, decodeToolBudgetEnv, shouldBlockToolForBudget, toolBudgetBlockedMessage, toolBudgetSoftNudge } from "./tool-budget.ts";
|
|
9
|
-
import type { JsonSchemaObject, ResolvedToolBudget } from "../../shared/types.ts";
|
|
16
|
+
import type { JsonSchemaObject, ResolvedToolBudget, SubagentState } from "../../shared/types.ts";
|
|
17
|
+
import { resolveCurrentSessionId } from "../../shared/session-identity.ts";
|
|
18
|
+
import { resolveWatchPath } from "../../shared/utils.ts";
|
|
19
|
+
import { registerChildWatchdog } from "../../watchdog/register-child.ts";
|
|
20
|
+
import { SUBAGENT_WATCHDOG_WARNING_TYPE } from "../../watchdog/types.ts";
|
|
21
|
+
import { resolveWaitToolConfig } from "../background/wait-config.ts";
|
|
22
|
+
import { registerWaitTool } from "../background/wait-tool.ts";
|
|
23
|
+
import { drainOutstandingWork } from "../background/auto-drain.ts";
|
|
10
24
|
|
|
11
25
|
const SUBAGENT_INHERIT_PROJECT_CONTEXT_ENV = "PI_SUBAGENT_INHERIT_PROJECT_CONTEXT";
|
|
12
26
|
const SUBAGENT_INHERIT_SKILLS_ENV = "PI_SUBAGENT_INHERIT_SKILLS";
|
|
@@ -55,6 +69,18 @@ function readBooleanEnv(name: string): boolean | undefined {
|
|
|
55
69
|
return value !== "0";
|
|
56
70
|
}
|
|
57
71
|
|
|
72
|
+
function refreshChildToolDiagnostic(pi: ExtensionAPI): ChildToolDiagnostic | undefined {
|
|
73
|
+
const filePath = process.env[CHILD_TOOL_DIAGNOSTIC_PATH_ENV]?.trim();
|
|
74
|
+
const encoded = process.env[REQUIRED_CHILD_TOOLS_ENV]?.trim();
|
|
75
|
+
if (!filePath || !encoded) return undefined;
|
|
76
|
+
const required = JSON.parse(encoded) as unknown;
|
|
77
|
+
if (!Array.isArray(required) || required.some((name) => typeof name !== "string" || !name)) {
|
|
78
|
+
throw new Error(`Invalid ${REQUIRED_CHILD_TOOLS_ENV} payload.`);
|
|
79
|
+
}
|
|
80
|
+
const available = pi.getAllTools().map((tool) => tool.name);
|
|
81
|
+
return writeChildToolDiagnostic(filePath, required, available, process.env[SUBAGENT_CHILD_AGENT_ENV]?.trim());
|
|
82
|
+
}
|
|
83
|
+
|
|
58
84
|
function findSectionEnd(prompt: string, startIndex: number, nextHeaders: string[]): number {
|
|
59
85
|
let endIndex = prompt.length;
|
|
60
86
|
for (const header of nextHeaders) {
|
|
@@ -114,9 +140,9 @@ export function rewriteSubagentPrompt(
|
|
|
114
140
|
|
|
115
141
|
function isParentOnlySubagentMessage(message: unknown): boolean {
|
|
116
142
|
const m = message as { role?: string; customType?: string };
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
143
|
+
if (m?.role !== "custom" || typeof m.customType !== "string") return false;
|
|
144
|
+
if (m.customType === SUBAGENT_WATCHDOG_WARNING_TYPE) return true;
|
|
145
|
+
return PARENT_ONLY_CUSTOM_MESSAGE_TYPES.has(m.customType);
|
|
120
146
|
}
|
|
121
147
|
|
|
122
148
|
function isSubagentToolResultMessage(message: unknown): boolean {
|
|
@@ -190,29 +216,59 @@ function registerToolBudget(pi: ExtensionAPI, budget: ResolvedToolBudget | undef
|
|
|
190
216
|
});
|
|
191
217
|
}
|
|
192
218
|
|
|
193
|
-
function registerSteeringInbox(
|
|
219
|
+
export function registerSteeringInbox(
|
|
220
|
+
pi: ExtensionAPI,
|
|
221
|
+
deps: { watch?: typeof fs.watch; nativeRealpath?: (filePath: string) => string } = {},
|
|
222
|
+
): void {
|
|
194
223
|
const steerInbox = process.env[SUBAGENT_STEER_INBOX_ENV]?.trim();
|
|
195
224
|
if (!steerInbox) return;
|
|
225
|
+
const capabilityPath = process.env[SUBAGENT_STEER_CAPABILITY_ENV]?.trim();
|
|
226
|
+
const ackDir = process.env[SUBAGENT_STEER_ACK_DIR_ENV]?.trim();
|
|
196
227
|
const sendUserMessage = (pi as { sendUserMessage?: (content: string, options: { deliverAs: "steer" }) => unknown }).sendUserMessage;
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
let canSteer = false;
|
|
228
|
+
const childIndex = Number(process.env[SUBAGENT_CHILD_INDEX_ENV]);
|
|
229
|
+
const pending = new Map<string, string[]>();
|
|
200
230
|
let disposed = false;
|
|
201
231
|
let flushing = false;
|
|
202
232
|
let started = false;
|
|
233
|
+
let canSteer = typeof sendUserMessage === "function";
|
|
203
234
|
let watcher: fs.FSWatcher | undefined;
|
|
204
235
|
let interval: NodeJS.Timeout | undefined;
|
|
236
|
+
const acknowledge = (request: SteerRequest, state: "delivered" | "failed", message: string): void => {
|
|
237
|
+
if (!ackDir || !Number.isInteger(childIndex) || childIndex < 0) return;
|
|
238
|
+
writeSteerAckAt(steerAckPathFromDir(ackDir, request.id), {
|
|
239
|
+
requestId: request.id,
|
|
240
|
+
index: childIndex,
|
|
241
|
+
ts: Date.now(),
|
|
242
|
+
state,
|
|
243
|
+
message,
|
|
244
|
+
});
|
|
245
|
+
};
|
|
246
|
+
const publishCapability = (): void => {
|
|
247
|
+
if (!capabilityPath || !Number.isInteger(childIndex) || childIndex < 0) return;
|
|
248
|
+
writeSteerCapabilityAt(capabilityPath, { index: childIndex, pid: process.pid, readyAt: Date.now(), supported: canSteer });
|
|
249
|
+
};
|
|
205
250
|
const flush = (): void => {
|
|
206
|
-
if (disposed || flushing
|
|
251
|
+
if (disposed || flushing) return;
|
|
207
252
|
flushing = true;
|
|
208
253
|
try {
|
|
209
254
|
const requests = consumeSteerRequestsFromDir(steerInbox);
|
|
210
255
|
for (let index = 0; index < requests.length; index++) {
|
|
211
256
|
const request = requests[index]!;
|
|
257
|
+
if (!canSteer || typeof sendUserMessage !== "function") {
|
|
258
|
+
acknowledge(request, "failed", "Child Pi session does not support sendUserMessage steering.");
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
const formatted = formatSteerMessage(request);
|
|
262
|
+
const ids = pending.get(formatted) ?? [];
|
|
263
|
+
ids.push(request.id);
|
|
264
|
+
pending.set(formatted, ids);
|
|
212
265
|
try {
|
|
213
|
-
sendUserMessage(
|
|
214
|
-
} catch {
|
|
215
|
-
|
|
266
|
+
sendUserMessage(formatted, { deliverAs: "steer" });
|
|
267
|
+
} catch (error) {
|
|
268
|
+
ids.pop();
|
|
269
|
+
if (ids.length === 0) pending.delete(formatted);
|
|
270
|
+
acknowledge(request, "failed", error instanceof Error ? error.message : String(error));
|
|
271
|
+
for (const retry of requests.slice(index + 1)) writeSteerRequestToDir(steerInbox, retry);
|
|
216
272
|
break;
|
|
217
273
|
}
|
|
218
274
|
}
|
|
@@ -220,16 +276,30 @@ function registerSteeringInbox(pi: ExtensionAPI): void {
|
|
|
220
276
|
flushing = false;
|
|
221
277
|
}
|
|
222
278
|
};
|
|
279
|
+
const onInput = (event: unknown): undefined => {
|
|
280
|
+
if (disposed || !event || typeof event !== "object") return undefined;
|
|
281
|
+
const input = event as { source?: unknown; streamingBehavior?: unknown; text?: unknown; content?: unknown };
|
|
282
|
+
if (input.source !== "extension" || input.streamingBehavior !== "steer") return undefined;
|
|
283
|
+
const text = typeof input.text === "string" ? input.text : typeof input.content === "string" ? input.content : undefined;
|
|
284
|
+
if (!text) return undefined;
|
|
285
|
+
const ids = pending.get(text);
|
|
286
|
+
const requestId = ids?.shift();
|
|
287
|
+
if (!requestId) return undefined;
|
|
288
|
+
if (ids?.length === 0) pending.delete(text);
|
|
289
|
+
acknowledge({ type: "steer", id: requestId, ts: Date.now(), message: text }, "delivered", "Pi accepted the correlated steering input.");
|
|
290
|
+
return undefined;
|
|
291
|
+
};
|
|
223
292
|
const start = (): void => {
|
|
224
293
|
if (started || disposed) return;
|
|
225
294
|
try {
|
|
226
295
|
fs.mkdirSync(steerInbox, { recursive: true });
|
|
296
|
+
publishCapability();
|
|
227
297
|
} catch {
|
|
228
298
|
return;
|
|
229
299
|
}
|
|
230
300
|
started = true;
|
|
231
301
|
try {
|
|
232
|
-
watcher = fs.watch(steerInbox, () => flush());
|
|
302
|
+
watcher = (deps.watch ?? fs.watch)(resolveWatchPath(steerInbox, deps.nativeRealpath), () => flush());
|
|
233
303
|
watcher.on("error", () => {});
|
|
234
304
|
} catch {
|
|
235
305
|
watcher = undefined;
|
|
@@ -239,21 +309,20 @@ function registerSteeringInbox(pi: ExtensionAPI): void {
|
|
|
239
309
|
};
|
|
240
310
|
const activate = (): undefined => {
|
|
241
311
|
start();
|
|
242
|
-
canSteer = true;
|
|
243
312
|
flush();
|
|
244
313
|
return undefined;
|
|
245
314
|
};
|
|
246
315
|
|
|
247
316
|
const onRuntimeEvent = pi.on as unknown as (event: string, handler: (event: unknown) => unknown) => void;
|
|
317
|
+
// Register input before the watcher so an accepted extension input cannot race request dispatch.
|
|
318
|
+
onRuntimeEvent("input", onInput);
|
|
248
319
|
onRuntimeEvent("session_start", () => start());
|
|
249
320
|
for (const eventName of ["message_start", "message_update", "message_end", "tool_execution_start", "tool_execution_end", "turn_end"] as const) {
|
|
250
321
|
onRuntimeEvent(eventName, activate);
|
|
251
322
|
}
|
|
252
323
|
onRuntimeEvent("session_shutdown", () => {
|
|
253
324
|
disposed = true;
|
|
254
|
-
try {
|
|
255
|
-
watcher?.close();
|
|
256
|
-
} catch {}
|
|
325
|
+
try { watcher?.close(); } catch {}
|
|
257
326
|
if (interval) clearInterval(interval);
|
|
258
327
|
});
|
|
259
328
|
}
|
|
@@ -261,6 +330,23 @@ function registerSteeringInbox(pi: ExtensionAPI): void {
|
|
|
261
330
|
export default function registerSubagentPromptRuntime(pi: ExtensionAPI): void {
|
|
262
331
|
registerSteeringInbox(pi);
|
|
263
332
|
registerToolBudget(pi, decodeToolBudgetEnv(process.env[TOOL_BUDGET_ENV]));
|
|
333
|
+
registerChildWatchdog(pi);
|
|
334
|
+
const waitToolEnabled = resolveWaitToolConfig().enabled;
|
|
335
|
+
const waitState = {
|
|
336
|
+
baseCwd: "",
|
|
337
|
+
currentSessionId: null,
|
|
338
|
+
asyncJobs: new Map(),
|
|
339
|
+
foregroundControls: new Map(),
|
|
340
|
+
lastForegroundControlId: null,
|
|
341
|
+
cleanupTimers: new Map(),
|
|
342
|
+
lastUiContext: null,
|
|
343
|
+
poller: null,
|
|
344
|
+
completionSeen: new Map(),
|
|
345
|
+
watcher: null,
|
|
346
|
+
watcherRestartTimer: null,
|
|
347
|
+
resultFileCoalescer: { schedule: () => false, clear: () => {} },
|
|
348
|
+
} as unknown as SubagentState;
|
|
349
|
+
if (typeof pi.registerTool === "function") registerWaitTool(pi, waitState, waitToolEnabled);
|
|
264
350
|
let nativeSupervisorClientRegistered = false;
|
|
265
351
|
let nativeSupervisorFallbackRegistered = false;
|
|
266
352
|
const registerNativeSupervisorClientOnce = (): void => {
|
|
@@ -275,7 +361,16 @@ export default function registerSubagentPromptRuntime(pi: ExtensionAPI): void {
|
|
|
275
361
|
registerNativeSupervisorClient(pi);
|
|
276
362
|
};
|
|
277
363
|
const onRuntimeEvent = pi.on as unknown as (event: string, handler: (event: unknown) => unknown) => void;
|
|
278
|
-
onRuntimeEvent("session_start",
|
|
364
|
+
onRuntimeEvent("session_start", (_event: unknown, ctx: unknown) => {
|
|
365
|
+
const sessionManager = (ctx as { sessionManager?: Parameters<typeof resolveCurrentSessionId>[0] } | undefined)?.sessionManager;
|
|
366
|
+
waitState.currentSessionId = sessionManager ? resolveCurrentSessionId(sessionManager) : null;
|
|
367
|
+
registerNativeSupervisorClientOnce();
|
|
368
|
+
refreshChildToolDiagnostic(pi);
|
|
369
|
+
});
|
|
370
|
+
onRuntimeEvent("agent_end", async (_event: unknown, ctx: unknown) => {
|
|
371
|
+
if ((ctx as { hasUI?: boolean } | undefined)?.hasUI === true) return;
|
|
372
|
+
await drainOutstandingWork({ state: waitState, events: pi.events });
|
|
373
|
+
});
|
|
279
374
|
const structuredOutputPath = process.env[STRUCTURED_OUTPUT_CAPTURE_ENV];
|
|
280
375
|
const structuredSchemaPath = process.env[STRUCTURED_OUTPUT_SCHEMA_ENV];
|
|
281
376
|
if (structuredOutputPath && structuredSchemaPath) {
|
|
@@ -322,6 +417,7 @@ export default function registerSubagentPromptRuntime(pi: ExtensionAPI): void {
|
|
|
322
417
|
|
|
323
418
|
onRuntimeEvent("before_agent_start", async (event: { systemPrompt: string }) => {
|
|
324
419
|
registerNativeSupervisorFallbackOnce();
|
|
420
|
+
const toolDiagnostic = refreshChildToolDiagnostic(pi);
|
|
325
421
|
const intercomSessionName = process.env[SUBAGENT_INTERCOM_SESSION_NAME_ENV]?.trim();
|
|
326
422
|
if (intercomSessionName && typeof pi.setSessionName === "function") {
|
|
327
423
|
pi.setSessionName(intercomSessionName);
|
|
@@ -330,12 +426,17 @@ export default function registerSubagentPromptRuntime(pi: ExtensionAPI): void {
|
|
|
330
426
|
const inheritProjectContext = readBooleanEnv(SUBAGENT_INHERIT_PROJECT_CONTEXT_ENV);
|
|
331
427
|
const inheritSkills = readBooleanEnv(SUBAGENT_INHERIT_SKILLS_ENV);
|
|
332
428
|
const fanoutChild = readBooleanEnv(SUBAGENT_FANOUT_CHILD_ENV);
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
429
|
+
let rewritten = event.systemPrompt;
|
|
430
|
+
if (inheritProjectContext !== undefined || inheritSkills !== undefined || fanoutChild !== undefined) {
|
|
431
|
+
rewritten = rewriteSubagentPrompt(event.systemPrompt, {
|
|
432
|
+
inheritProjectContext: inheritProjectContext ?? true,
|
|
433
|
+
inheritSkills: inheritSkills ?? true,
|
|
434
|
+
fanoutChild: fanoutChild === true,
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
if (toolDiagnostic) {
|
|
438
|
+
rewritten = `${formatChildToolDiagnostic(toolDiagnostic)}\nDo not claim tool-dependent work succeeded; report this configuration error to the parent.\n\n${rewritten}`;
|
|
439
|
+
}
|
|
339
440
|
if (rewritten === event.systemPrompt) return;
|
|
340
441
|
return { systemPrompt: rewritten };
|
|
341
442
|
});
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared task mutation-intent classifier.
|
|
3
|
+
*
|
|
4
|
+
* Single authority for reading a task's wording, answering two different
|
|
5
|
+
* questions from one prohibition analysis:
|
|
6
|
+
*
|
|
7
|
+
* - `classifyTaskMutationIntent` / `expectsImplementationMutation`: does the
|
|
8
|
+
* task REQUIRE file changes? Consumed by the completion mutation guard,
|
|
9
|
+
* which blocks completion, so its vocabulary is deliberately narrow.
|
|
10
|
+
* - `taskMayMutate`: COULD the task plausibly change files? Consumed by
|
|
11
|
+
* acceptance level inference, which only raises evidence gates, so its
|
|
12
|
+
* vocabulary is deliberately broad (any bare write verb).
|
|
13
|
+
*
|
|
14
|
+
* Explicit read-only wording ("do not modify", "review only") is a task-level
|
|
15
|
+
* intent only when no write imperative survives outside those phrases. A task
|
|
16
|
+
* like "Do not modify tests; implement the fix" is an implementation task with
|
|
17
|
+
* a scoped constraint, not a read-only task.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const REVIEW_ONLY_PATTERNS = [
|
|
21
|
+
/\breview only\b/i,
|
|
22
|
+
/\bsuggest fixes only\b/i,
|
|
23
|
+
/\bonly return findings\b/i,
|
|
24
|
+
/\breturn findings only\b/i,
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
const REVIEWER_REQUIRED_EDIT_PATTERNS = [
|
|
28
|
+
/\bmust\s+(?:edit|modify|change|fix|patch|apply)\b/i,
|
|
29
|
+
/\brequired\s+to\s+(?:edit|modify|change|fix|patch|apply)\b/i,
|
|
30
|
+
/\bregardless\s+of\s+findings\b/i,
|
|
31
|
+
/\balways\s+(?:edit|modify|change|fix|patch|apply)\b/i,
|
|
32
|
+
/\bapply\s+(?:the\s+)?fix(?:es)?\s+directly\b/i,
|
|
33
|
+
/\bmake\s+(?:the\s+)?code\s+changes\b/i,
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
// The prohibition's object ends at punctuation or at a coordinating word
|
|
37
|
+
// (but/and/then), so a follow-on clause like "but implement the fix" stays in
|
|
38
|
+
// the text for write-intent testing instead of being swallowed as the object.
|
|
39
|
+
const NO_EDIT_PROHIBITION_PATTERN = /\b(?:do not|don't|must not)\s+(?:edit|modify|write(?:\s+to)?|touch|change)\b((?:(?!\b(?:but|and|then)\b)[^.;,:!?\n–—-])*)/gi;
|
|
40
|
+
|
|
41
|
+
/** Objects of a no-edit prohibition that mean "the codebase in general" rather than a named scope. */
|
|
42
|
+
const GENERIC_PROHIBITION_OBJECT = /^\s*(?:(?:any|all|the|these|those|your|our|existing|project|source|sources|repo|repository)[\s/,-]*)*(?:files?|code|codebase|sources?|anything|repo(?:sitory)?)?\s*$/i;
|
|
43
|
+
|
|
44
|
+
const SCOPED_NO_EDIT_CONSTRAINT_PATTERNS = [
|
|
45
|
+
/\bdo not edit files?\s+outside\b/i,
|
|
46
|
+
/\bdo not edit\s+outside\b/i,
|
|
47
|
+
/\bdo not edit\s+unrelated files?\b/i,
|
|
48
|
+
/\bdo not change\s+unrelated files?\b/i,
|
|
49
|
+
/\bdo not modify\s+unrelated files?\b/i,
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
const NO_TOOL_INTENT_PATTERNS = [
|
|
53
|
+
/\bno tools? needed\b/i,
|
|
54
|
+
/\bno tools? required\b/i,
|
|
55
|
+
/\bwithout using tools\b/i,
|
|
56
|
+
/\bdo not use tools\b/i,
|
|
57
|
+
/\bdon't use tools\b/i,
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
const READ_ONLY_DELIVERABLE_PATTERNS = [
|
|
61
|
+
/\b(?:draft|write|compose|prepare|produce)\s+(?:(?:a|an|the)\s+)?(?:github\s+)?(?:issue|bug report|issue draft|issue body|proposal|plan|report|summary|findings?|analysis|recommendations?)\b/i,
|
|
62
|
+
/\b(?:issue|bug report)\s+(?:draft|body|template)\b/i,
|
|
63
|
+
/\b(?:return|provide|produce)\s+(?:text|markdown|answer|findings?|recommendations?)\s+only\b/i,
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
const RESEARCH_AGENT_PATTERNS = [
|
|
67
|
+
/\binvestigate\b/i,
|
|
68
|
+
/\bscout\b/i,
|
|
69
|
+
/\bresearch(?:er)?\b/i,
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
const FIX_OR_PATCH_IMPLEMENTATION_PATTERN = /\b(?:fix|patch)\s+(?:(?:it|this|that|them|each|any|all|these|those)\b|(?:(?:a|an|the|any|all)\s+)?(?:(?:failing|failed|broken|flaky|red|cold|start|current|existing|reported|approved|known|regression|unit|integration|e2e|source|typescript|type-?script|ts|type-?check|compiler)\s+)*(?:bug|defect|issues?|problems?|failures?|regressions?|tests?|errors?|items?|typos?|code|source|implementation|component|function|module|class|method|logic|file|files|readme|docs?|changelog|package\.json|config|manifest|extension|prompt|command|lint(?:ing)?|build|ci|type-?check|type\s+checking)\b)/i;
|
|
73
|
+
|
|
74
|
+
const WORKER_IMPLEMENTATION_PATTERNS = [
|
|
75
|
+
/\b(?:implement|edit|modify|refactor|delete)\b/i,
|
|
76
|
+
FIX_OR_PATCH_IMPLEMENTATION_PATTERN,
|
|
77
|
+
/\b(?:update|add|remove|replace|create)\b(?!\s+(?:(?:a|an|the)\s+)?(?:report|summary|findings?)(?:\b|$))/i,
|
|
78
|
+
/\bapply\s+(?:the\s+)?(?:(?:suggested|proposed|recommended)\s+)?(?:changes?|fix(?:es)?|patch)\b/i,
|
|
79
|
+
/\bmake\s+(?:the\s+)?changes\b/i,
|
|
80
|
+
/\bdo those fixes\b/i,
|
|
81
|
+
];
|
|
82
|
+
|
|
83
|
+
const GENERAL_IMPLEMENTATION_PATTERNS = [
|
|
84
|
+
/\b(?:implement|edit|modify|refactor)\b/i,
|
|
85
|
+
FIX_OR_PATCH_IMPLEMENTATION_PATTERN,
|
|
86
|
+
/\bapply\s+(?:the\s+)?(?:(?:suggested|proposed|recommended)\s+)?(?:changes?|fix(?:es)?|patch)\b/i,
|
|
87
|
+
/\bmake\s+(?:the\s+)?changes\b/i,
|
|
88
|
+
/\bdo those fixes\b/i,
|
|
89
|
+
/\b(?:update|add|remove|replace|delete|create)\s+(?:the\s+)?(?:file|files|code|source|implementation|test|tests|component|function|module|class|method|logic|import|imports|readme|docs?|changelog|package\.json|config|manifest|extension|prompt|command)\b/i,
|
|
90
|
+
];
|
|
91
|
+
|
|
92
|
+
export type TaskMutationIntent = { kind: "implementation" } | { kind: "read-only" } | { kind: "unknown" };
|
|
93
|
+
|
|
94
|
+
function stripFrameworkInstructions(task: string): string {
|
|
95
|
+
return task
|
|
96
|
+
.split("\n")
|
|
97
|
+
.filter((line) => !/^\s*\[(?:Write to|Read from):/i.test(line))
|
|
98
|
+
.filter((line) => !/^\s*(?:Create and maintain progress at:|Update progress at:|\*\*Output:\*\*|Write your findings to(?: exactly this path)?:|Return the complete artifact in your final response\.|The runtime will persist it to exactly this path:|Do not call contact_supervisor merely because no write-capable tool is available\.|This path is authoritative for this run\.|Ignore any other output filename or output path mentioned elsewhere)/i.test(line))
|
|
99
|
+
.join("\n");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function stripPatterns(task: string, patterns: RegExp[]): string {
|
|
103
|
+
let stripped = task;
|
|
104
|
+
for (const pattern of patterns) {
|
|
105
|
+
const flags = pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`;
|
|
106
|
+
stripped = stripped.replace(new RegExp(pattern.source, flags), " ");
|
|
107
|
+
}
|
|
108
|
+
return stripped;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
interface NoEditProhibitionAnalysis {
|
|
112
|
+
/** True when any prohibition (or review-only/no-tool wording) is present. */
|
|
113
|
+
present: boolean;
|
|
114
|
+
/** True when a prohibition covers files/code in general; such wording wins over write verbs. */
|
|
115
|
+
blanket: boolean;
|
|
116
|
+
/** Task text with prohibition phrases and their objects removed, for write-intent testing. */
|
|
117
|
+
strippedText: string;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function analyzeNoEditProhibitions(taskText: string): NoEditProhibitionAnalysis {
|
|
121
|
+
let present = REVIEW_ONLY_PATTERNS.some((pattern) => pattern.test(taskText))
|
|
122
|
+
|| NO_TOOL_INTENT_PATTERNS.some((pattern) => pattern.test(taskText));
|
|
123
|
+
let blanket = present;
|
|
124
|
+
let strippedText = stripPatterns(taskText, [...REVIEW_ONLY_PATTERNS, ...NO_TOOL_INTENT_PATTERNS]);
|
|
125
|
+
strippedText = strippedText.replace(new RegExp(NO_EDIT_PROHIBITION_PATTERN.source, NO_EDIT_PROHIBITION_PATTERN.flags), (_match, object: string) => {
|
|
126
|
+
present = true;
|
|
127
|
+
if (GENERIC_PROHIBITION_OBJECT.test(object)) blanket = true;
|
|
128
|
+
return " ";
|
|
129
|
+
});
|
|
130
|
+
return { present, blanket, strippedText };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function taskHasReadOnlyDeliverable(taskText: string): boolean {
|
|
134
|
+
return READ_ONLY_DELIVERABLE_PATTERNS.some((pattern) => pattern.test(taskText));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function hasImplementationIntent(agent: string, taskText: string): boolean {
|
|
138
|
+
if (/\breviewer\b/i.test(agent)) return REVIEWER_REQUIRED_EDIT_PATTERNS.some((pattern) => pattern.test(taskText));
|
|
139
|
+
if (agent === "worker") return WORKER_IMPLEMENTATION_PATTERNS.some((pattern) => pattern.test(taskText));
|
|
140
|
+
return GENERAL_IMPLEMENTATION_PATTERNS.some((pattern) => pattern.test(taskText));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function classifyTaskMutationIntent(agent: string, task: string): TaskMutationIntent {
|
|
144
|
+
const taskText = stripFrameworkInstructions(task);
|
|
145
|
+
const taskTextWithoutScopedConstraints = stripPatterns(taskText, SCOPED_NO_EDIT_CONSTRAINT_PATTERNS);
|
|
146
|
+
const prohibitions = analyzeNoEditProhibitions(taskTextWithoutScopedConstraints);
|
|
147
|
+
if (prohibitions.present) {
|
|
148
|
+
if (prohibitions.blanket) return { kind: "read-only" };
|
|
149
|
+
return hasImplementationIntent(agent, prohibitions.strippedText) ? { kind: "implementation" } : { kind: "read-only" };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (RESEARCH_AGENT_PATTERNS.some((pattern) => pattern.test(agent))) return { kind: "read-only" };
|
|
153
|
+
if (hasImplementationIntent(agent, taskText)) return { kind: "implementation" };
|
|
154
|
+
if (/\breviewer\b/i.test(agent)) return { kind: "read-only" };
|
|
155
|
+
return taskHasReadOnlyDeliverable(taskTextWithoutScopedConstraints) ? { kind: "read-only" } : { kind: "unknown" };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function expectsImplementationMutation(agent: string, task: string): boolean {
|
|
159
|
+
return classifyTaskMutationIntent(agent, task).kind === "implementation";
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Bare write verbs that make a task write-capable for acceptance inference. */
|
|
163
|
+
const MAY_MUTATE_VERB_PATTERN = /\b(?:fix|implement|update|write|edit|modify|migrate|delete|remove|refactor|commit)\b/i;
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Whether the task could plausibly change files. Blanket prohibitions win;
|
|
167
|
+
* write verbs inside a scoped prohibition's object or a read-only deliverable
|
|
168
|
+
* phrase ("write a summary report") do not count; any other bare write verb
|
|
169
|
+
* does.
|
|
170
|
+
*/
|
|
171
|
+
export function taskMayMutate(task: string): boolean {
|
|
172
|
+
const taskText = stripPatterns(stripFrameworkInstructions(task), SCOPED_NO_EDIT_CONSTRAINT_PATTERNS);
|
|
173
|
+
const prohibitions = analyzeNoEditProhibitions(taskText);
|
|
174
|
+
if (prohibitions.blanket) return false;
|
|
175
|
+
return MAY_MUTATE_VERB_PATTERN.test(stripPatterns(prohibitions.strippedText, READ_ONLY_DELIVERABLE_PATTERNS));
|
|
176
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
|
|
4
|
+
export const REQUIRED_CHILD_TOOLS_ENV = "PI_SUBAGENT_REQUIRED_TOOLS";
|
|
5
|
+
export const CHILD_TOOL_DIAGNOSTIC_PATH_ENV = "PI_SUBAGENT_TOOL_DIAGNOSTIC_PATH";
|
|
6
|
+
|
|
7
|
+
export interface ChildToolDiagnostic {
|
|
8
|
+
agent?: string;
|
|
9
|
+
required: string[];
|
|
10
|
+
available: string[];
|
|
11
|
+
missing: string[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function writeChildToolDiagnostic(
|
|
15
|
+
filePath: string,
|
|
16
|
+
required: string[],
|
|
17
|
+
available: string[],
|
|
18
|
+
agent?: string,
|
|
19
|
+
): ChildToolDiagnostic | undefined {
|
|
20
|
+
const availableNames = new Set(available);
|
|
21
|
+
const missing = required.filter((name) => !availableNames.has(name));
|
|
22
|
+
if (missing.length === 0) {
|
|
23
|
+
fs.rmSync(filePath, { force: true });
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const diagnostic: ChildToolDiagnostic = { agent, required, available, missing };
|
|
28
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
29
|
+
fs.writeFileSync(filePath, JSON.stringify(diagnostic), { mode: 0o600 });
|
|
30
|
+
return diagnostic;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function readChildToolDiagnostic(filePath: string | undefined): ChildToolDiagnostic | undefined {
|
|
34
|
+
if (!filePath || !fs.existsSync(filePath)) return undefined;
|
|
35
|
+
const parsed = JSON.parse(fs.readFileSync(filePath, "utf-8")) as Partial<ChildToolDiagnostic>;
|
|
36
|
+
const stringArray = (value: unknown): value is string[] => Array.isArray(value) && value.every((entry) => typeof entry === "string" && entry.length > 0);
|
|
37
|
+
if (!stringArray(parsed.required) || !stringArray(parsed.available) || !stringArray(parsed.missing) || (parsed.agent !== undefined && typeof parsed.agent !== "string")) {
|
|
38
|
+
throw new Error(`Malformed child tool diagnostic at '${filePath}'.`);
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
...(parsed.agent ? { agent: parsed.agent } : {}),
|
|
42
|
+
required: parsed.required,
|
|
43
|
+
available: parsed.available,
|
|
44
|
+
missing: parsed.missing,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function formatChildToolDiagnostic(diagnostic: ChildToolDiagnostic): string {
|
|
49
|
+
const subject = diagnostic.agent ? `Agent '${diagnostic.agent}'` : "Subagent";
|
|
50
|
+
return [
|
|
51
|
+
`${subject} requested unavailable child tools: ${diagnostic.missing.join(", ")}.`,
|
|
52
|
+
"The `tools` field is a strict allowlist; it does not load extension code.",
|
|
53
|
+
"For extension tools, add the provider path to `subagentOnlyExtensions` (child-only), `extensions`, or as a path-like entry in `tools`, while keeping each registered tool name in `tools`.",
|
|
54
|
+
"For MCP tools, verify the MCP adapter configuration and selected tool names. For builtin tools, verify the name against the installed Pi version.",
|
|
55
|
+
].join("\n");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function readChildToolDiagnosticError(filePath: string | undefined): string | undefined {
|
|
59
|
+
try {
|
|
60
|
+
const diagnostic = readChildToolDiagnostic(filePath);
|
|
61
|
+
return diagnostic ? formatChildToolDiagnostic(diagnostic) : undefined;
|
|
62
|
+
} catch (error) {
|
|
63
|
+
return `Failed to read child tool availability diagnostic: ${error instanceof Error ? error.message : String(error)}`;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -2,6 +2,27 @@ import type { ResolvedTurnBudget, TurnBudgetState } from "../../shared/types.ts"
|
|
|
2
2
|
|
|
3
3
|
export const DEFAULT_TURN_BUDGET_GRACE_TURNS = 1;
|
|
4
4
|
|
|
5
|
+
export function resolveTurnBudgetConfig(
|
|
6
|
+
raw: unknown,
|
|
7
|
+
label = "turnBudget",
|
|
8
|
+
): { turnBudget?: ResolvedTurnBudget; error?: string } {
|
|
9
|
+
if (raw === undefined) return {};
|
|
10
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
11
|
+
return { error: `${label} must be an object with maxTurns and optional graceTurns.` };
|
|
12
|
+
}
|
|
13
|
+
const unknownField = Object.keys(raw).find((key) => key !== "maxTurns" && key !== "graceTurns");
|
|
14
|
+
if (unknownField) return { error: `${label}.${unknownField} is not supported.` };
|
|
15
|
+
const budget = raw as { maxTurns?: unknown; graceTurns?: unknown };
|
|
16
|
+
if (typeof budget.maxTurns !== "number" || !Number.isInteger(budget.maxTurns) || budget.maxTurns < 1) {
|
|
17
|
+
return { error: `${label}.maxTurns must be an integer >= 1.` };
|
|
18
|
+
}
|
|
19
|
+
const graceTurns = budget.graceTurns ?? DEFAULT_TURN_BUDGET_GRACE_TURNS;
|
|
20
|
+
if (typeof graceTurns !== "number" || !Number.isInteger(graceTurns) || graceTurns < 0) {
|
|
21
|
+
return { error: `${label}.graceTurns must be an integer >= 0.` };
|
|
22
|
+
}
|
|
23
|
+
return { turnBudget: { maxTurns: budget.maxTurns, graceTurns } };
|
|
24
|
+
}
|
|
25
|
+
|
|
5
26
|
export function appendTurnBudgetSystemPrompt(systemPrompt: string, budget: ResolvedTurnBudget | undefined): string {
|
|
6
27
|
if (!budget) return systemPrompt;
|
|
7
28
|
const grace = budget.graceTurns === 1 ? "1 additional assistant turn" : `${budget.graceTurns} additional assistant turns`;
|
|
@@ -24,6 +45,10 @@ export function turnBudgetExceededMessage(budget: ResolvedTurnBudget, turnCount:
|
|
|
24
45
|
return `Subagent exceeded turn budget after ${turnCount} assistant turn${turnCount === 1 ? "" : "s"} (soft limit ${budget.maxTurns} + grace ${budget.graceTurns}).`;
|
|
25
46
|
}
|
|
26
47
|
|
|
48
|
+
export function turnBudgetDeferredNote(budget: ResolvedTurnBudget, turnCount: number): string {
|
|
49
|
+
return `Turn-budget termination was deferred at ${turnCount} assistant turn${turnCount === 1 ? "" : "s"} (soft limit ${budget.maxTurns} + grace ${budget.graceTurns}) because the assistant started tool work. The run ended before another safe assistant boundary; output may be partial.`;
|
|
50
|
+
}
|
|
51
|
+
|
|
27
52
|
export function formatTurnBudgetOutput(message: string, output: string): string {
|
|
28
53
|
return output.trim()
|
|
29
54
|
? `${message}\n\nPartial output before turn-budget abort:\n${output}`
|
|
@@ -44,9 +69,29 @@ export function turnBudgetState(budget: ResolvedTurnBudget, turnCount: number, e
|
|
|
44
69
|
};
|
|
45
70
|
}
|
|
46
71
|
|
|
47
|
-
export function
|
|
72
|
+
export function turnBudgetDeferredState(
|
|
73
|
+
budget: ResolvedTurnBudget,
|
|
74
|
+
turnCount: number,
|
|
75
|
+
terminationDeferredAtTurn = turnCount,
|
|
76
|
+
): TurnBudgetState {
|
|
77
|
+
return {
|
|
78
|
+
...budget,
|
|
79
|
+
turnCount,
|
|
80
|
+
outcome: "termination-deferred",
|
|
81
|
+
wrapUpRequestedAtTurn: budget.maxTurns,
|
|
82
|
+
terminationDeferredAtTurn,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function turnBudgetDecision(
|
|
87
|
+
budget: ResolvedTurnBudget,
|
|
88
|
+
turnCount: number,
|
|
89
|
+
terminalAssistantStop: boolean,
|
|
90
|
+
toolWorkActiveOrStarting: boolean,
|
|
91
|
+
): "continue" | "defer" | "abort" {
|
|
48
92
|
const hardLimit = budget.maxTurns + budget.graceTurns;
|
|
49
|
-
if (turnCount < hardLimit) return
|
|
50
|
-
if (
|
|
51
|
-
return
|
|
93
|
+
if (turnCount < hardLimit) return "continue";
|
|
94
|
+
if (toolWorkActiveOrStarting) return "defer";
|
|
95
|
+
if (turnCount === hardLimit && terminalAssistantStop) return "continue";
|
|
96
|
+
return "abort";
|
|
52
97
|
}
|
|
@@ -8,6 +8,7 @@ type AtomicJsonWriterOptions = {
|
|
|
8
8
|
now?: () => number;
|
|
9
9
|
pid?: number;
|
|
10
10
|
random?: () => number;
|
|
11
|
+
mode?: number;
|
|
11
12
|
retryRenameErrors?: boolean;
|
|
12
13
|
retryDelaysMs?: readonly number[];
|
|
13
14
|
wait?: (delayMs: number) => void;
|
|
@@ -65,6 +66,7 @@ export function createAtomicJsonWriter(options: AtomicJsonWriterOptions = {}): (
|
|
|
65
66
|
const now = options.now ?? Date.now;
|
|
66
67
|
const pid = options.pid ?? process.pid;
|
|
67
68
|
const random = options.random ?? Math.random;
|
|
69
|
+
const mode = options.mode;
|
|
68
70
|
const retryRenameErrors = options.retryRenameErrors ?? process.platform === "win32";
|
|
69
71
|
const retryDelaysMs = retryRenameErrors ? options.retryDelaysMs ?? DEFAULT_RENAME_RETRY_DELAYS_MS : [];
|
|
70
72
|
const wait = options.wait ?? waitSync;
|
|
@@ -75,7 +77,7 @@ export function createAtomicJsonWriter(options: AtomicJsonWriterOptions = {}): (
|
|
|
75
77
|
`.${path.basename(filePath)}.${pid}.${now()}.${random().toString(36).slice(2)}.tmp`,
|
|
76
78
|
);
|
|
77
79
|
try {
|
|
78
|
-
fsImpl.writeFileSync(tempPath, JSON.stringify(payload, null, 2), "utf-8");
|
|
80
|
+
fsImpl.writeFileSync(tempPath, JSON.stringify(payload, null, 2), mode === undefined ? "utf-8" : { encoding: "utf-8", mode });
|
|
79
81
|
renameWithRetry(fsImpl, tempPath, filePath, retryDelaysMs, wait);
|
|
80
82
|
} finally {
|
|
81
83
|
fsImpl.rmSync(tempPath, { force: true });
|
|
@@ -84,3 +86,4 @@ export function createAtomicJsonWriter(options: AtomicJsonWriterOptions = {}): (
|
|
|
84
86
|
}
|
|
85
87
|
|
|
86
88
|
export const writeAtomicJson = createAtomicJsonWriter();
|
|
89
|
+
export const writePrivateAtomicJson = createAtomicJsonWriter({ mode: 0o600 });
|