@tranhoangnguyen0310/pi-flow-external 1.1.0-external.2 → 1.2.0-external.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 +25 -0
- package/README.md +17 -2
- package/package.json +1 -1
- package/src/core/agy.ts +32 -10
- package/src/core/claude.ts +61 -17
- package/src/core/codex.ts +27 -7
- package/src/core/permissions.ts +85 -0
- package/src/core/resume.ts +43 -0
- package/src/core/retention.ts +64 -0
- package/src/core/spawn.ts +102 -23
- package/src/core/subagent-render.ts +16 -1
- package/src/external-command.ts +14 -3
- package/src/pi-subagent.ts +47 -3
- package/src/profiles.ts +33 -3
- package/src/settings.ts +65 -18
- package/src/types.ts +48 -0
- package/src/workflow/replay-cache.ts +3 -0
- package/src/workflow/runtime-values.ts +14 -0
- package/src/workflow/runtime.ts +11 -1
- package/src/workflow/tool.ts +12 -1
- package/src/workflow/types.ts +6 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,31 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to pi-flow external are documented here.
|
|
4
4
|
|
|
5
|
+
## [1.2.0-external.0] - 2026-08-29
|
|
6
|
+
|
|
7
|
+
Implements the orchestrator-decided tiers design ([#11](https://github.com/tranhoangnguyen03/pi-flow-external/issues/11)), live-verified against Claude Code 2.1.239, codex-cli 0.150.1, and agy 1.1.22.
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- Orchestrator-decided permission tiers: `permission` (`readonly`/`edit`/`danger`, default `danger`) on `Agent` calls and workflow `agent()` children, enforced via native harness mechanisms (Claude permission modes, Codex single-axis `--sandbox`, agy bypass-flag omission). Unsupported tiers are labeled `advisory, not enforced` instead of blocking the launch; Claude permission denials are surfaced in receipts.
|
|
12
|
+
- Per-call and per-profile USD budgets (`max_budget_usd`, settings `defaultMaxBudgetUsd`): passed natively to Claude (`--max-budget-usd`) and recorded elsewhere with an honest `budget unenforceable` label when the backend reports no cost.
|
|
13
|
+
- Session resume: `resume` on `Agent`/`agent()` continues a prior run's backend conversation (Claude `--resume`, Codex `exec resume`, agy `--conversation`); session ids are recorded in `summary.json`. Claude sessions now persist in Claude Code's own local storage (live-verified: `--resume` fails after `--no-session-persistence`, so that flag is gone).
|
|
14
|
+
- Run-record retention: settings `maxRunRecords` (default 200, `0` keeps everything) prunes oldest completed records at session start and via `/external runs --prune`; incomplete or active records are never pruned.
|
|
15
|
+
- Settings v2 with migrate-on-read from v1 files.
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- Codex `danger` runs now use `--sandbox danger-full-access` (single axis) instead of `--dangerously-bypass-approvals-and-sandbox`, so sandbox and bypass flags can never combine.
|
|
20
|
+
- Run summaries replace the `permissionControl` prototype field with the resolved `permission` tier, enforcement flag, budget, session id, and resume provenance.
|
|
21
|
+
|
|
22
|
+
## [1.1.0-external.3] - 2026-08-28
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
|
|
26
|
+
- Claude runs that spawn background agents no longer fail when a later task-notification result follows the parent turn result; the latest result is used and process exit remains the stream boundary.
|
|
27
|
+
- Agy children now run with `--print-timeout 15m`, so Agy's own five-minute print-mode default can no longer end a run early. Runs longer than 15 minutes are cut by Agy itself; the extension timeout still governs below that.
|
|
28
|
+
- Agy failures now surface the backend's terminal error (for example "The stream was interrupted") instead of only `agy exited with code 1`.
|
|
29
|
+
|
|
5
30
|
## [1.1.0-external.2] - 2026-08-24
|
|
6
31
|
|
|
7
32
|
### Added
|
package/README.md
CHANGED
|
@@ -187,6 +187,16 @@ Use the workflow tool to ask "claude-explorer" for an architecture map and "code
|
|
|
187
187
|
|
|
188
188
|
Direct `Agent` calls and workflow children share the same concurrency and timeout controls.
|
|
189
189
|
|
|
190
|
+
## Permission tiers, budgets, and resume
|
|
191
|
+
|
|
192
|
+
Every `Agent` call and workflow `agent()` child accepts three optional parameters:
|
|
193
|
+
|
|
194
|
+
- `permission`: `readonly` | `edit` | `danger` (default `danger`, today's behavior). Tiers map onto native harness mechanisms — Claude permission modes, Codex's single-axis `--sandbox`, and Antigravity's default headless policy (`edit` omits the bypass flag). Where a harness cannot enforce a tier — agy `readonly`, whose headless mode always auto-allows workspace writes — the run proceeds and the receipt labels it `advisory, not enforced`. Claude `readonly`/`edit` runs auto-deny shell commands headlessly; denials are surfaced in the receipt.
|
|
195
|
+
- `max_budget_usd`: a spending cap. Claude Code enforces it mid-run with its native `--max-budget-usd` flag; codex and agy do not report cost, so the cap is recorded and marked `budget unenforceable` instead of pretended.
|
|
196
|
+
- `resume`: a prior run id. Continues the same backend conversation (Claude `--resume`, Codex `exec resume`, agy `--conversation`) instead of starting from scratch. The prior run must use the same backend. Claude sessions persist in Claude Code's own local storage (this extension no longer passes `--no-session-persistence`) so recorded session ids stay resumable; remove old conversations from Claude Code itself if that matters to you.
|
|
197
|
+
|
|
198
|
+
Resolution order for tiers and budgets: call > profile frontmatter (`permission:`, `max_budget_usd:`) > settings defaults.
|
|
199
|
+
|
|
190
200
|
## Settings and runtime limits
|
|
191
201
|
|
|
192
202
|
The extension creates:
|
|
@@ -199,12 +209,17 @@ Normally this resolves to `~/.pi/agent/pi-flow-external/settings.json`:
|
|
|
199
209
|
|
|
200
210
|
```json
|
|
201
211
|
{
|
|
202
|
-
"version":
|
|
212
|
+
"version": 2,
|
|
203
213
|
"maxConcurrentSubagents": 12,
|
|
204
|
-
"subagentTimeoutMs": 7200000
|
|
214
|
+
"subagentTimeoutMs": 7200000,
|
|
215
|
+
"defaultPermission": "danger",
|
|
216
|
+
"defaultMaxBudgetUsd": null,
|
|
217
|
+
"maxRunRecords": 200
|
|
205
218
|
}
|
|
206
219
|
```
|
|
207
220
|
|
|
221
|
+
Version 1 files migrate on read: recognized keys carry over, unknown keys warn, invalid values fall back per-key. `maxRunRecords` prunes the oldest completed run records at session start and via `/external runs --prune`; records still running or interrupted are never pruned, and `0` keeps everything.
|
|
222
|
+
|
|
208
223
|
Edit the file and run `/reload`. Startup flags override extension factory options, which override this file, which overrides built-in defaults.
|
|
209
224
|
|
|
210
225
|
Equivalent startup flags:
|
package/package.json
CHANGED
package/src/core/agy.ts
CHANGED
|
@@ -6,7 +6,8 @@ import {
|
|
|
6
6
|
MAX_STDERR_CHARS,
|
|
7
7
|
MAX_STDOUT_LINE_CHARS,
|
|
8
8
|
} from "./stream.ts";
|
|
9
|
-
import type { SubagentProfile, SubagentUsage, ThinkingLevel } from "../types.ts";
|
|
9
|
+
import type { PermissionTier, SubagentProfile, SubagentUsage, ThinkingLevel } from "../types.ts";
|
|
10
|
+
import { buildPermissionArgs } from "./permissions.ts";
|
|
10
11
|
|
|
11
12
|
const AGY_COMMAND = "agy";
|
|
12
13
|
const FORCE_KILL_DELAY_MS = 3000;
|
|
@@ -171,18 +172,30 @@ export function buildAgyArgs({
|
|
|
171
172
|
profile,
|
|
172
173
|
thinkingLevel,
|
|
173
174
|
outputSchema,
|
|
175
|
+
permission = "danger",
|
|
176
|
+
resumeConversationId,
|
|
174
177
|
}: {
|
|
175
178
|
profile: SubagentProfile;
|
|
176
179
|
thinkingLevel: ThinkingLevel | undefined;
|
|
177
180
|
outputSchema?: unknown;
|
|
181
|
+
permission?: PermissionTier;
|
|
182
|
+
resumeConversationId?: string;
|
|
178
183
|
}): string[] {
|
|
179
184
|
const args = [
|
|
180
|
-
"
|
|
185
|
+
...buildPermissionArgs(permission, "agy"),
|
|
181
186
|
"--output-format",
|
|
182
187
|
"stream-json",
|
|
183
188
|
"--input-format",
|
|
184
189
|
"stream-json",
|
|
190
|
+
// ponytail: fixed 15m print ceiling so agy's own 5m default cannot
|
|
191
|
+
// preempt the outer timeout; derive from subagentTimeoutMs if agy
|
|
192
|
+
// runs ever legitimately need more than 15 minutes.
|
|
193
|
+
"--print-timeout",
|
|
194
|
+
"15m",
|
|
185
195
|
];
|
|
196
|
+
if (resumeConversationId) {
|
|
197
|
+
args.push("--conversation", resumeConversationId);
|
|
198
|
+
}
|
|
186
199
|
if (profile.model) {
|
|
187
200
|
args.push("--model", profile.model);
|
|
188
201
|
}
|
|
@@ -238,6 +251,8 @@ export async function spawnAgySubagent(params: {
|
|
|
238
251
|
onBackendEvent?: (event: unknown) => void;
|
|
239
252
|
appendInstructions?: string;
|
|
240
253
|
outputSchema?: unknown;
|
|
254
|
+
permission?: PermissionTier;
|
|
255
|
+
resumeConversationId?: string;
|
|
241
256
|
}): Promise<AgentToolResult> {
|
|
242
257
|
const subagentType = params.profile.name;
|
|
243
258
|
const promptParts = [params.profile.systemPrompt, params.prompt, params.appendInstructions].filter(Boolean);
|
|
@@ -312,6 +327,8 @@ export async function spawnAgySubagent(params: {
|
|
|
312
327
|
profile: params.profile,
|
|
313
328
|
thinkingLevel: params.thinkingLevel,
|
|
314
329
|
outputSchema: params.outputSchema,
|
|
330
|
+
permission: params.permission,
|
|
331
|
+
resumeConversationId: params.resumeConversationId,
|
|
315
332
|
}), {
|
|
316
333
|
cwd: params.ctx.cwd,
|
|
317
334
|
env: process.env,
|
|
@@ -389,19 +406,24 @@ export async function spawnAgySubagent(params: {
|
|
|
389
406
|
if (protocolError) {
|
|
390
407
|
throw new Error(protocolError);
|
|
391
408
|
}
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
}
|
|
409
|
+
const exitSuffix = closeResult.code !== 0
|
|
410
|
+
? ` (exit code ${closeResult.code}${closeResult.signal ? `, signal ${closeResult.signal}` : ""})`
|
|
411
|
+
: "";
|
|
396
412
|
if (!terminalResult) {
|
|
397
|
-
|
|
413
|
+
const stderr = stderrBuffer.text().trim();
|
|
414
|
+
throw new Error(`agy exited without a terminal result event${exitSuffix}${stderr ? `: ${stderr}` : ""}`);
|
|
398
415
|
}
|
|
399
416
|
const terminalStatus = terminalResult.status?.trim().toUpperCase();
|
|
400
417
|
if (!terminalStatus) {
|
|
401
418
|
throw new Error("agy terminal result is missing status");
|
|
402
419
|
}
|
|
403
420
|
if (terminalStatus !== "SUCCESS") {
|
|
404
|
-
|
|
421
|
+
// Prefer agy's own failure reason over the generic exit-code error.
|
|
422
|
+
throw new Error(`agy failed with status ${terminalStatus}${terminalResult.error?.trim() ? `: ${terminalResult.error.trim()}` : ""}${exitSuffix}`);
|
|
423
|
+
}
|
|
424
|
+
if (closeResult.code !== 0) {
|
|
425
|
+
const stderr = stderrBuffer.text().trim();
|
|
426
|
+
throw new Error(`agy exited with code ${closeResult.code}${closeResult.signal ? ` (signal ${closeResult.signal})` : ""}${stderr ? `: ${stderr}` : ""}`);
|
|
405
427
|
}
|
|
406
428
|
const result = textFromAgyResult(terminalResult)?.trim();
|
|
407
429
|
if (!result) {
|
|
@@ -421,7 +443,7 @@ export async function spawnAgySubagent(params: {
|
|
|
421
443
|
status: "done",
|
|
422
444
|
result,
|
|
423
445
|
usage: latestUsage,
|
|
424
|
-
...(conversationId ? { conversationId } : {}),
|
|
446
|
+
...(conversationId ? { conversationId, sessionId: conversationId } : {}),
|
|
425
447
|
...(progress ? { progress } : {}),
|
|
426
448
|
});
|
|
427
449
|
} catch (error) {
|
|
@@ -442,7 +464,7 @@ export async function spawnAgySubagent(params: {
|
|
|
442
464
|
status,
|
|
443
465
|
error: message,
|
|
444
466
|
usage: latestUsage,
|
|
445
|
-
...(conversationId ? { conversationId } : {}),
|
|
467
|
+
...(conversationId ? { conversationId, sessionId: conversationId } : {}),
|
|
446
468
|
...(progress ? { progress } : {}),
|
|
447
469
|
});
|
|
448
470
|
} finally {
|
package/src/core/claude.ts
CHANGED
|
@@ -10,7 +10,8 @@ import {
|
|
|
10
10
|
MAX_STDERR_CHARS,
|
|
11
11
|
MAX_STDOUT_LINE_CHARS,
|
|
12
12
|
} from "./stream.ts";
|
|
13
|
-
import type { SubagentProfile, SubagentUsage, ThinkingLevel } from "../types.ts";
|
|
13
|
+
import type { PermissionTier, SubagentProfile, SubagentUsage, ThinkingLevel } from "../types.ts";
|
|
14
|
+
import { buildPermissionArgs } from "./permissions.ts";
|
|
14
15
|
|
|
15
16
|
const CLAUDE_COMMAND = "claude";
|
|
16
17
|
const FORCE_KILL_DELAY_MS = 3000;
|
|
@@ -35,11 +36,17 @@ export function buildClaudeArgs({
|
|
|
35
36
|
profile,
|
|
36
37
|
thinkingLevel,
|
|
37
38
|
outputSchema,
|
|
39
|
+
permission = "danger",
|
|
40
|
+
maxBudgetUsd,
|
|
41
|
+
resumeSessionId,
|
|
38
42
|
effectiveUid = process.geteuid?.(),
|
|
39
43
|
}: {
|
|
40
44
|
profile: SubagentProfile;
|
|
41
45
|
thinkingLevel: ThinkingLevel | undefined;
|
|
42
46
|
outputSchema?: unknown;
|
|
47
|
+
permission?: PermissionTier;
|
|
48
|
+
maxBudgetUsd?: number;
|
|
49
|
+
resumeSessionId?: string;
|
|
43
50
|
effectiveUid?: number;
|
|
44
51
|
}): string[] {
|
|
45
52
|
const args = [
|
|
@@ -47,11 +54,17 @@ export function buildClaudeArgs({
|
|
|
47
54
|
"--output-format",
|
|
48
55
|
"stream-json",
|
|
49
56
|
"--verbose",
|
|
50
|
-
"--no-session-persistence",
|
|
51
57
|
];
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
58
|
+
if (resumeSessionId) {
|
|
59
|
+
args.push("--resume", resumeSessionId);
|
|
60
|
+
}
|
|
61
|
+
// Otherwise sessions persist in Claude Code's own local storage so a later
|
|
62
|
+
// call can --resume them; live-verified (plan doc A2). --no-session-persistence
|
|
63
|
+
// made every recorded session_id unresumable.
|
|
64
|
+
args.push(...buildPermissionArgs(permission, "claude", { effectiveUid }));
|
|
65
|
+
if (maxBudgetUsd !== undefined && Number.isFinite(maxBudgetUsd) && maxBudgetUsd > 0) {
|
|
66
|
+
args.push("--max-budget-usd", String(maxBudgetUsd));
|
|
67
|
+
}
|
|
55
68
|
if (profile.systemPrompt) {
|
|
56
69
|
args.push("--append-system-prompt", profile.systemPrompt);
|
|
57
70
|
}
|
|
@@ -173,6 +186,20 @@ export function extractClaudeCostUsd(event: Record<string, unknown>): number | u
|
|
|
173
186
|
return asFiniteNumber(event.total_cost_usd) ?? sumModelUsageCost(event.modelUsage);
|
|
174
187
|
}
|
|
175
188
|
|
|
189
|
+
export function extractClaudeSessionId(event: Record<string, unknown>): string | undefined {
|
|
190
|
+
if (event.type !== "result") {
|
|
191
|
+
return undefined;
|
|
192
|
+
}
|
|
193
|
+
return typeof event.session_id === "string" && event.session_id ? event.session_id : undefined;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function extractClaudePermissionDenials(event: Record<string, unknown>): number | undefined {
|
|
197
|
+
if (event.type !== "result" || !Array.isArray(event.permission_denials)) {
|
|
198
|
+
return undefined;
|
|
199
|
+
}
|
|
200
|
+
return event.permission_denials.length;
|
|
201
|
+
}
|
|
202
|
+
|
|
176
203
|
export function claudeUsageToSubagentUsage(usage: ClaudeTokenUsage, costUsd: number | undefined): SubagentUsage {
|
|
177
204
|
const input = Math.max(0, usage.inputTokens);
|
|
178
205
|
const cacheRead = Math.max(0, usage.cacheReadInputTokens);
|
|
@@ -339,6 +366,9 @@ export async function spawnClaudeSubagent(params: {
|
|
|
339
366
|
onBackendEvent?: (event: unknown) => void;
|
|
340
367
|
appendInstructions?: string;
|
|
341
368
|
outputSchema?: unknown;
|
|
369
|
+
permission?: PermissionTier;
|
|
370
|
+
maxBudgetUsd?: number;
|
|
371
|
+
resumeSessionId?: string;
|
|
342
372
|
}): Promise<AgentToolResult> {
|
|
343
373
|
const subagentType = params.profile.name;
|
|
344
374
|
const taskPrompt = params.appendInstructions ? `${params.prompt}\n\n${params.appendInstructions}` : params.prompt;
|
|
@@ -355,11 +385,12 @@ export async function spawnClaudeSubagent(params: {
|
|
|
355
385
|
let latestCostUsd: number | undefined;
|
|
356
386
|
let latestUsage = claudeUsageToSubagentUsage(latestRawUsage, latestCostUsd);
|
|
357
387
|
let resultText = "";
|
|
388
|
+
let sessionId: string | undefined;
|
|
389
|
+
let permissionDenials: number | undefined;
|
|
358
390
|
const stderrBuffer = createBoundedBuffer(MAX_STDERR_CHARS);
|
|
359
391
|
let sawTerminalEvent = false;
|
|
360
392
|
let terminalSucceeded = false;
|
|
361
393
|
let eventError: string | undefined;
|
|
362
|
-
let protocolError: string | undefined;
|
|
363
394
|
let oversizeError: string | undefined;
|
|
364
395
|
let child: ChildProcess | undefined;
|
|
365
396
|
let abortHandler: (() => void) | undefined;
|
|
@@ -384,14 +415,13 @@ export async function spawnClaudeSubagent(params: {
|
|
|
384
415
|
} catch {
|
|
385
416
|
// Observation hooks must not change the backend result.
|
|
386
417
|
}
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
if (isTerminal) {
|
|
418
|
+
// A result event ends a model turn, not the stream: when the child runs
|
|
419
|
+
// background agents, a task notification can run another turn and emit a
|
|
420
|
+
// later result. Process exit is the stream boundary; the latest result
|
|
421
|
+
// wins, and explicit error events stay fatal below.
|
|
422
|
+
if (event.type === "result") {
|
|
393
423
|
sawTerminalEvent = true;
|
|
394
|
-
terminalSucceeded = event.
|
|
424
|
+
terminalSucceeded = event.subtype === "success" && event.is_error === false;
|
|
395
425
|
}
|
|
396
426
|
const activity = claudeActivityFromEvent(event);
|
|
397
427
|
if (activity) {
|
|
@@ -404,6 +434,16 @@ export async function spawnClaudeSubagent(params: {
|
|
|
404
434
|
publishUsage(usage, cost);
|
|
405
435
|
}
|
|
406
436
|
const text = extractClaudeFinalText(event);
|
|
437
|
+
const eventSessionId = extractClaudeSessionId(event);
|
|
438
|
+
if (eventSessionId) {
|
|
439
|
+
sessionId = eventSessionId;
|
|
440
|
+
}
|
|
441
|
+
const denials = extractClaudePermissionDenials(event);
|
|
442
|
+
if (denials !== undefined) {
|
|
443
|
+
// Latest result wins, consistent with text/usage/session fields: a
|
|
444
|
+
// background-agent follow-up turn re-reports rather than appends.
|
|
445
|
+
permissionDenials = denials;
|
|
446
|
+
}
|
|
407
447
|
if (text !== undefined) {
|
|
408
448
|
resultText = text;
|
|
409
449
|
if (text.trim()) {
|
|
@@ -414,7 +454,7 @@ export async function spawnClaudeSubagent(params: {
|
|
|
414
454
|
const error = extractClaudeError(event);
|
|
415
455
|
if (error) {
|
|
416
456
|
eventError ??= error;
|
|
417
|
-
} else if (
|
|
457
|
+
} else if (event.type === "result" && !terminalSucceeded) {
|
|
418
458
|
eventError ??= "claude terminal event did not affirm success";
|
|
419
459
|
}
|
|
420
460
|
};
|
|
@@ -428,6 +468,9 @@ export async function spawnClaudeSubagent(params: {
|
|
|
428
468
|
profile: params.profile,
|
|
429
469
|
thinkingLevel: params.thinkingLevel,
|
|
430
470
|
outputSchema: params.outputSchema,
|
|
471
|
+
permission: params.permission,
|
|
472
|
+
maxBudgetUsd: params.maxBudgetUsd,
|
|
473
|
+
resumeSessionId: params.resumeSessionId,
|
|
431
474
|
});
|
|
432
475
|
|
|
433
476
|
const proc = spawn(CLAUDE_COMMAND, args, {
|
|
@@ -508,9 +551,6 @@ export async function spawnClaudeSubagent(params: {
|
|
|
508
551
|
if (oversizeError) {
|
|
509
552
|
throw new Error(oversizeError);
|
|
510
553
|
}
|
|
511
|
-
if (protocolError) {
|
|
512
|
-
throw new Error(protocolError);
|
|
513
|
-
}
|
|
514
554
|
if (eventError) {
|
|
515
555
|
throw new Error(eventError);
|
|
516
556
|
}
|
|
@@ -543,6 +583,8 @@ export async function spawnClaudeSubagent(params: {
|
|
|
543
583
|
status: "done",
|
|
544
584
|
result,
|
|
545
585
|
usage: latestUsage,
|
|
586
|
+
...(permissionDenials !== undefined && permissionDenials > 0 ? { permissionDenials } : {}),
|
|
587
|
+
...(sessionId ? { sessionId } : {}),
|
|
546
588
|
...(progress ? { progress } : {}),
|
|
547
589
|
});
|
|
548
590
|
} catch (error) {
|
|
@@ -566,6 +608,8 @@ export async function spawnClaudeSubagent(params: {
|
|
|
566
608
|
status,
|
|
567
609
|
error: message,
|
|
568
610
|
usage: latestUsage,
|
|
611
|
+
...(sessionId ? { sessionId } : {}),
|
|
612
|
+
...(permissionDenials !== undefined && permissionDenials > 0 ? { permissionDenials } : {}),
|
|
569
613
|
...(progress ? { progress } : {}),
|
|
570
614
|
});
|
|
571
615
|
} finally {
|
package/src/core/codex.ts
CHANGED
|
@@ -13,7 +13,8 @@ import {
|
|
|
13
13
|
MAX_STDERR_CHARS,
|
|
14
14
|
MAX_STDOUT_LINE_CHARS,
|
|
15
15
|
} from "./stream.ts";
|
|
16
|
-
import type { SubagentProfile, SubagentUsage, ThinkingLevel } from "../types.ts";
|
|
16
|
+
import type { PermissionTier, SubagentProfile, SubagentUsage, ThinkingLevel } from "../types.ts";
|
|
17
|
+
import { buildPermissionArgs } from "./permissions.ts";
|
|
17
18
|
|
|
18
19
|
const CODEX_COMMAND = "codex";
|
|
19
20
|
const FORCE_KILL_DELAY_MS = 3000;
|
|
@@ -100,18 +101,19 @@ export function buildCodexArgs({
|
|
|
100
101
|
profile,
|
|
101
102
|
thinkingLevel,
|
|
102
103
|
outputSchemaPath,
|
|
104
|
+
permission = "danger",
|
|
105
|
+
resumeSessionId,
|
|
103
106
|
}: {
|
|
104
107
|
prompt: string;
|
|
105
108
|
profile: SubagentProfile;
|
|
106
109
|
thinkingLevel: ThinkingLevel | undefined;
|
|
107
110
|
outputSchemaPath?: string;
|
|
111
|
+
permission?: PermissionTier;
|
|
112
|
+
resumeSessionId?: string;
|
|
108
113
|
}): string[] {
|
|
109
|
-
const args =
|
|
110
|
-
"exec",
|
|
111
|
-
"--json",
|
|
112
|
-
"--skip-git-repo-check",
|
|
113
|
-
"--dangerously-bypass-approvals-and-sandbox",
|
|
114
|
-
];
|
|
114
|
+
const args = resumeSessionId
|
|
115
|
+
? ["exec", ...buildPermissionArgs(permission, "codex"), "resume", resumeSessionId, "--json", "--skip-git-repo-check"]
|
|
116
|
+
: ["exec", "--json", "--skip-git-repo-check", ...buildPermissionArgs(permission, "codex")];
|
|
115
117
|
if (profile.systemPrompt) {
|
|
116
118
|
args.push("-c", buildConfigOverrideArg("developer_instructions", profile.systemPrompt));
|
|
117
119
|
}
|
|
@@ -174,6 +176,13 @@ export function extractCodexUsage(event: Record<string, unknown>): CodexTokenUsa
|
|
|
174
176
|
return info ? parseUsageRecord(info.last_token_usage) : undefined;
|
|
175
177
|
}
|
|
176
178
|
|
|
179
|
+
export function extractCodexSessionId(event: Record<string, unknown>): string | undefined {
|
|
180
|
+
if (event.type !== "thread.started") {
|
|
181
|
+
return undefined;
|
|
182
|
+
}
|
|
183
|
+
return typeof event.thread_id === "string" && event.thread_id ? event.thread_id : undefined;
|
|
184
|
+
}
|
|
185
|
+
|
|
177
186
|
function textFromCodexValue(value: unknown): string | undefined {
|
|
178
187
|
if (typeof value === "string") {
|
|
179
188
|
return value;
|
|
@@ -332,6 +341,8 @@ export async function spawnCodexSubagent(params: {
|
|
|
332
341
|
onBackendEvent?: (event: unknown) => void;
|
|
333
342
|
appendInstructions?: string;
|
|
334
343
|
outputSchema?: unknown;
|
|
344
|
+
permission?: PermissionTier;
|
|
345
|
+
resumeSessionId?: string;
|
|
335
346
|
}): Promise<AgentToolResult> {
|
|
336
347
|
const subagentType = params.profile.name;
|
|
337
348
|
const taskPrompt = params.appendInstructions ? `${params.prompt}\n\n${params.appendInstructions}` : params.prompt;
|
|
@@ -346,6 +357,7 @@ export async function spawnCodexSubagent(params: {
|
|
|
346
357
|
const progress = emitter.progress;
|
|
347
358
|
let latestUsage = emptyUsage(params.profile.model);
|
|
348
359
|
let resultText = "";
|
|
360
|
+
let sessionId: string | undefined;
|
|
349
361
|
const stderrBuffer = createBoundedBuffer(MAX_STDERR_CHARS);
|
|
350
362
|
let sawTerminalEvent = false;
|
|
351
363
|
let terminalSucceeded = false;
|
|
@@ -372,6 +384,10 @@ export async function spawnCodexSubagent(params: {
|
|
|
372
384
|
sawTerminalEvent = true;
|
|
373
385
|
terminalSucceeded = event.type === "turn.completed";
|
|
374
386
|
}
|
|
387
|
+
const eventSessionId = extractCodexSessionId(event);
|
|
388
|
+
if (eventSessionId) {
|
|
389
|
+
sessionId = eventSessionId;
|
|
390
|
+
}
|
|
375
391
|
const activity = codexActivityFromEvent(event);
|
|
376
392
|
if (activity) {
|
|
377
393
|
emitter.addActivity(activity);
|
|
@@ -415,6 +431,8 @@ export async function spawnCodexSubagent(params: {
|
|
|
415
431
|
profile: params.profile,
|
|
416
432
|
thinkingLevel: params.thinkingLevel,
|
|
417
433
|
outputSchemaPath: schemaFile?.path,
|
|
434
|
+
permission: params.permission,
|
|
435
|
+
resumeSessionId: params.resumeSessionId,
|
|
418
436
|
});
|
|
419
437
|
|
|
420
438
|
const proc = spawn(CODEX_COMMAND, args, {
|
|
@@ -531,6 +549,7 @@ export async function spawnCodexSubagent(params: {
|
|
|
531
549
|
status: "done",
|
|
532
550
|
result,
|
|
533
551
|
usage: latestUsage,
|
|
552
|
+
...(sessionId ? { sessionId } : {}),
|
|
534
553
|
...(progress ? { progress } : {}),
|
|
535
554
|
});
|
|
536
555
|
} catch (error) {
|
|
@@ -554,6 +573,7 @@ export async function spawnCodexSubagent(params: {
|
|
|
554
573
|
status,
|
|
555
574
|
error: message,
|
|
556
575
|
usage: latestUsage,
|
|
576
|
+
...(sessionId ? { sessionId } : {}),
|
|
557
577
|
...(progress ? { progress } : {}),
|
|
558
578
|
});
|
|
559
579
|
} finally {
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { PermissionTier, SubagentBackend } from "../types.ts";
|
|
2
|
+
|
|
3
|
+
export interface PermissionResolution {
|
|
4
|
+
tier: PermissionTier;
|
|
5
|
+
/** True when the backend enforces the tier with a native mechanism. */
|
|
6
|
+
enforced: boolean;
|
|
7
|
+
/** Short human-readable caveat shown in disclosure labels. */
|
|
8
|
+
caveat: string | undefined;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Resolve a tier for one backend. Trust + disclose: unsupported tiers are
|
|
13
|
+
* advisory (instruction-only), never a launch failure.
|
|
14
|
+
*/
|
|
15
|
+
export function resolvePermission(tier: PermissionTier, backend: SubagentBackend): PermissionResolution {
|
|
16
|
+
switch (backend) {
|
|
17
|
+
case "claude":
|
|
18
|
+
return {
|
|
19
|
+
tier,
|
|
20
|
+
enforced: true,
|
|
21
|
+
// plan/acceptEdits auto-deny Bash headlessly; denials are surfaced.
|
|
22
|
+
caveat: tier === "danger" ? undefined : "Bash auto-denied headlessly",
|
|
23
|
+
};
|
|
24
|
+
case "codex":
|
|
25
|
+
return {
|
|
26
|
+
tier,
|
|
27
|
+
enforced: true,
|
|
28
|
+
// --sandbox governs model-generated shell commands, not MCP/plugins/hooks.
|
|
29
|
+
caveat: tier === "danger" ? undefined : "shell commands only",
|
|
30
|
+
};
|
|
31
|
+
case "agy":
|
|
32
|
+
if (tier === "danger") {
|
|
33
|
+
return { tier, enforced: true, caveat: undefined };
|
|
34
|
+
}
|
|
35
|
+
// agy headless without bypass: workspace writes auto-allowed, shell
|
|
36
|
+
// commands soft-denied. That is a native `edit` tier but not readonly.
|
|
37
|
+
return {
|
|
38
|
+
tier,
|
|
39
|
+
enforced: tier === "edit",
|
|
40
|
+
caveat: tier === "edit" ? "workspace writes allowed, shell soft-denied" : "advisory, not enforced",
|
|
41
|
+
};
|
|
42
|
+
default:
|
|
43
|
+
return { tier, enforced: false, caveat: "advisory, not enforced" };
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Native argv fragments implementing each tier. */
|
|
48
|
+
export function buildPermissionArgs(
|
|
49
|
+
tier: PermissionTier,
|
|
50
|
+
backend: SubagentBackend,
|
|
51
|
+
options: { effectiveUid?: number } = {},
|
|
52
|
+
): string[] {
|
|
53
|
+
switch (backend) {
|
|
54
|
+
case "claude":
|
|
55
|
+
if (tier === "readonly") return ["--permission-mode", "plan"];
|
|
56
|
+
if (tier === "edit") return ["--permission-mode", "acceptEdits"];
|
|
57
|
+
return options.effectiveUid === 0 ? ["--permission-mode", "auto"] : ["--dangerously-skip-permissions"];
|
|
58
|
+
case "codex":
|
|
59
|
+
if (tier === "readonly") return ["--sandbox", "read-only"];
|
|
60
|
+
if (tier === "edit") return ["--sandbox", "workspace-write"];
|
|
61
|
+
return ["--sandbox", "danger-full-access"];
|
|
62
|
+
case "agy":
|
|
63
|
+
// edit/readonly: omit the bypass flag; agy's default headless policy
|
|
64
|
+
// allows workspace writes and soft-denies shell commands.
|
|
65
|
+
return tier === "danger" ? ["--dangerously-skip-permissions"] : [];
|
|
66
|
+
default:
|
|
67
|
+
return [];
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Disclosure label used in delegation cards and receipts. */
|
|
72
|
+
export function permissionLabel(resolution: PermissionResolution): string {
|
|
73
|
+
const { tier, enforced, caveat } = resolution;
|
|
74
|
+
if (tier === "danger") {
|
|
75
|
+
return "unsandboxed external CLI";
|
|
76
|
+
}
|
|
77
|
+
const parts = [`external CLI · ${tier}`];
|
|
78
|
+
if (!enforced) {
|
|
79
|
+
parts.push("advisory, not enforced");
|
|
80
|
+
}
|
|
81
|
+
if (caveat && caveat !== "advisory, not enforced") {
|
|
82
|
+
parts.push(caveat);
|
|
83
|
+
}
|
|
84
|
+
return parts.join(" · ");
|
|
85
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import type { SubagentBackend } from "../types.ts";
|
|
4
|
+
|
|
5
|
+
export interface ResumedSession {
|
|
6
|
+
runId: string;
|
|
7
|
+
sessionId: string;
|
|
8
|
+
backend: SubagentBackend;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Resolve a prior run id to its recorded backend session id by reading the
|
|
13
|
+
* run record's summary.json. Returns an error string on mismatch or missing
|
|
14
|
+
* records; callers surface it as a failed tool result, not a throw.
|
|
15
|
+
*/
|
|
16
|
+
export async function resolveResume(
|
|
17
|
+
runsDirectory: string,
|
|
18
|
+
runId: string,
|
|
19
|
+
expectedBackend: SubagentBackend,
|
|
20
|
+
): Promise<{ session?: ResumedSession; error?: string }> {
|
|
21
|
+
if (!/^run_[a-z0-9-]+$/i.test(runId)) {
|
|
22
|
+
return { error: `Invalid resume run id "${runId}".` };
|
|
23
|
+
}
|
|
24
|
+
let summary: unknown;
|
|
25
|
+
try {
|
|
26
|
+
summary = JSON.parse(await readFile(join(runsDirectory, runId, "summary.json"), "utf8"));
|
|
27
|
+
} catch {
|
|
28
|
+
return { error: `No completed run record found for "${runId}" under ${runsDirectory}.` };
|
|
29
|
+
}
|
|
30
|
+
const record = summary as Record<string, unknown> | null;
|
|
31
|
+
const payload = record && typeof record === "object"
|
|
32
|
+
? record.summary as Record<string, unknown> | undefined
|
|
33
|
+
: undefined;
|
|
34
|
+
const sessionId = typeof payload?.sessionId === "string" ? payload.sessionId : undefined;
|
|
35
|
+
const backend = typeof payload?.backend === "string" ? payload.backend : undefined;
|
|
36
|
+
if (!sessionId || !backend) {
|
|
37
|
+
return { error: `Run record "${runId}" has no session id to resume.` };
|
|
38
|
+
}
|
|
39
|
+
if (backend !== expectedBackend) {
|
|
40
|
+
return { error: `Run "${runId}" used backend "${backend}"; resume must use the same backend (expected "${expectedBackend}").` };
|
|
41
|
+
}
|
|
42
|
+
return { session: { runId, sessionId, backend: backend as SubagentBackend } };
|
|
43
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { readFile, readdir, rm, stat } from "node:fs/promises";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
|
|
5
|
+
const RUN_RECORDS_DIRECTORY_ENV = "PI_FLOW_EXTERNAL_RUNS_DIR";
|
|
6
|
+
|
|
7
|
+
/** Directory holding local external run records (env-overridable). */
|
|
8
|
+
export function runRecordsDirectory(): string {
|
|
9
|
+
const configured = process.env[RUN_RECORDS_DIRECTORY_ENV]?.trim();
|
|
10
|
+
return configured ? resolve(configured) : join(getAgentDir(), "pi-flow-external", "runs");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Prune completed run records beyond `maxRecords`, oldest first.
|
|
15
|
+
*
|
|
16
|
+
* Only records whose `summary.json` exists are eligible: a record without a
|
|
17
|
+
* summary is either still running or was interrupted, and deleting it could
|
|
18
|
+
* kill the evidence of an active run. `maxRecords <= 0` keeps everything.
|
|
19
|
+
*/
|
|
20
|
+
export async function pruneRunRecords(
|
|
21
|
+
runsDirectory: string,
|
|
22
|
+
maxRecords: number,
|
|
23
|
+
): Promise<{ pruned: string[]; kept: number }> {
|
|
24
|
+
if (!(maxRecords > 0)) {
|
|
25
|
+
return { pruned: [], kept: 0 };
|
|
26
|
+
}
|
|
27
|
+
let entries: string[];
|
|
28
|
+
try {
|
|
29
|
+
entries = await readdir(runsDirectory);
|
|
30
|
+
} catch {
|
|
31
|
+
return { pruned: [], kept: 0 };
|
|
32
|
+
}
|
|
33
|
+
const completed: { directory: string; mtimeMs: number }[] = [];
|
|
34
|
+
let kept = 0;
|
|
35
|
+
for (const entry of entries) {
|
|
36
|
+
if (!entry.startsWith("run_")) {
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
const directory = join(runsDirectory, entry);
|
|
40
|
+
try {
|
|
41
|
+
// A summary is only a completion marker when it parses and matches its
|
|
42
|
+
// run directory; damaged or partial evidence stays untouched.
|
|
43
|
+
const summary = JSON.parse(await readFile(join(directory, "summary.json"), "utf8")) as Record<string, unknown>;
|
|
44
|
+
if (summary.runId !== entry) {
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
} catch {
|
|
48
|
+
// Incomplete, active, or damaged: never eligible for pruning.
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
completed.push({ directory, mtimeMs: (await stat(directory)).mtimeMs });
|
|
52
|
+
}
|
|
53
|
+
completed.sort((a, b) => a.mtimeMs - b.mtimeMs);
|
|
54
|
+
const pruned: string[] = [];
|
|
55
|
+
for (const item of completed) {
|
|
56
|
+
if (completed.length - pruned.length <= maxRecords) {
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
await rm(item.directory, { recursive: true, force: true });
|
|
60
|
+
pruned.push(item.directory);
|
|
61
|
+
}
|
|
62
|
+
kept = completed.length - pruned.length;
|
|
63
|
+
return { pruned, kept };
|
|
64
|
+
}
|