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.
- package/node_modules/pi-better-background-tasks/package.json +1 -1
- package/node_modules/pi-better-background-tasks/src/navigator-provider.ts +24 -3
- package/node_modules/pi-better-background-tasks/src/observed-waits.ts +3 -1
- package/node_modules/pi-better-background-tasks/src/process-identity.ts +108 -31
- package/node_modules/pi-better-background-tasks/src/supervisor/generated/process-identity.mjs +75 -5
- package/node_modules/pi-better-background-tasks/src/supervisor/supervisor.ts +76 -2
- package/node_modules/pi-better-background-tasks/src/supervisor/types.ts +10 -0
- package/node_modules/pi-better-subagents/effective-settings.ts +26 -5
- package/node_modules/pi-better-subagents/index.ts +5 -11
- package/node_modules/pi-better-subagents/package.json +1 -1
- package/package.json +3 -3
|
@@ -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(
|
|
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
|
-
|
|
122
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
6
|
-
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
101
|
+
pid: number | undefined,
|
|
102
|
+
recordedStartToken?: string,
|
|
103
|
+
recordedAt?: number,
|
|
27
104
|
): boolean {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
+
}
|
package/node_modules/pi-better-background-tasks/src/supervisor/generated/process-identity.mjs
CHANGED
|
@@ -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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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 =
|
|
381
|
-
|
|
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
|
-
|
|
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:
|
|
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);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-better-harness",
|
|
3
|
-
"version": "0.40.
|
|
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.
|
|
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.
|
|
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",
|