pi-better-harness 0.40.0 → 0.40.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-background-tasks",
3
- "version": "0.14.0",
3
+ "version": "0.14.1",
4
4
  "description": "Pi extension for durable background shell tasks, watchers, logs, and status inspection.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -101,8 +101,12 @@ const provider: BackgroundWorkProvider = {
101
101
  },
102
102
  onVisibleChanged: (notify) => {
103
103
  const offMeta = onMetaChanged(notify);
104
- const offDurable = getObservedWaitHost(piRef)?.onDurableTaskChange(notify);
104
+ const offDurable = getObservedWaitHost(piRef)?.onDurableTaskChange(() => {
105
+ invalidateDurableProjection();
106
+ notify();
107
+ });
105
108
  return () => {
109
+ invalidateDurableProjection();
106
110
  offMeta();
107
111
  offDurable?.();
108
112
  };
@@ -113,13 +117,30 @@ function navigatorHost() {
113
117
  return piRef ? getObservedWaitHost(piRef) : undefined;
114
118
  }
115
119
 
120
+ /**
121
+ * One refresh asks for {@link provider.visibleCount} and then {@link provider.listRows},
122
+ * and a detail view asks again. Rebuilding the projection re-reads every durable
123
+ * record, so a single paint would pay for the same list up to three times. Hold
124
+ * one result per paint window; a durable-change notification clears it.
125
+ */
126
+ const PROJECTION_MEMO_MS = 250;
127
+ let projectionMemo: { at: number; tasks: DurableTaskProjection[] } | undefined;
128
+
129
+ function invalidateDurableProjection(): void {
130
+ projectionMemo = undefined;
131
+ }
132
+
116
133
  function visibleDurableTasks(now = Date.now()): DurableTaskProjection[] {
117
134
  const active = activeNavigatorOrigin;
118
135
  const host = navigatorHost();
119
136
  if (!active || !host) return [];
137
+ if (projectionMemo && now - projectionMemo.at < PROJECTION_MEMO_MS) {
138
+ return projectionMemo.tasks.filter((task) => !isExpiredDurableRow(task, now));
139
+ }
120
140
  const listed = host.listDurableTasks(active, { retentionMs: TERMINAL_NAVIGATOR_RETENTION_MS });
121
- return listed
122
- .filter((task) => task.dismissedAt === undefined && !isExpiredDurableRow(task, now));
141
+ const tasks = listed.filter((task) => task.dismissedAt === undefined);
142
+ projectionMemo = { at: now, tasks };
143
+ return tasks.filter((task) => !isExpiredDurableRow(task, now));
123
144
  }
124
145
 
125
146
  function durableTask(id: string): DurableTaskProjection | undefined {
@@ -1366,8 +1366,10 @@ export class ObservedWaitHost {
1366
1366
 
1367
1367
  private projectDurable(operation: OperationRecord, now: number): DurableTaskProjection | undefined {
1368
1368
  const context = this.launchContext(operation.taskId);
1369
+ // Projection is the rendering path: a snapshot reads the durable records
1370
+ // without taking the cross-process fence or spawning `ps`.
1369
1371
  const inspection = LOCAL_DURABLE_ID.test(operation.taskId)
1370
- ? (() => { try { return this.supervisor.inspect(operation.taskId, { recordLost: false }); } catch { return undefined; } })()
1372
+ ? (() => { try { return this.supervisor.snapshot(operation.taskId); } catch { return undefined; } })()
1371
1373
  : undefined;
1372
1374
  const storeStatus = operation.terminalRevision > 0 ? this.statusOf(operation.operationId) : undefined;
1373
1375
  const status: SupervisorTaskStatus = inspection?.terminal ? inspection.status : storeStatus ?? inspection?.status ?? "running";
@@ -1,41 +1,118 @@
1
1
  import { execFileSync } from "node:child_process";
2
2
 
3
- const selfStartToken = readProcessStartToken(process.pid);
3
+ /**
4
+ * Process start-token reads.
5
+ *
6
+ * `ps` is a process spawn. Reading it on a rendering path — once per task, per
7
+ * frame, for every terminal task kept in the panel — is a measurable CPU drain
8
+ * on its own, and it competes with the very commands the panel is showing.
9
+ *
10
+ * Two rules keep that bounded:
11
+ *
12
+ * 1. A pid's start token is immutable for the life of that pid, so a short-lived
13
+ * memo is safe. Entries expire so a recycled pid is re-read rather than
14
+ * mistaking the previous owner's token for the current one, and failures are
15
+ * memoized too, so a pid that cannot be read is not retried on every frame.
16
+ * 2. Callers that decide to *signal* a pid can ask for a fresh read. Liveness
17
+ * display may use the memo; authorization may not.
18
+ */
4
19
 
5
- export function currentProcessStartToken(): string | undefined {
6
- return selfStartToken;
20
+ /** How long one pid→token answer is trusted. Short enough to bound pid reuse. */
21
+ const TOKEN_MEMO_TTL_MS = 1_000;
22
+ /** Bound on memo entries; the panel is small, but a long session is not. */
23
+ const TOKEN_MEMO_MAX_ENTRIES = 512;
24
+
25
+ interface TokenMemoEntry {
26
+ token: string | undefined;
27
+ readAt: number;
28
+ }
29
+
30
+ const tokenMemo = new Map<number, TokenMemoEntry>();
31
+
32
+ function readTokenUncached(pid: number): string | undefined {
33
+ if (!Number.isInteger(pid) || pid <= 0 || process.platform === "win32") return undefined;
34
+ try {
35
+ const output = execFileSync("ps", ["-o", "lstart=", "-p", String(pid)], {
36
+ encoding: "utf8",
37
+ stdio: ["ignore", "pipe", "ignore"],
38
+ timeout: 2_000,
39
+ }).trim();
40
+ return output || undefined;
41
+ } catch {
42
+ return undefined;
43
+ }
44
+ }
45
+
46
+ function remember(pid: number, token: string | undefined, now: number): void {
47
+ if (tokenMemo.size >= TOKEN_MEMO_MAX_ENTRIES) {
48
+ // Drop expired entries first; only evict live ones if that was not enough.
49
+ for (const [candidate, entry] of tokenMemo) {
50
+ if (now - entry.readAt >= TOKEN_MEMO_TTL_MS) tokenMemo.delete(candidate);
51
+ }
52
+ if (tokenMemo.size >= TOKEN_MEMO_MAX_ENTRIES) {
53
+ const oldest = tokenMemo.keys().next();
54
+ if (!oldest.done) tokenMemo.delete(oldest.value);
55
+ }
56
+ }
57
+ tokenMemo.set(pid, { token, readAt: now });
7
58
  }
8
59
 
60
+ /**
61
+ * Start token for `pid`, memoized for {@link TOKEN_MEMO_TTL_MS}.
62
+ *
63
+ * Suitable for liveness and display. A caller that is about to send a signal
64
+ * must use {@link readProcessStartTokenFresh} instead.
65
+ */
9
66
  export function readProcessStartToken(pid: number): string | undefined {
10
- if (!Number.isInteger(pid) || pid <= 0 || process.platform === "win32") return undefined;
11
- try {
12
- const output = execFileSync("ps", ["-o", "lstart=", "-p", String(pid)], {
13
- encoding: "utf8",
14
- stdio: ["ignore", "pipe", "ignore"],
15
- timeout: 2_000,
16
- }).trim();
17
- return output || undefined;
18
- } catch {
19
- return undefined;
20
- }
67
+ if (!Number.isInteger(pid) || pid <= 0 || process.platform === "win32") return undefined;
68
+ const now = Date.now();
69
+ const entry = tokenMemo.get(pid);
70
+ if (entry && now - entry.readAt < TOKEN_MEMO_TTL_MS) return entry.token;
71
+ const token = readTokenUncached(pid);
72
+ remember(pid, token, now);
73
+ return token;
74
+ }
75
+
76
+ /**
77
+ * Start token for `pid`, always read from the OS.
78
+ *
79
+ * Used where the answer authorizes a signal, so a recycled pid cannot inherit
80
+ * a memoized identity.
81
+ */
82
+ export function readProcessStartTokenFresh(pid: number): string | undefined {
83
+ if (!Number.isInteger(pid) || pid <= 0 || process.platform === "win32") return undefined;
84
+ const token = readTokenUncached(pid);
85
+ remember(pid, token, Date.now());
86
+ return token;
87
+ }
88
+
89
+ /** Drop every memoized identity. Tests and reloads start from a clean slate. */
90
+ export function clearProcessIdentityMemo(): void {
91
+ tokenMemo.clear();
92
+ }
93
+
94
+ const selfStartToken = readTokenUncached(process.pid);
95
+
96
+ export function currentProcessStartToken(): string | undefined {
97
+ return selfStartToken;
21
98
  }
22
99
 
23
100
  export function processIdentityAlive(
24
- pid: number | undefined,
25
- recordedStartToken?: string,
26
- recordedAt?: number,
101
+ pid: number | undefined,
102
+ recordedStartToken?: string,
103
+ recordedAt?: number,
27
104
  ): boolean {
28
- if (!pid || pid <= 0) return false;
29
- try {
30
- process.kill(pid, 0);
31
- } catch (error) {
32
- if ((error as NodeJS.ErrnoException).code !== "EPERM") return false;
33
- }
34
- const current = readProcessStartToken(pid);
35
- if (recordedStartToken && current) return recordedStartToken === current;
36
- if (!recordedStartToken && current && recordedAt !== undefined) {
37
- const currentStartedAt = Date.parse(current);
38
- if (Number.isFinite(currentStartedAt) && currentStartedAt > recordedAt + 2_000) return false;
39
- }
40
- return true;
41
- }
105
+ if (!pid || pid <= 0) return false;
106
+ try {
107
+ process.kill(pid, 0);
108
+ } catch (error) {
109
+ if ((error as NodeJS.ErrnoException).code !== "EPERM") return false;
110
+ }
111
+ const current = readProcessStartToken(pid);
112
+ if (recordedStartToken && current) return recordedStartToken === current;
113
+ if (!recordedStartToken && current && recordedAt !== undefined) {
114
+ const currentStartedAt = Date.parse(current);
115
+ if (Number.isFinite(currentStartedAt) && currentStartedAt > recordedAt + 2_000) return false;
116
+ }
117
+ return true;
118
+ }
@@ -2,11 +2,28 @@
2
2
  // Do not edit: change the source and re-run the emitter (a drift test enforces this).
3
3
 
4
4
  import { execFileSync } from "node:child_process";
5
- const selfStartToken = readProcessStartToken(process.pid);
6
- export function currentProcessStartToken() {
7
- return selfStartToken;
8
- }
9
- export function readProcessStartToken(pid) {
5
+ /**
6
+ * Process start-token reads.
7
+ *
8
+ * `ps` is a process spawn. Reading it on a rendering path — once per task, per
9
+ * frame, for every terminal task kept in the panel — is a measurable CPU drain
10
+ * on its own, and it competes with the very commands the panel is showing.
11
+ *
12
+ * Two rules keep that bounded:
13
+ *
14
+ * 1. A pid's start token is immutable for the life of that pid, so a short-lived
15
+ * memo is safe. Entries expire so a recycled pid is re-read rather than
16
+ * mistaking the previous owner's token for the current one, and failures are
17
+ * memoized too, so a pid that cannot be read is not retried on every frame.
18
+ * 2. Callers that decide to *signal* a pid can ask for a fresh read. Liveness
19
+ * display may use the memo; authorization may not.
20
+ */
21
+ /** How long one pid→token answer is trusted. Short enough to bound pid reuse. */
22
+ const TOKEN_MEMO_TTL_MS = 1_000;
23
+ /** Bound on memo entries; the panel is small, but a long session is not. */
24
+ const TOKEN_MEMO_MAX_ENTRIES = 512;
25
+ const tokenMemo = new Map();
26
+ function readTokenUncached(pid) {
10
27
  if (!Number.isInteger(pid) || pid <= 0 || process.platform === "win32")
11
28
  return undefined;
12
29
  try {
@@ -21,6 +38,59 @@ export function readProcessStartToken(pid) {
21
38
  return undefined;
22
39
  }
23
40
  }
41
+ function remember(pid, token, now) {
42
+ if (tokenMemo.size >= TOKEN_MEMO_MAX_ENTRIES) {
43
+ // Drop expired entries first; only evict live ones if that was not enough.
44
+ for (const [candidate, entry] of tokenMemo) {
45
+ if (now - entry.readAt >= TOKEN_MEMO_TTL_MS)
46
+ tokenMemo.delete(candidate);
47
+ }
48
+ if (tokenMemo.size >= TOKEN_MEMO_MAX_ENTRIES) {
49
+ const oldest = tokenMemo.keys().next();
50
+ if (!oldest.done)
51
+ tokenMemo.delete(oldest.value);
52
+ }
53
+ }
54
+ tokenMemo.set(pid, { token, readAt: now });
55
+ }
56
+ /**
57
+ * Start token for `pid`, memoized for {@link TOKEN_MEMO_TTL_MS}.
58
+ *
59
+ * Suitable for liveness and display. A caller that is about to send a signal
60
+ * must use {@link readProcessStartTokenFresh} instead.
61
+ */
62
+ export function readProcessStartToken(pid) {
63
+ if (!Number.isInteger(pid) || pid <= 0 || process.platform === "win32")
64
+ return undefined;
65
+ const now = Date.now();
66
+ const entry = tokenMemo.get(pid);
67
+ if (entry && now - entry.readAt < TOKEN_MEMO_TTL_MS)
68
+ return entry.token;
69
+ const token = readTokenUncached(pid);
70
+ remember(pid, token, now);
71
+ return token;
72
+ }
73
+ /**
74
+ * Start token for `pid`, always read from the OS.
75
+ *
76
+ * Used where the answer authorizes a signal, so a recycled pid cannot inherit
77
+ * a memoized identity.
78
+ */
79
+ export function readProcessStartTokenFresh(pid) {
80
+ if (!Number.isInteger(pid) || pid <= 0 || process.platform === "win32")
81
+ return undefined;
82
+ const token = readTokenUncached(pid);
83
+ remember(pid, token, Date.now());
84
+ return token;
85
+ }
86
+ /** Drop every memoized identity. Tests and reloads start from a clean slate. */
87
+ export function clearProcessIdentityMemo() {
88
+ tokenMemo.clear();
89
+ }
90
+ const selfStartToken = readTokenUncached(process.pid);
91
+ export function currentProcessStartToken() {
92
+ return selfStartToken;
93
+ }
24
94
  export function processIdentityAlive(pid, recordedStartToken, recordedAt) {
25
95
  if (!pid || pid <= 0)
26
96
  return false;
@@ -376,9 +376,22 @@ class Supervisor implements LocalSupervisor {
376
376
  const command = readCommandRecord(this.directory, taskId).value;
377
377
  const stop = readStopRequest(this.directory, taskId);
378
378
  const cleanup = readCleanupRecord(this.directory, taskId);
379
+ // Reading a process start token spawns `ps`. A task that already published a
380
+ // terminal/lost record cannot change state again, so its liveness only
381
+ // matters to the one caller that may still act on a surviving command group
382
+ // (`stop`). Every other reader would pay for an answer it does not use, once
383
+ // per task per panel frame. Probe only when that authority is actually needed.
384
+ const needsLiveness = result === undefined && lostRecord === undefined
385
+ ? true
386
+ : result !== undefined && result.groupCleanup === "unproven"
387
+ || lostRecord !== undefined;
379
388
  // One identity source per instance: liveness and signalling agree.
380
- const claimAlive = claim ? identityAlive(claim.pid, claim.pidStartTime, claim.claimedAt, this.readToken) : false;
381
- const commandAlive = command ? identityAlive(command.pid, command.pidStartTime, command.startedAt, this.readToken) : false;
389
+ const claimAlive = needsLiveness && claim
390
+ ? identityAlive(claim.pid, claim.pidStartTime, claim.claimedAt, this.readToken)
391
+ : false;
392
+ const commandAlive = needsLiveness && command
393
+ ? identityAlive(command.pid, command.pidStartTime, command.startedAt, this.readToken)
394
+ : false;
382
395
 
383
396
  // A published terminal/lost record needs no fence. Anything else is decided
384
397
  // under the fence, so a `lost` attestation and a claim cannot both win.
@@ -426,6 +439,67 @@ class Supervisor implements LocalSupervisor {
426
439
  };
427
440
  }
428
441
 
442
+ /**
443
+ * Read-only projection for rendering: no fence, no lost publication, no `ps`.
444
+ *
445
+ * `inspect` is the authoritative path and pays for that authority — a
446
+ * cross-process SQLite fence and a process-identity probe per recorded pid.
447
+ * A panel asks per task per frame, so it reads what the records already say.
448
+ */
449
+ snapshot(taskId: string): SupervisorInspection | undefined {
450
+ const launchRead = readLaunchRecord(this.directory, taskId);
451
+ if (launchRead.missing) return undefined;
452
+ if (!launchRead.value) {
453
+ throw new Error(`Unreadable supervisor launch record for ${taskId}: ${launchRead.error}`);
454
+ }
455
+ const launch = launchRead.value;
456
+ const paths = supervisorPaths(this.directory, taskId);
457
+ const result = readTerminalRecord(this.directory, taskId).value;
458
+ const lostRecord = readLostRecord(this.directory, taskId).value;
459
+ const claim = readClaimRecord(this.directory, taskId).value;
460
+ const command = readCommandRecord(this.directory, taskId).value;
461
+ const stop = readStopRequest(this.directory, taskId);
462
+ const cleanup = readCleanupRecord(this.directory, taskId);
463
+ const status: SupervisorTaskStatus = result
464
+ ? (result.status as SupervisorTaskStatus)
465
+ : lostRecord
466
+ ? "lost"
467
+ : claim
468
+ ? "running"
469
+ : "starting";
470
+ return {
471
+ taskId,
472
+ token: launch.token,
473
+ kind: launch.kind,
474
+ status,
475
+ terminal: result !== undefined || lostRecord !== undefined,
476
+ launch: launchSummary(launch),
477
+ // Liveness is deliberately not asserted here: a snapshot never spawns `ps`.
478
+ ...(claim === undefined
479
+ ? {}
480
+ : { claim: { pid: claim.pid, pidStartTime: claim.pidStartTime, claimedAt: claim.claimedAt, alive: false } }),
481
+ ...(command === undefined
482
+ ? {}
483
+ : {
484
+ command: {
485
+ pid: command.pid,
486
+ pgid: command.pgid,
487
+ pidStartTime: command.pidStartTime,
488
+ startedAt: command.startedAt,
489
+ attempt: command.attempt ?? 1,
490
+ alive: false,
491
+ },
492
+ }),
493
+ ...(result === undefined ? {} : { result }),
494
+ ...(lostRecord === undefined ? {} : { lost: lostRecord }),
495
+ ...(cleanup === undefined ? {} : { cleanup }),
496
+ ...(stop === undefined ? {} : { stopRequestedAt: stop.requestedAt }),
497
+ taskDir: paths.dir,
498
+ logs: { stdout: paths.stdout, stderr: paths.stderr, events: paths.events },
499
+ observedAt: Date.now(),
500
+ };
501
+ }
502
+
429
503
  /**
430
504
  * Decide the state of a launch with no terminal result, under the cross-process
431
505
  * fence. This is the only place a `lost` record is published, so it can never
@@ -380,5 +380,15 @@ export interface LocalSupervisorOptions {
380
380
  export interface LocalSupervisor {
381
381
  start(options: SupervisorStartOptions): Promise<SupervisorStartResult>;
382
382
  inspect(taskId: string, options?: SupervisorInspectOptions): SupervisorInspection | undefined;
383
+ /**
384
+ * Read-only projection of the durable records: no state fence, no lost
385
+ * publication and no process-identity probe.
386
+ *
387
+ * This is the rendering path. `inspect` may take a cross-process SQLite fence
388
+ * and spawn `ps` per task, which must not happen once per task per frame. A
389
+ * snapshot reports what the records already say — `running` for a claim,
390
+ * `starting` before one — and leaves reconciliation to the caller's cadence.
391
+ */
392
+ snapshot(taskId: string): SupervisorInspection | undefined;
383
393
  stop(taskId: string, options?: SupervisorStopOptions): Promise<SupervisorStopResult | undefined>;
384
394
  }
@@ -28,19 +28,40 @@ export function effectiveSubagentSettings(
28
28
  }
29
29
 
30
30
  export function formatSubagentSettings(settings: EffectiveSubagentSettings): string {
31
+ const manual = settings.delegationMode === "manual";
32
+ const capacity = manual
33
+ ? "The limit is a ceiling, not a target. In manual mode it bounds only work the user or an active workflow explicitly delegates; no slot count is an invitation to launch work."
34
+ : "The limit is a ceiling, not a target. Launch ready, independent, useful work when a suitable subagent is available.";
35
+ // No occupancy counters here. This text is injected into the conversation and
36
+ // may only ever be appended to, so anything volatile would force either a
37
+ // rewrite of already-sent content (which invalidates the provider's cached
38
+ // prefix) or one more copy per change. Admission rechecks capacity at the
39
+ // spawn anyway; live counts stay available through the subagent_settings tool.
31
40
  return [
32
41
  `Subagent settings: revision ${settings.revision}`,
33
42
  `Delegation: ${settings.delegationMode} (${settings.sources.delegationMode})`,
34
43
  `Concurrency limit: ${settings.maxConcurrent} (${settings.sources.maxConcurrent})`,
35
- `Running: ${settings.runningCount}; reserved: ${settings.pendingCount}`,
36
- `Occupied slots: ${settings.occupiedSlots}`,
37
- `Available slots: ${settings.availableSlots}`,
38
- "The limit is a ceiling, not a target. Launch ready, independent, useful work within available capacity. " +
39
- "This is a point-in-time snapshot; spawn admission rechecks capacity. Lowering the limit does not stop existing runs.",
44
+ // Mode first: the policy governs the settings that follow it.
40
45
  delegationPrompt(settings.delegationMode),
46
+ capacity + " Spawn admission rechecks capacity; lower the limit without stopping existing runs.",
41
47
  ].join("\n");
42
48
  }
43
49
 
50
+ /**
51
+ * Append the delegation policy to the end of the system prompt.
52
+ *
53
+ * The system prompt is not part of the compaction window, so the policy survives
54
+ * a summary that rewrites the conversation. It is also part of the cached
55
+ * prefix, so it costs nothing per request while it stays unchanged.
56
+ *
57
+ * Nothing volatile goes in here: live occupancy would rewrite the cached prefix
58
+ * on every run, and admission rechecks capacity at spawn anyway.
59
+ */
60
+ export function appendSubagentSettings(systemPrompt: string, settings: EffectiveSubagentSettings): string {
61
+ const policy = formatSubagentSettings(settings);
62
+ return systemPrompt.includes(policy) ? systemPrompt : `${systemPrompt}\n\n${policy}`;
63
+ }
64
+
44
65
  export function subagentSettingsTool(Type: any, read: () => EffectiveSubagentSettings) {
45
66
  return {
46
67
  name: "subagent_settings",
@@ -38,7 +38,7 @@ import { selectSessionByName } from "./session-selection.ts";
38
38
  import { isWithinWorkspace } from "./workspace-scope.ts";
39
39
  import { loadConfig, writeDelegationMode, writeSubagentSettings, isConcurrencyCap, normalizeConcurrencyCap, normalizeTools, resolveExtensionPath, configPath, selfDir, SAFE_DEFAULT_TOOLS, SAFE_CLEAN_TOOLS } from "./config.ts";
40
40
  import { createSubagentSettingsPage } from "./settings-page.ts";
41
- import { effectiveSubagentSettings, formatSubagentSettings, subagentSettingsTool, SUBAGENT_SETTINGS_CONTEXT } from "./effective-settings.ts";
41
+ import { appendSubagentSettings, effectiveSubagentSettings, formatSubagentSettings, subagentSettingsTool, SUBAGENT_SETTINGS_CONTEXT } from "./effective-settings.ts";
42
42
  import { STEER_FILE_ENV, readSteerReceipt } from "./child-steer.ts";
43
43
  import {
44
44
  decideTiming,
@@ -3603,18 +3603,12 @@ export default function (pi: ExtensionAPI) {
3603
3603
  pi.registerTool(discoveryTool as Parameters<ExtensionAPI["registerTool"]>[0]);
3604
3604
 
3605
3605
  pi.on("before_agent_start", (event) => ({
3606
- systemPrompt: `${appendExecutionPolicy(event.systemPrompt)}\n\nUse the latest Subagent settings context block as the authoritative delegation mode and capacity snapshot. Its delegation guidance replaces earlier mode-specific guidance, including guidance in a plan. Inspect subagent_settings when needed; never treat the concurrency limit as a target number of agents.`,
3606
+ systemPrompt: appendSubagentSettings(
3607
+ `${appendExecutionPolicy(event.systemPrompt)}\n\nTreat the Subagent settings block at the end of this prompt as the authoritative delegation mode and cap. Its delegation guidance replaces earlier mode-specific guidance, including guidance in a plan. Live occupancy is not pushed into context: inspect subagent_settings when it matters, and never treat the concurrency limit as a target number of agents.`,
3608
+ effectiveSettings(),
3609
+ ),
3607
3610
  }));
3608
3611
 
3609
- pi.on("context", (event) => {
3610
- const settings = effectiveSettings();
3611
- return { messages: [
3612
- ...event.messages.filter(message => message.role !== "custom" || message.customType !== SUBAGENT_SETTINGS_CONTEXT),
3613
- { role: "custom" as const, customType: SUBAGENT_SETTINGS_CONTEXT,
3614
- content: formatSubagentSettings(settings), display: false, details: settings, timestamp: Date.now() },
3615
- ] };
3616
- });
3617
-
3618
3612
  // ---- live-status lifecycle -----------------------------------------
3619
3613
  pi.on("agent_start", async (_event, ctx) => {
3620
3614
  setCallbackBatchContext(pi, ctx);
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-subagents",
3
- "version": "0.27.0",
3
+ "version": "0.27.1",
4
4
  "description": "Pi extension for detached, sandboxed subagent runs that keep the foreground session free.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-harness",
3
- "version": "0.40.0",
3
+ "version": "0.40.1",
4
4
  "description": "Pi extension bundle for a write sandbox, subagents, background tasks, SSH, goals, model routing, and Outcome & Todo.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -55,13 +55,13 @@
55
55
  "test": "node --import tsx --import ../../scripts/isolate-registry.mjs --test test/*.test.mjs test/*.test.ts test/task-context/*.test.ts"
56
56
  },
57
57
  "dependencies": {
58
- "pi-better-background-tasks": "0.14.0",
58
+ "pi-better-background-tasks": "0.14.1",
59
59
  "pi-better-goal": "0.12.0",
60
60
  "pi-better-model-router": "0.1.2",
61
61
  "pi-ollama-cloud": "0.13.1",
62
62
  "pi-better-sandbox": "0.14.1",
63
63
  "pi-better-ssh": "0.1.4",
64
- "pi-better-subagents": "0.27.0",
64
+ "pi-better-subagents": "0.27.1",
65
65
  "proper-lockfile": "^4.1.2",
66
66
  "shell-quote": "1.12.0",
67
67
  "smol-toml": "1.9.0",