pi-orchestrator-config 4.3.11 → 4.3.13

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.
@@ -13,8 +13,7 @@ import { fileURLToPath } from "node:url";
13
13
  const require = createRequire(import.meta.url);
14
14
 
15
15
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
16
- import type { AgentConfig } from "./agents.js";
17
- import { discoverAgents } from "./agents.js";
16
+ import type { AgentConfig, AgentDiscoveryResult } from "./agents.js";
18
17
  import { resolveAgentModelProvider } from "./resolve-agent-model.js";
19
18
  import { getPiInvocation, getProjectTmpDir, parseProcStartTime, djb2Hash } from "./utils.js";
20
19
  import { addReviewerPending, recordReviewerResult, countFindings, readReviewState, markTestsPassed, markTestsFailed } from "./pi-config-review-state.js";
@@ -22,14 +21,17 @@ import {
22
21
  getMainBranch,
23
22
  } from "./git-helpers.js";
24
23
  import { waitForResultFiles } from "./async-wait.js";
25
- import { openAsyncStatusOverlay } from "./async-status-ui.js";
24
+ import { formatAsyncResultOutput, reviewerOutputArchivePath } from "./async-result-format.js";
25
+ import { cleanupReviewerOutputArchives } from "./reviewer-output-archive.js";
26
26
  const log = createLogger("async_agents");
27
27
 
28
28
  export { autoCompleteTask, autoMarkInProgress } from "./task-lifecycle.js";
29
29
  import { autoCompleteTask, autoMarkInProgress } from "./task-lifecycle.js";
30
+ import { openAsyncStatusOverlay } from "./async-status-ui.js";
30
31
  import { setSlot } from "./status-bar.js";
31
32
  import { getSetting } from "./project-settings.js";
32
33
  import { substituteSettingsPlaceholders } from "./rule-placeholders.js";
34
+ import { isLiveExtensionCtx } from "../shared/live-ctx.js";
33
35
 
34
36
  const SETTINGS_KEYS: Record<string, unknown> = JSON.parse(
35
37
  fs.readFileSync(
@@ -117,6 +119,10 @@ export function formatDuration(ms: number): string {
117
119
  export function registerAsyncAgents(
118
120
  pi: ExtensionAPI,
119
121
  terminalNotify: (title: string, body: string) => void,
122
+ runtime: {
123
+ spawnProcess?: typeof spawn;
124
+ discoverAgents?: (cwd: string, scope: "both") => Pick<AgentDiscoveryResult, "agents">;
125
+ } = {},
120
126
  ): {
121
127
  spawnAsyncAgent: (agentName: string, task: string, cwd: string, agents: AgentConfig[], options?: { fireAndForget?: boolean; name?: string; parentModelId?: string; parentProvider?: string; groupId?: string; taskId?: string; onComplete?: () => void; persistSession?: boolean; explicit?: { model?: string; provider?: string } }) => { id: string; error?: string; model?: string };
122
128
  killAsyncAgent: (target: string) => { killed: string[]; errors: string[] };
@@ -125,6 +131,28 @@ export function registerAsyncAgents(
125
131
  let PROJECT_TMP_DIR = path.join(process.cwd(), ".pi", "tmp"); // Computed only; created on session_start
126
132
  let ASYNC_RESULTS_DIR = ""; // Set on session_start to project-scoped dir
127
133
 
134
+ function resultOutputPath(job: Pick<AsyncJob, "agent" | "id" | "workerDir">): string {
135
+ const workerOutputPath = path.join(job.workerDir, "output.log");
136
+ const outputPath = job.agent.startsWith("code-reviewer-")
137
+ ? reviewerOutputArchivePath(PROJECT_TMP_DIR, job.id)
138
+ : workerOutputPath;
139
+ log.debug("async_result_output_path", { agent: job.agent, archived: outputPath !== workerOutputPath });
140
+ return outputPath;
141
+ }
142
+
143
+ function preserveReviewerOutput(job: Pick<AsyncJob, "agent" | "id">, output: string): void {
144
+ if (!job.agent.startsWith("code-reviewer-")) return;
145
+ const outputPath = reviewerOutputArchivePath(PROJECT_TMP_DIR, job.id);
146
+ try {
147
+ fs.mkdirSync(path.dirname(outputPath), { recursive: true, mode: 0o700 });
148
+ fs.writeFileSync(outputPath, output, { mode: 0o600 });
149
+ fs.chmodSync(outputPath, 0o600);
150
+ cleanupReviewerOutputArchives(path.dirname(outputPath));
151
+ log.info("preserved_reviewer_output", { agent: job.agent, bytes: Buffer.byteLength(output, "utf8") });
152
+ } catch (e: any) {
153
+ log.error(`preserve reviewer output failed for ${job.id}: ${e?.message}`);
154
+ }
155
+ }
128
156
 
129
157
  const asyncState: AsyncState = {
130
158
  jobs: new Map(),
@@ -148,10 +176,20 @@ export function registerAsyncAgents(
148
176
  }
149
177
 
150
178
  let lastWidgetKey = "";
179
+ function getLiveAsyncCtx(): any | null {
180
+ const ctx = asyncState.lastCtx;
181
+ if (!isLiveExtensionCtx(ctx)) {
182
+ log.debug("async_context_stale", { hadContext: ctx !== null });
183
+ asyncState.lastCtx = null;
184
+ return null;
185
+ }
186
+ return ctx;
187
+ }
188
+
151
189
  function updateAsyncWidget() {
152
- if (!asyncState.lastCtx?.hasUI) return;
153
190
  try {
154
- const ctx = asyncState.lastCtx;
191
+ const ctx = getLiveAsyncCtx();
192
+ if (!ctx?.hasUI) return;
155
193
  const running = Array.from(asyncState.jobs.values()).filter(j => j.status === "running" || j.status === "queued");
156
194
  const names = running.map(j => j.name || j.agent).join(", ");
157
195
  const widgetKey = `${running.length}:${names}`;
@@ -181,10 +219,15 @@ export function registerAsyncAgents(
181
219
  function ensureAsyncPoller() {
182
220
  if (asyncState.poller) return;
183
221
  asyncState.poller = setInterval(() => {
222
+ const liveCtx = getLiveAsyncCtx();
223
+ if (!liveCtx) {
224
+ // ctx is stale (session ended) — stop polling
225
+ if (asyncState.poller) { clearInterval(asyncState.poller); asyncState.poller = null; }
226
+ return;
227
+ }
184
228
  try {
185
- if (!asyncState.lastCtx?.hasUI) return;
229
+ if (!liveCtx.hasUI) return;
186
230
  } catch {
187
- // ctx is stale (session ended) — stop polling
188
231
  if (asyncState.poller) { clearInterval(asyncState.poller); asyncState.poller = null; }
189
232
  return;
190
233
  }
@@ -375,7 +418,7 @@ export function registerAsyncAgents(
375
418
  deliverGroupResults(groupJobs);
376
419
  }
377
420
  // Delivery: retry for non-grouped jobs (zombie-ingest path may skip delivery)
378
- } else if (asyncState.lastCtx && !job.fireAndForget) {
421
+ } else if (getLiveAsyncCtx() && !job.fireAndForget) {
379
422
  const displayName = job.name || job.agent;
380
423
  const resultStatus = job.status === "complete" ? "✅ completed" : "❌ failed";
381
424
  const duration = job.durationMs || (job.updatedAt ? job.updatedAt - job.startedAt : 0);
@@ -392,9 +435,14 @@ export function registerAsyncAgents(
392
435
  log.error(`reconcile: auto-complete failed for task #${safeTaskId}: ${e?.message}`);
393
436
  }
394
437
  }
395
- // Enforce same output-length budget as processResultFile
396
- const maxOutput = 3000 - autoCompleteError.length;
397
- const output = (job.output || "").slice(0, Math.max(maxOutput, 500));
438
+ // Enforce same output-length budget as processResultFile.
439
+ const maxOutput = Math.max(3000 - autoCompleteError.length, 500);
440
+ const output = formatAsyncResultOutput(
441
+ job.agent,
442
+ job.output || "",
443
+ resultOutputPath(job),
444
+ maxOutput,
445
+ );
398
446
  // Emit lifecycle events
399
447
  if (job.status === "complete") {
400
448
  pi.events.emit("subagents:completed", { id: job.id, result: job.output || "" });
@@ -406,7 +454,7 @@ export function registerAsyncAgents(
406
454
  job.delivered = true;
407
455
  log.debug(`reconcile: skipping already-delivered result for ${job.id}`);
408
456
  } else {
409
- log.debug("reconcile_deliver", job.id, "hasCtx", !!asyncState.lastCtx);
457
+ log.debug("reconcile_deliver", job.id, "hasCtx", true);
410
458
  try {
411
459
  pi.sendMessage({
412
460
  customType: "async-agent-result",
@@ -463,8 +511,6 @@ export function registerAsyncAgents(
463
511
  if (gid && groupDeliveryInProgress.has(gid)) return;
464
512
  if (gid) groupDeliveryInProgress.add(gid);
465
513
  try {
466
- if (!asyncState.lastCtx) return;
467
-
468
514
  // Ingest any unprocessed result files — zombie/kill paths may trigger delivery
469
515
  // before processResultFile() has read all group members' outputs.
470
516
  // Wait up to 2s total (not per-job) for all missing result files.
@@ -481,6 +527,7 @@ export function registerAsyncAgents(
481
527
  try {
482
528
  const data = JSON.parse(fs.readFileSync(rp, "utf-8"));
483
529
  j.output = data.output;
530
+ preserveReviewerOutput(j, typeof data.output === "string" ? data.output : JSON.stringify(data.output ?? ""));
484
531
  j.exitCode = data.exitCode;
485
532
  j.durationMs = data.durationMs;
486
533
  if (data.success !== undefined) j.status = data.success ? "complete" : "failed";
@@ -549,7 +596,11 @@ export function registerAsyncAgents(
549
596
  if (j.fireAndForget) { j.delivered = true; continue; }
550
597
  const resultStatus = j.status === "complete" ? "✅ completed" : "❌ failed";
551
598
  const displayName = j.name || j.agent;
552
- const output = (j.output || "").slice(0, 3000);
599
+ const output = formatAsyncResultOutput(
600
+ j.agent,
601
+ j.output || "",
602
+ resultOutputPath(j),
603
+ );
553
604
  let autoCompleteError = "";
554
605
  // Auto-complete linked task directly in the store file (no AI involvement)
555
606
  if (j.taskId && j.taskId !== "-1" && j.status === "complete" && j.cwd) {
@@ -567,6 +618,12 @@ export function registerAsyncAgents(
567
618
  }
568
619
 
569
620
  if (sections.length > 0) {
621
+ // A group may have waited for result files while its session was replaced.
622
+ // Keep persisted/in-memory results for reconciliation rather than sending into it.
623
+ if (!getLiveAsyncCtx()) {
624
+ log.debug("deliver_group_results_deferred", { groupId: groupJobs[0]?.groupId, jobs: groupJobs.length, deliverableJobs: deliverableJobs.length, status: groupJobs[0]?.status });
625
+ return;
626
+ }
570
627
  try {
571
628
  pi.sendMessage({
572
629
  customType: "async-agent-result",
@@ -613,7 +670,7 @@ export function registerAsyncAgents(
613
670
  try {
614
671
  const data = JSON.parse(fs.readFileSync(resultPath, "utf-8"));
615
672
  const job = asyncState.jobs.get(data.id);
616
- log.debug(`processResultFile: ${path.basename(resultPath)} job=${!!job} delivered=${job?.delivered} hasCtx=${!!asyncState.lastCtx} fireAndForget=${job?.fireAndForget} groupId=${job?.groupId}`);
673
+ log.debug(`processResultFile: ${path.basename(resultPath)} job=${!!job} delivered=${job?.delivered} hasCtx=${!!getLiveAsyncCtx()} fireAndForget=${job?.fireAndForget} groupId=${job?.groupId}`);
617
674
  if (!job) {
618
675
  // Orphan result file — job not in current session. Clean up to prevent re-delivery on reload.
619
676
  try { fs.unlinkSync(resultPath); } catch (e: any) { log.debug(`orphan cleanup failed: ${resultPath}: ${e?.message}`); }
@@ -639,6 +696,7 @@ export function registerAsyncAgents(
639
696
  job.exitCode = data.exitCode;
640
697
  job.durationMs = data.durationMs;
641
698
  job.updatedAt = Date.now();
699
+ preserveReviewerOutput(job, typeof data.output === "string" ? data.output : JSON.stringify(data.output ?? ""));
642
700
 
643
701
  // Notify terminal per-agent (lightweight, non-conversational)
644
702
  const displayName = job.name || data.agent;
@@ -657,11 +715,11 @@ export function registerAsyncAgents(
657
715
  // Use model context window from the agent's effective model.
658
716
  // If agent uses a custom model, we pass its context window via the config.
659
717
  // Fall back to orchestrator's model context window.
660
- const contextWindow = data.contextWindow || asyncState.lastCtx?.model?.contextWindow || 0;
718
+ const contextWindow = data.contextWindow || getLiveAsyncCtx()?.model?.contextWindow || 0;
661
719
  if (data.lastUsage?.totalTokens && data.lastUsage.totalTokens > 0 && contextWindow > 0) {
662
720
  const pct = (data.lastUsage.totalTokens / contextWindow) * 100;
663
721
  if (pct > 80) {
664
- const sessId = `async-${job.agent}-${djb2Hash(job.agent + ':' + (jobCwd(job)) + ':' + (asyncState.lastCtx?.sessionManager?.getSessionId?.() || '')).toString(36)}`;
722
+ const sessId = `async-${job.agent}-${djb2Hash(job.agent + ':' + (jobCwd(job)) + ':' + (getLiveAsyncCtx()?.sessionManager?.getSessionId?.() || '')).toString(36)}`;
665
723
  try {
666
724
  // Find and delete the session file to force fresh on next cycle
667
725
  const sessDir = path.join(os.homedir(), '.pi', 'agent', 'sessions');
@@ -703,7 +761,11 @@ export function registerAsyncAgents(
703
761
  try {
704
762
  const statusPath = path.join(job.workerDir, "status.json");
705
763
  const existing = JSON.parse(fs.readFileSync(statusPath, "utf-8"));
706
- existing.output = (data.output || "").slice(0, 3000);
764
+ existing.output = formatAsyncResultOutput(
765
+ job.agent,
766
+ data.output || "",
767
+ resultOutputPath(job),
768
+ );
707
769
  existing.state = job.status;
708
770
  existing.exitCode = job.exitCode;
709
771
  existing.durationMs = job.durationMs;
@@ -735,7 +797,7 @@ export function registerAsyncAgents(
735
797
 
736
798
  // Non-grouped job: deliver immediately (existing behavior)
737
799
  let sendSucceeded = false;
738
- if (asyncState.lastCtx && !job.fireAndForget) {
800
+ if (getLiveAsyncCtx() && !job.fireAndForget) {
739
801
  const resultStatus = data.success ? "✅ completed" : "❌ failed";
740
802
  let autoCompleteError = "";
741
803
  // Auto-complete linked task directly in the store file (no AI involvement)
@@ -748,8 +810,13 @@ export function registerAsyncAgents(
748
810
  log.error(`auto-complete failed for task #${job.taskId}: ${e?.message}`);
749
811
  }
750
812
  }
751
- const maxOutput = 3000 - autoCompleteError.length;
752
- const output = (data.output || "").slice(0, Math.max(maxOutput, 500));
813
+ const maxOutput = Math.max(3000 - autoCompleteError.length, 500);
814
+ const output = formatAsyncResultOutput(
815
+ job.agent,
816
+ data.output || "",
817
+ resultOutputPath(job),
818
+ maxOutput,
819
+ );
753
820
  const pContent = `## Async Agent Result: ${displayName} ${resultStatus}\n\nTask: ${data.task}\nDuration: ${formatDuration(data.durationMs)}\n\n${output}${autoCompleteError}`;
754
821
  if (wasAlreadyDelivered(job.id)) {
755
822
  // Already delivered in previous lifecycle — skip send AND onComplete
@@ -806,16 +873,17 @@ export function registerAsyncAgents(
806
873
  watchedDir = ASYNC_RESULTS_DIR;
807
874
  try {
808
875
  fs.mkdirSync(ASYNC_RESULTS_DIR, { recursive: true, mode: 0o700 });
809
- asyncState.watcher = fs.watch(ASYNC_RESULTS_DIR, (ev, file) => {
876
+ const resultsDir = ASYNC_RESULTS_DIR;
877
+ asyncState.watcher = fs.watch(resultsDir, (ev, file) => {
810
878
  if (ev !== "rename" || !file) return;
811
879
  const fileName = file.toString();
812
880
  log.debug("watcher_event", ev, fileName);
813
881
  if (!fileName.endsWith(".json")) return;
814
- const resultPath = path.join(ASYNC_RESULTS_DIR, fileName);
882
+ const resultPath = path.join(resultsDir, fileName);
815
883
  setTimeout(() => processResultFile(resultPath), 100);
816
884
  });
817
885
  if (asyncState.watcher.unref) asyncState.watcher.unref();
818
- } catch (e: any) { console.debug("[async-agents] watcher setup failed:", e?.message || e); }
886
+ } catch (e: any) { log.error(`watcher setup failed: ${e?.message || e}`); }
819
887
  }
820
888
 
821
889
  function spawnAsyncAgent(
@@ -844,7 +912,7 @@ export function registerAsyncAgents(
844
912
  } catch { /* retry */ }
845
913
  }
846
914
  if (!parentStartTime) {
847
- console.debug("[async-agents] WARNING: could not read /proc/self/stat starttime after 3 attempts");
915
+ log.warn("async-spawn: could not read /proc/self/stat starttime after 3 attempts");
848
916
  }
849
917
  const { model: effectiveModel, provider: effectiveProvider } = resolveAgentModelProvider(agentName, agent, options?.parentModelId, options?.parentProvider, cwd, options?.explicit);
850
918
  fs.writeFileSync(path.join(workerDir, "session.json"), JSON.stringify({
@@ -854,8 +922,8 @@ export function registerAsyncAgents(
854
922
  parentStartTime,
855
923
  taskId: options?.taskId || null,
856
924
  cwd,
857
- projectCwd: asyncState.lastCtx?.sessionManager?.getCwd?.() || null,
858
- sessionId: asyncState.lastCtx?.sessionManager?.getSessionId?.() || null,
925
+ projectCwd: getLiveAsyncCtx()?.sessionManager?.getCwd?.() || null,
926
+ sessionId: getLiveAsyncCtx()?.sessionManager?.getSessionId?.() || null,
859
927
  }), { mode: 0o600 });
860
928
 
861
929
  // Track reviewer spawn BEFORE building args — addReviewerPending sets status to
@@ -877,7 +945,7 @@ export function registerAsyncAgents(
877
945
  // Deterministic session ID for provider cache affinity + optional session reuse.
878
946
  // When persistSession is true, omits --no-session so the session persists to disk.
879
947
  // Same agent + cwd gets the same session ID across calls.
880
- const parentSessionId = asyncState.lastCtx?.sessionManager?.getSessionId?.() || '';
948
+ const parentSessionId = getLiveAsyncCtx()?.sessionManager?.getSessionId?.() || '';
881
949
  const sessionIdSource = persistSession
882
950
  ? agentName + ':' + cwd + ':' + parentSessionId
883
951
  : agentName + ':' + task.slice(0, 100);
@@ -910,8 +978,11 @@ export function registerAsyncAgents(
910
978
  task,
911
979
  cwd,
912
980
  model: effectiveModel,
913
- contextWindow: asyncState.lastCtx?.model?.contextWindow || 0,
981
+ contextWindow: getLiveAsyncCtx()?.model?.contextWindow || 0,
914
982
  resultPath,
983
+ reviewerOutputPath: agentName.startsWith("code-reviewer-")
984
+ ? reviewerOutputArchivePath(PROJECT_TMP_DIR, id)
985
+ : undefined,
915
986
  workerDir,
916
987
  sessionId: `${process.pid}:${process.cwd()}`,
917
988
  piCommand: inv.command,
@@ -982,7 +1053,7 @@ export function registerAsyncAgents(
982
1053
 
983
1054
  // Fail fast with a self-explanatory error instead of spawning a child that
984
1055
  // cannot load TypeScript (bare node dies on ".js"→".ts" specifiers).
985
- if (!jitiCliPath) {
1056
+ if (!jitiCliPath && !runtime.spawnProcess) {
986
1057
  log.error(`async-spawn: ${id} — no jiti-cli.mjs found. Probes:\n ${(jitiProbes.slice(-14)).join("\n ")}`);
987
1058
  // Clean up the artifacts we already created — nothing will consume them.
988
1059
  try { fs.rmSync(configPath, { force: true }); } catch {}
@@ -1045,8 +1116,8 @@ export function registerAsyncAgents(
1045
1116
  groupId: options?.groupId,
1046
1117
  taskId: options?.taskId,
1047
1118
  cwd,
1048
- projectCwd: asyncState.lastCtx?.sessionManager?.getCwd?.(),
1049
- sessionId: asyncState.lastCtx?.sessionManager?.getSessionId?.(),
1119
+ projectCwd: getLiveAsyncCtx()?.sessionManager?.getCwd?.(),
1120
+ sessionId: getLiveAsyncCtx()?.sessionManager?.getSessionId?.(),
1050
1121
  model: effectiveModel,
1051
1122
  };
1052
1123
  asyncState.jobs.set(id, job);
@@ -1056,7 +1127,7 @@ export function registerAsyncAgents(
1056
1127
  stderrLog.on("error", () => {}); // never let a log write failure crash the session
1057
1128
  log.debug(`async-spawn: ${job.id} stderr → ${stderrLogPath}`);
1058
1129
 
1059
- const proc = spawn(process.execPath, spawnArgs, {
1130
+ const proc = (runtime.spawnProcess ?? spawn)(process.execPath, spawnArgs, {
1060
1131
  cwd,
1061
1132
  stdio: ["ignore", "ignore", "pipe"],
1062
1133
  windowsHide: true,
@@ -1136,7 +1207,7 @@ export function registerAsyncAgents(
1136
1207
 
1137
1208
  // Auto-mark linked task as in_progress AFTER successful spawn
1138
1209
  if (options?.taskId && options.taskId !== "-1") {
1139
- autoMarkInProgress(options.taskId, asyncState.lastCtx?.sessionManager?.getCwd?.() || cwd, asyncState.lastCtx?.sessionManager?.getSessionId?.() || undefined)
1210
+ autoMarkInProgress(options.taskId, getLiveAsyncCtx()?.sessionManager?.getCwd?.() || cwd, getLiveAsyncCtx()?.sessionManager?.getSessionId?.() || undefined)
1140
1211
  .catch(() => {}); // best-effort, don't block spawn
1141
1212
  }
1142
1213
 
@@ -1145,6 +1216,10 @@ export function registerAsyncAgents(
1145
1216
 
1146
1217
  // Start result watcher on session start
1147
1218
  pi.on("session_start", (_event, ctx) => {
1219
+ if (!isLiveExtensionCtx(ctx)) {
1220
+ log.warn("session_start received a stale ctx; preserving async results for a live session");
1221
+ return;
1222
+ }
1148
1223
  asyncState.lastCtx = ctx;
1149
1224
  deliveredResultIds.clear();
1150
1225
 
@@ -1153,6 +1228,8 @@ export function registerAsyncAgents(
1153
1228
  // Export as env var so prompts/CLI commands can reference it
1154
1229
  process.env.PROJECT_TMP_DIR = PROJECT_TMP_DIR;
1155
1230
 
1231
+ cleanupReviewerOutputArchives(path.join(PROJECT_TMP_DIR, "reviewer-results"));
1232
+
1156
1233
  // Scan worker directories for jobs with delivered=true in status.json
1157
1234
  // This is more reliable than content hashing — uses job IDs directly
1158
1235
  try {
@@ -1205,7 +1282,7 @@ export function registerAsyncAgents(
1205
1282
  try { fs.rmSync(jobDir, { recursive: true, force: true }); } catch {}
1206
1283
  } catch {} // skip unreadable dirs
1207
1284
  }
1208
- } catch (e: any) { console.debug("[async-agents] zombie cleanup failed:", e?.message?.slice(0, 100)); }
1285
+ } catch (e: any) { log.error(`zombie cleanup failed: ${e?.message?.slice(0, 100)}`); }
1209
1286
 
1210
1287
  // Clean up stale result directories from dead sessions
1211
1288
  try {
@@ -1296,13 +1373,13 @@ export function registerAsyncAgents(
1296
1373
  log.info(`restored job: ${id} state=${job.status}`);
1297
1374
  }
1298
1375
  }
1299
- } catch (e: any) { console.debug("[async-agents] job restore failed:", e?.message || e); }
1376
+ } catch (e: any) { log.error(`job restore failed: ${e?.message || e}`); }
1300
1377
 
1301
1378
  // Start poller if we have jobs or unprocessed result files
1302
1379
  let hasResultFiles = false;
1303
1380
  try {
1304
1381
  hasResultFiles = fs.existsSync(ASYNC_RESULTS_DIR) && fs.readdirSync(ASYNC_RESULTS_DIR).some(f => f.endsWith(".json"));
1305
- } catch (e: any) { console.debug("[async-agents] result files check failed:", e?.message || e); }
1382
+ } catch (e: any) { log.error(`result files check failed: ${e?.message || e}`); }
1306
1383
  if (asyncState.jobs.size > 0 || hasResultFiles) {
1307
1384
  ensureAsyncPoller();
1308
1385
  }
@@ -1314,6 +1391,7 @@ export function registerAsyncAgents(
1314
1391
  // Clean up on shutdown
1315
1392
  pi.on("session_shutdown", () => {
1316
1393
  log.info(`shutdown: jobs=${asyncState.jobs.size}`);
1394
+ asyncState.lastCtx = null;
1317
1395
  if (asyncState.poller) { clearInterval(asyncState.poller); asyncState.poller = null; }
1318
1396
  if (asyncState.watcher) { asyncState.watcher.close(); asyncState.watcher = null; }
1319
1397
  });
@@ -1321,6 +1399,7 @@ export function registerAsyncAgents(
1321
1399
  // /async-status — fullscreen overlay list → live output detail
1322
1400
  async function handleAsyncStatus(ctx: any): Promise<void> {
1323
1401
  if (!ctx.hasUI) return;
1402
+ log.debug("opening_async_status_overlay", { jobs: asyncState.jobs.size });
1324
1403
  await openAsyncStatusOverlay(ctx, {
1325
1404
  listJobs: () => Array.from(asyncState.jobs.values()),
1326
1405
  killJob: (id) => {
@@ -1408,12 +1487,16 @@ export function registerAsyncAgents(
1408
1487
  if (pending.length === 0) {
1409
1488
  deliverGroupResults(groupJobs);
1410
1489
  }
1411
- } else if (asyncState.lastCtx && !job.fireAndForget) {
1490
+ } else if (getLiveAsyncCtx() && !job.fireAndForget) {
1412
1491
  // Non-grouped killed job — deliver immediately so AI knows it was killed
1413
1492
  const displayName = job.name || job.agent;
1414
1493
  const duration = job.durationMs || (Date.now() - job.startedAt);
1415
1494
  const rawOutput = typeof job.output === "string" ? job.output : "Killed by user";
1416
- const output = rawOutput.slice(0, 3000);
1495
+ const output = formatAsyncResultOutput(
1496
+ job.agent,
1497
+ rawOutput,
1498
+ resultOutputPath(job),
1499
+ );
1417
1500
  const killContent = `## Async Agent Result: ${displayName} ❌ failed\n\nTask: ${job.task}\nDuration: ${formatDuration(duration)}\n\n${output}`;
1418
1501
  if (wasAlreadyDelivered(job.id)) {
1419
1502
  job.delivered = true;
@@ -1524,13 +1607,15 @@ export function registerAsyncAgents(
1524
1607
 
1525
1608
  // Spawn — create an async agent from pitasks' TaskExecute
1526
1609
  handleRpc<{ requestId: string; type: string; prompt: string; options?: any }>(
1527
- "subagents:rpc:spawn", ({ type, prompt, options }) => {
1528
- const ctx = asyncState.lastCtx;
1610
+ "subagents:rpc:spawn", async ({ type, prompt, options }) => {
1611
+ const ctx = getLiveAsyncCtx();
1529
1612
  if (!ctx) throw new Error("No active session");
1530
1613
  if (!type || typeof type !== "string") throw new Error("Missing or invalid 'type' parameter");
1531
1614
  if (!prompt || typeof prompt !== "string") throw new Error("Missing or invalid 'prompt' parameter");
1532
1615
 
1533
1616
  const cwd = options?.cwd || ctx.cwd;
1617
+ log.debug("rpc_spawn_requested", { type, customCwd: Boolean(options?.cwd) });
1618
+ const discoverAgents = runtime.discoverAgents ?? (await import("./agents.js")).discoverAgents;
1534
1619
  const discovery = discoverAgents(cwd, "both");
1535
1620
  const agents = discovery.agents;
1536
1621
 
@@ -0,0 +1,58 @@
1
+ /** Format async-agent output for bounded delivery and status persistence. */
2
+
3
+ import { createHash } from "node:crypto";
4
+ import * as path from "node:path";
5
+ import { createLogger } from "../shared/logger.js";
6
+
7
+ const log = createLogger("async_agents");
8
+ const MAX_OUTPUT_CHARS = 3000;
9
+
10
+ /** Return the completed-result archive path, outside cleanup-prone worker directories. */
11
+ export function reviewerOutputArchivePath(projectTmpDir: string, jobId: string): string {
12
+ const archiveDir = path.resolve(projectTmpDir, "reviewer-results");
13
+ const filename = `${createHash("sha256").update(jobId).digest("hex")}.json`;
14
+ const archivePath = path.resolve(archiveDir, filename);
15
+ if (path.dirname(archivePath) !== archiveDir) {
16
+ throw new Error("Reviewer archive destination escapes its archive root");
17
+ }
18
+ log.debug("reviewer_output_archive_path", { jobId, archivePath });
19
+ return archivePath;
20
+ }
21
+
22
+ /**
23
+ * Preserve valid reviewer JSON unless it exceeds the delivery budget.
24
+ * Oversized reviewer output is available only in its worker output.log.
25
+ */
26
+ export function formatAsyncResultOutput(
27
+ agent: string,
28
+ output: string,
29
+ outputPath: string,
30
+ maxChars = MAX_OUTPUT_CHARS,
31
+ ): string {
32
+ log.debug("format_async_result_output", { agent, outputChars: output.length, maxChars });
33
+ if (agent.startsWith("code-reviewer-") && output.length > maxChars) {
34
+ try {
35
+ const parsed = JSON.parse(output);
36
+ if (parsed && typeof parsed === "object" && Array.isArray(parsed.findings)) {
37
+ const metadata = {
38
+ truncated: true,
39
+ format: "json",
40
+ outputBytes: Buffer.byteLength(output, "utf8"),
41
+ outputPath,
42
+ };
43
+ const serialized = JSON.stringify(metadata);
44
+ if (serialized.length <= maxChars) return serialized;
45
+ log.warn("reviewer_metadata_path_exceeds_budget", { agent, maxChars });
46
+ return JSON.stringify({
47
+ truncated: true,
48
+ format: "json",
49
+ outputBytes: metadata.outputBytes,
50
+ outputPathOmitted: true,
51
+ });
52
+ }
53
+ } catch {
54
+ // Invalid reviewer output retains ordinary bounded-text behavior.
55
+ }
56
+ }
57
+ return output.slice(0, maxChars);
58
+ }
@@ -9,6 +9,10 @@ import * as fs from "node:fs";
9
9
  import * as path from "node:path";
10
10
  import { createRequire } from "node:module";
11
11
  import { getSetting } from "./project-settings.js";
12
+ import { createLogger } from "../shared/logger.js";
13
+ import { archiveReviewerOutput } from "./reviewer-output-archive.js";
14
+
15
+ const log = createLogger("async_agents");
12
16
 
13
17
  interface RunConfig {
14
18
  id: string;
@@ -20,6 +24,7 @@ interface RunConfig {
20
24
  tools?: string[];
21
25
  systemPrompt?: string;
22
26
  resultPath: string;
27
+ reviewerOutputPath?: string;
23
28
  workerDir: string;
24
29
  sessionId?: string;
25
30
  piCommand: string;
@@ -72,7 +77,7 @@ async function run(config: RunConfig): Promise<void> {
72
77
  writeJson(statusPath, status);
73
78
  let lastUsage: any = null;
74
79
 
75
- const outputStream = fs.createWriteStream(outputPath, { flags: "w" });
80
+ const outputStream = fs.createWriteStream(outputPath, { flags: "w", mode: 0o600 });
76
81
 
77
82
  // Connect to pidash server to stream events
78
83
  const pidashPort = getSetting(config.cwd, "pidash_port");
@@ -157,7 +162,7 @@ async function run(config: RunConfig): Promise<void> {
157
162
  if (pidashWs?.readyState === 1) {
158
163
  pidashWs.send(JSON.stringify({ type: "async_event", id: config.id, event: ev }));
159
164
  }
160
- } catch (e: any) { console.debug("[async-runner] event processing failed:", e?.message || e); }
165
+ } catch (e: any) { log.warn(`event processing skipped: ${e?.message || e}`); }
161
166
  }
162
167
 
163
168
  // Update status periodically (every ~10 lines)
@@ -193,7 +198,7 @@ async function run(config: RunConfig): Promise<void> {
193
198
  if (p.type === "text") finalOutput = p.text;
194
199
  }
195
200
  }
196
- } catch (e: any) { console.debug("[async-runner] final line parse failed:", e?.message || e); }
201
+ } catch (e: any) { log.error(`final line parse failed: ${e?.message || e}`); }
197
202
  }
198
203
 
199
204
  // Write final status
@@ -283,7 +288,18 @@ async function run(config: RunConfig): Promise<void> {
283
288
  }
284
289
 
285
290
  // End the output stream after all retries are done
286
- outputStream.end();
291
+ await new Promise<void>((resolve, reject) => {
292
+ outputStream.once("error", reject);
293
+ outputStream.end(resolve);
294
+ });
295
+ if (config.reviewerOutputPath && config.agent.startsWith("code-reviewer-")) {
296
+ try {
297
+ archiveReviewerOutput(outputPath, config.reviewerOutputPath);
298
+ log.info("archived_reviewer_output", { agent: config.agent, outputPath: config.reviewerOutputPath });
299
+ } catch (err) {
300
+ log.error("archive_reviewer_output_failed", err);
301
+ }
302
+ }
287
303
 
288
304
  // Write result for the watcher to pick up
289
305
  writeJson(config.resultPath, {
@@ -3,10 +3,10 @@
3
3
  */
4
4
 
5
5
  export interface CronStatusTaskView {
6
- /** Unique overlay row id (e.g. "1" or "cron-123-abc.json:1"). */
6
+ /** Unique scope-qualified overlay row id (e.g. "session:<uuid>"). */
7
7
  id: string;
8
- /** Numeric cron task id within its session file. */
9
- taskId: number;
8
+ /** Stable UUID within its session or project scope. */
9
+ taskId: string | number;
10
10
  description: string;
11
11
  task: string;
12
12
  intervalMs?: number;
@@ -15,7 +15,7 @@ export interface CronStatusTaskView {
15
15
  createdAt: number;
16
16
  lastRun?: number;
17
17
  nextRun?: number;
18
- /** Shown in list-all rows (e.g. "this session", "PID 42"). */
18
+ /** Optional scope and leader-state label shown in overlay rows. */
19
19
  sessionLabel?: string;
20
20
  isLocal?: boolean;
21
21
  cronFile?: string;
@@ -91,7 +91,7 @@ export function formatNextRunLabel(
91
91
  /** Map a CronTask-like object into an overlay row view. */
92
92
  export function toCronStatusTaskView(
93
93
  task: {
94
- id: number;
94
+ id: string | number;
95
95
  description: string;
96
96
  task: string;
97
97
  intervalMs?: number;
@@ -110,7 +110,7 @@ export function toCronStatusTaskView(
110
110
  ): CronStatusTaskView {
111
111
  return {
112
112
  id: opts.overlayId,
113
- taskId: task.id,
113
+ taskId: opts.overlayId,
114
114
  description: task.description,
115
115
  task: task.task,
116
116
  intervalMs: task.intervalMs,
@@ -1,5 +1,5 @@
1
1
  /**
2
- * /cron list + list-all fullscreen overlay — list dashboard → task detail.
2
+ * /cron list fullscreen overlay for session and project tasks — list dashboard → task detail.
3
3
  */
4
4
 
5
5
  import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
@@ -25,7 +25,7 @@ export {
25
25
 
26
26
  export interface CronStatusUiDeps {
27
27
  listTasks: () => CronStatusTaskView[];
28
- /** Remove by overlay row id. Return true if removed. */
28
+ /** Remove by scope-qualified task id. Return true if removed. */
29
29
  removeTask: (id: string) => boolean;
30
30
  title?: string;
31
31
  emptyMessage?: string;
@@ -33,7 +33,7 @@ export interface CronStatusUiDeps {
33
33
  }
34
34
 
35
35
  /**
36
- * Open fullscreen cron list. Loops list → detail until user closes list.
36
+ * Open fullscreen cron list for the selected scope. Loops list → detail until closed.
37
37
  */
38
38
  export async function openCronStatusOverlay(
39
39
  ctx: ExtensionCommandContext,