@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 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": 1,
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tranhoangnguyen0310/pi-flow-external",
3
- "version": "1.1.0-external.2",
3
+ "version": "1.2.0-external.0",
4
4
  "description": "External Claude Code, Codex CLI, and Antigravity delegation for pi.",
5
5
  "type": "module",
6
6
  "main": "./index.ts",
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
- "--dangerously-skip-permissions",
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
- if (closeResult.code !== 0) {
393
- const stderr = stderrBuffer.text().trim();
394
- throw new Error(`agy exited with code ${closeResult.code}${closeResult.signal ? ` (signal ${closeResult.signal})` : ""}${stderr ? `: ${stderr}` : ""}`);
395
- }
409
+ const exitSuffix = closeResult.code !== 0
410
+ ? ` (exit code ${closeResult.code}${closeResult.signal ? `, signal ${closeResult.signal}` : ""})`
411
+ : "";
396
412
  if (!terminalResult) {
397
- throw new Error("agy exited without a terminal result event");
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
- throw new Error(`agy failed with status ${terminalStatus}${terminalResult.error?.trim() ? `: ${terminalResult.error.trim()}` : ""}`);
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 {
@@ -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
- args.push(...effectiveUid === 0
53
- ? ["--permission-mode", "auto"]
54
- : ["--dangerously-skip-permissions"]);
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
- if (sawTerminalEvent) {
388
- protocolError ??= "claude emitted an event after its terminal event";
389
- return;
390
- }
391
- const isTerminal = event.type === "result" || event.type === "error";
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.type === "result" && event.subtype === "success" && event.is_error === false;
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 (isTerminal && !terminalSucceeded) {
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
+ }