pi-pier 0.4.1 → 0.4.2
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/README.md +1 -2
- package/package.json +1 -1
- package/schemas/efficiency-config.schema.json +6 -0
- package/src/ask-dialog.ts +9 -2
- package/src/ask-user.ts +142 -74
- package/src/compact-coordinator.ts +204 -33
- package/src/compact-economics-core.ts +62 -29
- package/src/config-catalog-core.ts +1 -0
- package/src/delegation-core.ts +1 -1
- package/src/efficiency-config-core.ts +16 -2
- package/src/efficiency-store.ts +42 -5
- package/src/herdr-client.ts +15 -5
- package/src/index-pipe.ts +0 -8
- package/src/index.ts +69 -85
- package/src/jev-client.ts +32 -7
- package/src/jev-core.ts +70 -33
- package/src/observation-core.ts +13 -1
- package/src/pipe-channel.ts +0 -8
- package/src/plugins/heat-plan.ts +18 -3
- package/src/plugins/observation.ts +118 -46
- package/src/plugins/subagent.ts +53 -40
- package/src/plugins/terminal.ts +192 -47
- package/src/plugins/todo.ts +43 -21
- package/src/reducer-invoker.ts +27 -13
- package/src/renderers.ts +1 -13
- package/src/roles/worker-default.json +3 -3
- package/src/settle-wake-core.ts +8 -2
- package/src/subagent-core.ts +41 -3
- package/src/subagent-poller.ts +52 -18
- package/src/subagent-session.ts +67 -4
- package/src/subagent-spawn.ts +6 -5
- package/src/terminal-core.ts +111 -17
- package/src/todo-core.ts +47 -2
- package/src/todo-read-hook.ts +31 -17
- package/src/todo-reminder-core.ts +9 -5
- package/src/todo-window.ts +8 -4
- package/src/todos-service.ts +12 -0
- package/src/vocab.ts +7 -1
package/src/plugins/todo.ts
CHANGED
|
@@ -15,7 +15,7 @@ import { makeProgressUpdate } from '../subagent-core.ts';
|
|
|
15
15
|
import { TODO_DETAILS_KEY, TODO_STATUSES, TODO_TOOL_NAME, formatTodoConfirmation, type TodoItem } from '../vocab.ts';
|
|
16
16
|
import { toolError } from '../tool-error.ts';
|
|
17
17
|
import { formatAge, isArchived } from '../stale-core.ts';
|
|
18
|
-
import { TODO_EDIT_CUSTOM_TYPE, completionTransitions, fuzzyFind, listsEqual, makeSnapshot, normalizeStrict, revertedCompleted, validateTodos, type TodoEditOp } from '../todo-core.ts';
|
|
18
|
+
import { TODO_ARCHIVE_WINDOW_CUSTOM_TYPE, TODO_EDIT_CUSTOM_TYPE, completionTransitions, fuzzyFind, listsEqual, makeSnapshot, normalizeStrict, revertedCompleted, todoListHash, validateTodos, type TodoEditOp } from '../todo-core.ts';
|
|
19
19
|
import { formatTodoSummary, renderTodoGroups } from '../todo-window.ts';
|
|
20
20
|
import { unifiedWidgetLines, type WorkerWidgetRow } from '../tree-view.ts';
|
|
21
21
|
import { swallow } from '../swallow.ts';
|
|
@@ -52,13 +52,10 @@ export interface TodoDeps {
|
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
const TOOL_DESCRIPTION = [
|
|
55
|
-
'Record
|
|
56
|
-
'Entries: `content` (
|
|
57
|
-
'
|
|
58
|
-
'
|
|
59
|
-
'Before stopping, reconcile open entries: anything waiting on a human (decision, approval, or an ops action you cannot perform) must be marked blocked with a blocker note — never left pending, and never executed just to clear the list; finished or obsolete entries must be completed or removed.',
|
|
60
|
-
'Do not make todo_write the only tool call of a turn — batch it together with real work.',
|
|
61
|
-
'Delegated work belongs on this list too. When you hand an entry to a subagent, append ` <sub>` to its content (the subagent is doing it, not you); when the subagent settles, a matching entry is auto-completed — you will see "Reconciled:" in the settlement note. If no auto-match fired, update the entry yourself.',
|
|
55
|
+
'Record the complete task list for the current work. Every call REPLACES the whole list, so always resend it in full.',
|
|
56
|
+
'Entries: `content` (unique short imperative, no numbering), `status`, `blocker` (only when blocked: what it waits for), optional `phase` (group for a multi-stage plan). At most 15 entries, one per meaningful unit of work; several may be in_progress when work truly runs in parallel.',
|
|
57
|
+
'Do not make todo_write the only tool call of a turn — batch it with real work.',
|
|
58
|
+
'When you hand an entry to a subagent, append ` <sub>` to its content; it auto-completes when the subagent settles ("Reconciled:" in the note), otherwise update it yourself.',
|
|
62
59
|
].join(' ');
|
|
63
60
|
|
|
64
61
|
|
|
@@ -126,7 +123,7 @@ export default function todoPlugin(ctx: Context): void {
|
|
|
126
123
|
staleNotices = 0;
|
|
127
124
|
});
|
|
128
125
|
|
|
129
|
-
scoped.on('before_agent_start', async (eventCtx?: unknown) => {
|
|
126
|
+
scoped.on('before_agent_start', async (eventCtx?: unknown, ctx?: unknown) => {
|
|
130
127
|
// pi 1.2+ also fires this for extension-triggered runs; skip the ones that would double-inject
|
|
131
128
|
// or consume the guard cadence (stop reminder, config guide, third-party custom runs).
|
|
132
129
|
if (!shouldRunTodoRead(eventCtx)) return;
|
|
@@ -139,12 +136,28 @@ export default function todoPlugin(ctx: Context): void {
|
|
|
139
136
|
now: Date.now(),
|
|
140
137
|
staleNotices,
|
|
141
138
|
lastStaleGuardTurn,
|
|
139
|
+
archiveWindowOffered: todos.archiveWindowOffered,
|
|
142
140
|
});
|
|
143
141
|
if (plan.inject && plan.effect === 'stale-notice') {
|
|
144
142
|
staleNotices += 1;
|
|
145
143
|
lastStaleGuardTurn = todoReadTurn;
|
|
146
144
|
}
|
|
147
|
-
//
|
|
145
|
+
// Persist the one-window offer against this list identity: the plugin closure dies with the
|
|
146
|
+
// process, and on /resume the same dead list must reach the terminal clear instead of re-warning.
|
|
147
|
+
if (plan.inject && plan.offerArchiveWindow) {
|
|
148
|
+
try {
|
|
149
|
+
appendEntry(TODO_ARCHIVE_WINDOW_CUSTOM_TYPE, {
|
|
150
|
+
version: 1,
|
|
151
|
+
itemsHash: todoListHash(todos.items),
|
|
152
|
+
});
|
|
153
|
+
} catch (err) {
|
|
154
|
+
// The in-memory mark still advances escalation in this process.
|
|
155
|
+
swallow('todo.persist-archive-window', err);
|
|
156
|
+
}
|
|
157
|
+
todos.markArchiveWindowOffered();
|
|
158
|
+
}
|
|
159
|
+
// Empty-list guards and the archive rewrite window share one cadence so adjacent notices do not
|
|
160
|
+
// each consume the grace window; the terminal clear is immediate and only marks the tick.
|
|
148
161
|
if (plan.inject && (plan.effect === 'empty-guard' || plan.archived)) {
|
|
149
162
|
lastEmptyGuardTurn = todoReadTurn;
|
|
150
163
|
}
|
|
@@ -159,6 +172,11 @@ export default function todoPlugin(ctx: Context): void {
|
|
|
159
172
|
swallow('todo.persist-archive', err);
|
|
160
173
|
}
|
|
161
174
|
todos.replace([], { source: 'archive' });
|
|
175
|
+
// The clear is the one todo mutation outside the tool/human paths that must refresh the
|
|
176
|
+
// title, sidebar token and widget; the dead list would otherwise stay authoritative. The hook's
|
|
177
|
+
// first argument is the event (no ui), so render through the run ctx; tests pass a ui ctx first.
|
|
178
|
+
mirrorTodos();
|
|
179
|
+
renderWidget(ctx ?? eventCtx);
|
|
162
180
|
}
|
|
163
181
|
todoReadTurn += 1;
|
|
164
182
|
if (!plan.inject) return;
|
|
@@ -194,11 +212,18 @@ export default function todoPlugin(ctx: Context): void {
|
|
|
194
212
|
const { role, stopReason } = msg as { role?: unknown; stopReason?: unknown };
|
|
195
213
|
if (role === 'assistant' && typeof stopReason === 'string') lastAssistantStopReason = stopReason;
|
|
196
214
|
});
|
|
197
|
-
|
|
198
|
-
|
|
215
|
+
// A retry starts a fresh turn, not necessarily a fresh agent_start: cancel the pending reminder
|
|
216
|
+
// there too so a provider-error retry cannot be interrupted by a stale 30 s timer.
|
|
217
|
+
for (const ev of ['agent_start', 'turn_start', 'session_shutdown']) scoped.on(ev, () => cancelTodoReminder());
|
|
218
|
+
scoped.on('agent_settled', async (event: unknown) => {
|
|
199
219
|
cancelTodoReminder();
|
|
220
|
+
// pi ≥ 1.1 carries `aborted` on agent_settled: authoritative for ESC mid-tool, where stopReason
|
|
221
|
+
// tracking sees a stale natural reason and would still schedule a reminder.
|
|
222
|
+
const aborted = event !== null && typeof event === 'object'
|
|
223
|
+
&& 'aborted' in event && event.aborted === true;
|
|
200
224
|
const plan = planStopTodoReminder({
|
|
201
225
|
lastStopReason: lastAssistantStopReason,
|
|
226
|
+
aborted,
|
|
202
227
|
intentionalAbort: stopReminder.isIntentionalAbort?.() ?? false,
|
|
203
228
|
compactionInFlight: stopReminder.isCompactionInFlight?.() ?? false,
|
|
204
229
|
reminders: todoReminders,
|
|
@@ -236,23 +261,20 @@ export default function todoPlugin(ctx: Context): void {
|
|
|
236
261
|
label: 'Todo',
|
|
237
262
|
description: TOOL_DESCRIPTION,
|
|
238
263
|
promptGuidelines: [
|
|
239
|
-
'Before stopping
|
|
264
|
+
'Before stopping, reconcile open entries: anything waiting on a human (decision, approval, an ops action you cannot perform) is marked blocked with a blocker note — never left pending, never executed just to clear the list; finished or obsolete entries are completed or removed.',
|
|
240
265
|
],
|
|
241
266
|
parameters: Type.Object({
|
|
242
267
|
todos: Type.Array(
|
|
243
268
|
Type.Object(
|
|
244
269
|
{
|
|
245
|
-
content: Type.String({ description: '
|
|
246
|
-
status: Type.Union(
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
),
|
|
250
|
-
blocker: Type.Optional(Type.String({ description: 'Only when status is "blocked": what it is waiting for. Omit the field entirely (never an empty string) for other statuses' })),
|
|
251
|
-
phase: Type.Optional(Type.String({ description: 'Optional group name (≤30 chars); omit for a flat list (never an empty string)' })),
|
|
270
|
+
content: Type.String({ description: 'Short imperative line' }),
|
|
271
|
+
status: Type.Union(TODO_STATUSES.map((status) => Type.Literal(status))),
|
|
272
|
+
blocker: Type.Optional(Type.String({ description: 'Only for blocked; omit otherwise (never an empty string)' })),
|
|
273
|
+
phase: Type.Optional(Type.String({ description: 'Group name (≤30 chars); omit for a flat list' })),
|
|
252
274
|
},
|
|
253
275
|
{ additionalProperties: false },
|
|
254
276
|
),
|
|
255
|
-
{ description: 'The COMPLETE
|
|
277
|
+
{ description: 'The COMPLETE list; replaces the previous one' },
|
|
256
278
|
),
|
|
257
279
|
}),
|
|
258
280
|
async execute(toolCallId: string, params: ToolParams, signal: AbortSignal | undefined, onUpdate: ((update: unknown) => void) | undefined, eventCtx: unknown) {
|
package/src/reducer-invoker.ts
CHANGED
|
@@ -68,6 +68,8 @@ type ReducerModelOutput = {
|
|
|
68
68
|
content?: readonly unknown[];
|
|
69
69
|
text?: unknown;
|
|
70
70
|
usage?: UsageLike;
|
|
71
|
+
stopReason?: string;
|
|
72
|
+
errorMessage?: string;
|
|
71
73
|
};
|
|
72
74
|
|
|
73
75
|
type ReducerModel = NonNullable<ExtensionContext['model']>;
|
|
@@ -139,23 +141,23 @@ function resolveCandidate(
|
|
|
139
141
|
}
|
|
140
142
|
|
|
141
143
|
/**
|
|
142
|
-
* Step 6,
|
|
143
|
-
*
|
|
144
|
-
*
|
|
144
|
+
* Step 6, regex first: only regex-listed commands are candidates; jev may veto them (a confident
|
|
145
|
+
* rejection is authoritative) but never promotes an unlisted command. Asking jev about every result
|
|
146
|
+
* flipped 0 of 437 decisions over a week while blocking each tool result ~0.8s and uploading every
|
|
147
|
+
* command line (D122). Jev failures, low confidence and credential-shaped commands keep the regex hit.
|
|
145
148
|
*/
|
|
146
149
|
async function isDiagnosticCandidate(
|
|
147
150
|
command: string,
|
|
148
151
|
gate: JevEprGateDependency | undefined,
|
|
149
152
|
sessionId: string | undefined,
|
|
150
153
|
): Promise<boolean> {
|
|
151
|
-
|
|
152
|
-
if (!gate || containsLikelySecret(command)) return
|
|
154
|
+
if (!isDiagnosticCommand(command)) return false;
|
|
155
|
+
if (!gate || containsLikelySecret(command)) return true;
|
|
153
156
|
const result = await gate.ask(diagnosticGateRequest(command), {
|
|
154
157
|
questionId: 'epr-diagnostic-gate',
|
|
155
158
|
timeoutMs: 2500, // cold TLS hit 1002ms at 1s; at 1500ms a real session still saw 14/73 calls (19%) time out — 2.5s keeps the gate decisive without stalling reducers
|
|
156
159
|
sessionId,
|
|
157
|
-
|
|
158
|
-
extra: { site: 'epr-gate', commandSha256: sha256Hex(command), regexHit },
|
|
160
|
+
extra: { site: 'epr-gate', commandSha256: sha256Hex(command) },
|
|
159
161
|
enrich: ({ answers }) => {
|
|
160
162
|
if (!answers) return {};
|
|
161
163
|
const verdict = evaluateDiagnosticGate(answers, gate.getMinConfidence());
|
|
@@ -167,10 +169,9 @@ async function isDiagnosticCandidate(
|
|
|
167
169
|
};
|
|
168
170
|
},
|
|
169
171
|
});
|
|
170
|
-
if (!result.ok) return
|
|
172
|
+
if (!result.ok) return true;
|
|
171
173
|
const verdict = evaluateDiagnosticGate(result.answers, gate.getMinConfidence());
|
|
172
|
-
|
|
173
|
-
return isDiagnosticGateUnanswered(verdict) ? regexHit : false;
|
|
174
|
+
return verdict.hit || isDiagnosticGateUnanswered(verdict);
|
|
174
175
|
}
|
|
175
176
|
|
|
176
177
|
/** Step 7: the untruncated source; both failure modes write the same `truncated-source` evidence
|
|
@@ -317,7 +318,10 @@ export async function handleReducerToolResult(
|
|
|
317
318
|
if (config.localOnly) return undefined; // Archival is the whole job in local-only mode.
|
|
318
319
|
|
|
319
320
|
const reducer = resolveReducerModel(ctx, config);
|
|
320
|
-
if (!reducer)
|
|
321
|
+
if (!reducer) {
|
|
322
|
+
await log({ sourceBytes, model: config.model ?? 'default', verificationOk: false, reason: 'no-reducer-model', action: 'fallback_full_text' });
|
|
323
|
+
return undefined;
|
|
324
|
+
}
|
|
321
325
|
/** Evidence row for a fail-open exit taken after the reducer model was resolved. */
|
|
322
326
|
const fallbackRow = (reason: string, durationMs: number, extra: Record<string, unknown> = {}): Promise<void> =>
|
|
323
327
|
log({
|
|
@@ -354,7 +358,14 @@ export async function handleReducerToolResult(
|
|
|
354
358
|
: typeof output.text === 'string'
|
|
355
359
|
? output.text
|
|
356
360
|
: '';
|
|
357
|
-
if (!modelOutput.trim())
|
|
361
|
+
if (!modelOutput.trim()) {
|
|
362
|
+
// pi's complete() reports provider failures as an empty `error` message instead of throwing.
|
|
363
|
+
await fallbackRow(output.stopReason === 'error' ? 'invoke-failed' : 'empty-output', durationMs, {
|
|
364
|
+
...(output.stopReason ? { stopReason: output.stopReason } : {}),
|
|
365
|
+
...(output.errorMessage ? { error: output.errorMessage } : {}),
|
|
366
|
+
});
|
|
367
|
+
return undefined;
|
|
368
|
+
}
|
|
358
369
|
|
|
359
370
|
const validated = validateReceipt(modelOutput, sourceHash, body, event.isError);
|
|
360
371
|
if (!validated.ok) {
|
|
@@ -377,7 +388,10 @@ export async function handleReducerToolResult(
|
|
|
377
388
|
totalTokens: output.usage?.totalTokens,
|
|
378
389
|
});
|
|
379
390
|
const receiptBytes = Buffer.byteLength(receipt, 'utf8');
|
|
380
|
-
if (receiptBytes >= sourceBytes)
|
|
391
|
+
if (receiptBytes >= sourceBytes) {
|
|
392
|
+
await fallbackRow('receipt-not-smaller', durationMs, { receiptBytes });
|
|
393
|
+
return undefined;
|
|
394
|
+
}
|
|
381
395
|
|
|
382
396
|
await log({
|
|
383
397
|
sourceBytes,
|
package/src/renderers.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* needed and older pi builds degrade to "no renderer".
|
|
5
5
|
*/
|
|
6
6
|
import { TODO_EDIT_CUSTOM_TYPE, type TodoEditPayload } from './todo-core.ts';
|
|
7
|
-
import { SUBS_CUSTOM_TYPE,
|
|
7
|
+
import { SUBS_CUSTOM_TYPE, subRef, type SubsRegistry } from './subagent-core.ts';
|
|
8
8
|
import { DELEGATION_CUSTOM_TYPE } from './delegation-core.ts';
|
|
9
9
|
import { TERMINALS_CUSTOM_TYPE, TERM_REMINDER_CUSTOM_TYPE, type TerminalsRegistry } from './terminal-core.ts';
|
|
10
10
|
import { TODO_REMINDER_CUSTOM_TYPE } from './todo-reminder-core.ts';
|
|
@@ -159,17 +159,6 @@ function approvalLines(data: unknown, theme: RenderTheme): string[] {
|
|
|
159
159
|
return [`${theme.fg('warning', '⚠ approval needed')} ${theme.fg('dim', `· ${tool} (${role})`)}`];
|
|
160
160
|
}
|
|
161
161
|
|
|
162
|
-
/** Phase 2: worker progress note (display-less custom message) as a one-line card — the sender
|
|
163
|
-
* pane rides `details.paneId`; keep it recognizable without flooding the transcript. */
|
|
164
|
-
function progressLines(message: unknown, theme: RenderTheme, expanded: boolean): string[] {
|
|
165
|
-
const rec = (message ?? {}) as { content?: unknown; details?: { paneId?: unknown } };
|
|
166
|
-
const from = typeof rec.details?.paneId === 'string' && rec.details.paneId ? rec.details.paneId : '?';
|
|
167
|
-
const content = typeof rec.content === 'string' ? rec.content : '';
|
|
168
|
-
const head = `${theme.fg('accent', '⤴ progress')} ${theme.fg('dim', `· ${from}: ${clip(content, 100)}`)}`;
|
|
169
|
-
if (!expanded) return [head];
|
|
170
|
-
return [head, ...content.split('\n').map((line) => theme.fg('dim', line))];
|
|
171
|
-
}
|
|
172
|
-
|
|
173
162
|
/** Reminder custom messages carry the full injected text; restyle without altering it. */
|
|
174
163
|
function reminderLines(message: unknown, theme: RenderTheme, label: string, expanded: boolean): string[] {
|
|
175
164
|
const content = typeof (message as { content?: unknown } | undefined)?.content === 'string'
|
|
@@ -215,7 +204,6 @@ export function installRenderers(pi: unknown): string[] {
|
|
|
215
204
|
entry(DELEGATION_CUSTOM_TYPE, delegationLines);
|
|
216
205
|
entry(ROLE_MANIFEST_CUSTOM_TYPE, (data, theme) => roleManifestLines(data, theme));
|
|
217
206
|
entry(APPROVAL_NEEDED_CUSTOM_TYPE, (data, theme) => approvalLines(data, theme));
|
|
218
|
-
message(PROGRESS_CUSTOM_TYPE, progressLines);
|
|
219
207
|
message(TODO_REMINDER_CUSTOM_TYPE, (msg, theme, expanded) => reminderLines(msg, theme, 'todo reminder', expanded));
|
|
220
208
|
message(TERM_REMINDER_CUSTOM_TYPE, (msg, theme, expanded) => reminderLines(msg, theme, 'terminal nudge', expanded));
|
|
221
209
|
return registered;
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"role": "worker-default",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "默认worker角色(D83 继承制):镜像 master.tools 减 subagent/terminal(禁嵌套、资源归属);排除族显式 deny——在 D82 unknownTools=allow 姿态下仍持禁令;todos serial。v1.5.0: Phase 2 显式放行 report_progress
|
|
3
|
+
"version": "1.6.0",
|
|
4
|
+
"description": "默认worker角色(D83 继承制):镜像 master.tools 减 subagent/terminal(禁嵌套、资源归属);排除族显式 deny——在 D82 unknownTools=allow 姿态下仍持禁令;todos serial。v1.6.0: 删除 report_progress(一周 0 调用,D125);v1.5.0: Phase 2 显式放行 report_progress;v1.4.0: 工具合并后更新 deny 列表",
|
|
5
5
|
"manifest": {
|
|
6
6
|
"unknownTools": "allow",
|
|
7
7
|
"tools": [
|
|
8
|
-
"todo_write", "ask_user_question",
|
|
8
|
+
"todo_write", "ask_user_question",
|
|
9
9
|
"read", "grep", "glob", "bash", "pwsh", "edit", "write",
|
|
10
10
|
"web_search"
|
|
11
11
|
],
|
package/src/settle-wake-core.ts
CHANGED
|
@@ -7,6 +7,9 @@
|
|
|
7
7
|
|
|
8
8
|
export const ABORT_STOP_REASON = 'aborted';
|
|
9
9
|
|
|
10
|
+
/** pi/provider failure stop. pi auto-retries these, so the run has not settled for the user. */
|
|
11
|
+
export const ERROR_STOP_REASON = 'error';
|
|
12
|
+
|
|
10
13
|
/** D96: cooldown before repeating a notice for the same running set. */
|
|
11
14
|
export const D96_REPEAT_NOTICE_MS = 10 * 60_000;
|
|
12
15
|
|
|
@@ -80,7 +83,9 @@ export function planSettleWake(input: SettleWakeInput): SettleWakePlan {
|
|
|
80
83
|
* coming: answering now reports "finished, no closing message" for live work and — worse —
|
|
81
84
|
* consumes the one-shot request plus the parent's claim latch, so the real closing message can
|
|
82
85
|
* never be delivered (observed: a worker mid-compaction announced finished; its final report
|
|
83
|
-
* never reached the parent). A user abort ('aborted') likewise settles a run that may re-run
|
|
86
|
+
* never reached the parent). A user abort ('aborted') likewise settles a run that may re-run, and
|
|
87
|
+
* an 'error' stop is an upstream failure pi retries — answering then consumes the latch for a
|
|
88
|
+
* parent's claim that the retry's closing message still has to satisfy.
|
|
84
89
|
*/
|
|
85
90
|
export function shouldPushSettleReply(input: {
|
|
86
91
|
intentionalAbort?: boolean;
|
|
@@ -92,5 +97,6 @@ export function shouldPushSettleReply(input: {
|
|
|
92
97
|
return !input.intentionalAbort
|
|
93
98
|
&& input.aborted !== true
|
|
94
99
|
&& !input.compactionInFlight
|
|
95
|
-
&& input.lastStopReason !== ABORT_STOP_REASON
|
|
100
|
+
&& input.lastStopReason !== ABORT_STOP_REASON
|
|
101
|
+
&& input.lastStopReason !== ERROR_STOP_REASON;
|
|
96
102
|
}
|
package/src/subagent-core.ts
CHANGED
|
@@ -114,7 +114,7 @@ export function buildResumeArgv(workerArgv: readonly string[], piBin = 'pi'): st
|
|
|
114
114
|
export const SUBAGENT_OUTPUT_SCHEMA = Type.Object({
|
|
115
115
|
action: Type.Union([
|
|
116
116
|
Type.Literal('spawn'), Type.Literal('resume'), Type.Literal('list'),
|
|
117
|
-
Type.Literal('send'), Type.Literal('interrupt'), Type.Literal('output'), Type.Literal('role'),
|
|
117
|
+
Type.Literal('send'), Type.Literal('interrupt'), Type.Literal('output'), Type.Literal('role'), Type.Literal('help'),
|
|
118
118
|
]),
|
|
119
119
|
paneId: Type.Optional(Type.String()),
|
|
120
120
|
taskId: Type.Optional(Type.String()),
|
|
@@ -195,6 +195,14 @@ export interface AliveProbe {
|
|
|
195
195
|
foregroundCwd?: string | null;
|
|
196
196
|
}
|
|
197
197
|
|
|
198
|
+
/** Trailing line for isolate spawn results: the branch (`pier/<slug>`) reads like a directory and
|
|
199
|
+
* masters have cd'd into `<repo>/pier/<slug>`; only the worktree path is an actual directory. */
|
|
200
|
+
export function formatIsolateLocation(
|
|
201
|
+
isolate: Pick<NonNullable<SubEntry['isolate']>, 'worktreePath' | 'branch'> | null | undefined,
|
|
202
|
+
): string {
|
|
203
|
+
return isolate ? `\nWorktree: ${isolate.worktreePath} (branch ${isolate.branch})` : '';
|
|
204
|
+
}
|
|
205
|
+
|
|
198
206
|
/**
|
|
199
207
|
* A subagent outlives its "no output" result: a working/blocked agent is alive regardless of session
|
|
200
208
|
* age, otherwise recent session writes decide. `paneExists: false` is death — an unavailable probe is
|
|
@@ -224,7 +232,7 @@ export function buildAliveNotice(
|
|
|
224
232
|
return [
|
|
225
233
|
`Subagent "${description}" is still running in pane ${paneId} (agent_status=${status}${activity}).`,
|
|
226
234
|
'The foreground wait has moved it to background so this call returns now.',
|
|
227
|
-
'Do NOT redo its work
|
|
235
|
+
'Do NOT redo its work and do NOT sleep or poll: when it settles, its result is delivered automatically as a settlement notice at your next turn boundary.',
|
|
228
236
|
'Use subagent(action: "list") to check its live state; subagent(action: "send") to give it follow-up work.',
|
|
229
237
|
].join(' ');
|
|
230
238
|
}
|
|
@@ -274,6 +282,9 @@ export interface SubEntry {
|
|
|
274
282
|
userTakeover?: boolean;
|
|
275
283
|
observationStartedAt?: number | null;
|
|
276
284
|
lastAgentStatus?: string | null;
|
|
285
|
+
/** The final report `subagent output` already returned (tail fingerprint + time): the settlement
|
|
286
|
+
* notice then points at that delivery instead of repeating the report. Durable across resume. */
|
|
287
|
+
reportDelivered?: { fingerprint: string; at: number } | null;
|
|
277
288
|
/** D98: isolate worktree metadata; `releasedAt` set once the worktree is removed. */
|
|
278
289
|
isolate?: {
|
|
279
290
|
worktreePath: string;
|
|
@@ -317,7 +328,30 @@ export function subRef(sub: { paneId: string; slug?: string }): string {
|
|
|
317
328
|
return sub.slug ? `${sub.slug}@${sub.paneId}` : sub.paneId;
|
|
318
329
|
}
|
|
319
330
|
|
|
320
|
-
|
|
331
|
+
/** Identity of a final report across the output action and the settlement notice: its tail. */
|
|
332
|
+
export function reportFingerprint(report: string): string {
|
|
333
|
+
return report.slice(-64);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** Shorter sleeps are ordinary polling of builds/logs; from a minute up, they were waits on workers. */
|
|
337
|
+
const SLEEP_NOTE_MIN_SECONDS = 60;
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Note for a master bash call that sleeps ≥ SLEEP_NOTE_MIN_SECONDS while background workers run:
|
|
341
|
+
* masters slept 660-840 s per worker although the settlement notice arrives on its own. Worded so a
|
|
342
|
+
* sleep that waits on something else (a load test, a build) is left alone.
|
|
343
|
+
*/
|
|
344
|
+
export function sleepWhileSubsRunningNote(
|
|
345
|
+
command: string,
|
|
346
|
+
running: ReadonlyArray<{ paneId: string; slug?: string }>,
|
|
347
|
+
): string | null {
|
|
348
|
+
if (running.length === 0) return null;
|
|
349
|
+
const longest = Math.max(0, ...[...command.matchAll(/\bsleep\s+(\d+)s?\b/g)].map((m) => Number(m[1])));
|
|
350
|
+
if (longest < SLEEP_NOTE_MIN_SECONDS) return null;
|
|
351
|
+
return `[pier] ${running.length} background subagent(s) running (${running.map(subRef).join(', ')}): their settlement `
|
|
352
|
+
+ 'notice arrives automatically at your next tool boundary, so do not sleep to wait for them — keep working or end '
|
|
353
|
+
+ 'your turn. Sleeping is only needed for other work (builds, logs).';
|
|
354
|
+
}
|
|
321
355
|
|
|
322
356
|
export interface SubsRegistry {
|
|
323
357
|
version: 2;
|
|
@@ -372,6 +406,10 @@ export function foldSubsRegistry(entries: ReadonlyArray<{ type?: string; customT
|
|
|
372
406
|
retainNotified: s.isolate.retainNotified === true,
|
|
373
407
|
}
|
|
374
408
|
: undefined,
|
|
409
|
+
reportDelivered: s.reportDelivered && typeof s.reportDelivered === 'object'
|
|
410
|
+
&& typeof s.reportDelivered.fingerprint === 'string' && typeof s.reportDelivered.at === 'number'
|
|
411
|
+
? { fingerprint: s.reportDelivered.fingerprint, at: s.reportDelivered.at }
|
|
412
|
+
: undefined,
|
|
375
413
|
}));
|
|
376
414
|
found = { version: 2, subs };
|
|
377
415
|
}
|
package/src/subagent-poller.ts
CHANGED
|
@@ -10,7 +10,7 @@ import { runtimePolicy, type RuntimePolicy } from './runtime-policy.ts';
|
|
|
10
10
|
import type { JevRuntime } from './jev-client.ts';
|
|
11
11
|
import { evaluateSettleVerdict, settleAskIsSafe, settleVerdictRequest } from './jev-core.ts';
|
|
12
12
|
import { formatSettlementNotice, type SettlementNullReason } from './vocab.ts';
|
|
13
|
-
import { buildBlockedGateNotice, sleep, type SubEntry } from './subagent-core.ts';
|
|
13
|
+
import { buildBlockedGateNotice, reportFingerprint, sleep, type SubEntry } from './subagent-core.ts';
|
|
14
14
|
import type { SessionIo } from './subagent-session.ts';
|
|
15
15
|
import type { GitIo } from './subagent-spawn.ts';
|
|
16
16
|
|
|
@@ -70,8 +70,17 @@ export function planObservationTick(input: {
|
|
|
70
70
|
agentStatus: string | null;
|
|
71
71
|
machineInjectAgoMs: number;
|
|
72
72
|
machineInjectGraceMs: number;
|
|
73
|
+
/** The transcript already holds a finalized answer (stopReason 'stop', no pending tool call). */
|
|
74
|
+
finalized?: boolean;
|
|
73
75
|
}): ObservationTickPlan {
|
|
74
|
-
if (!input.observationStartedAt)
|
|
76
|
+
if (!input.observationStartedAt) {
|
|
77
|
+
// A finalized answer on a pane that is not working settles on the detecting tick: the window
|
|
78
|
+
// would only delay the result, and the transcript cannot grow under an idle pane. A working or
|
|
79
|
+
// blocked pane keeps the window path — that is the human-takeover guard.
|
|
80
|
+
const paneBusy = input.agentStatus === 'working' || input.agentStatus === 'blocked';
|
|
81
|
+
if (input.finalized && !paneBusy) return { kind: 'settle' };
|
|
82
|
+
return { kind: 'start-observation' };
|
|
83
|
+
}
|
|
75
84
|
if (input.agentStatus === 'working') {
|
|
76
85
|
if (input.machineInjectAgoMs > input.machineInjectGraceMs) return { kind: 'user-takeover' };
|
|
77
86
|
return { kind: 'machine-inject-reset' };
|
|
@@ -118,8 +127,9 @@ export function buildSettlementNoticeText(
|
|
|
118
127
|
closing: string | null,
|
|
119
128
|
statLine: string | null,
|
|
120
129
|
nullReason: SettlementNullReason = 'silent',
|
|
130
|
+
reportDeliveredAt: number | null = null,
|
|
121
131
|
): string {
|
|
122
|
-
const base = formatSettlementNotice(agentLabel, closing, nullReason);
|
|
132
|
+
const base = formatSettlementNotice(agentLabel, closing, nullReason, reportDeliveredAt);
|
|
123
133
|
return statLine ? `${base}\n${statLine}` : base;
|
|
124
134
|
}
|
|
125
135
|
|
|
@@ -183,7 +193,7 @@ export interface PollerHost {
|
|
|
183
193
|
lastMachineInjectAt: Map<string, number>;
|
|
184
194
|
session: SessionIo;
|
|
185
195
|
git: GitIo;
|
|
186
|
-
injectNotice(content: string): Promise<void>;
|
|
196
|
+
injectNotice(content: string, paneId?: string): Promise<void>;
|
|
187
197
|
reconcileOnSettlement(description: string, outcome: 'settled' | 'failed'): string[];
|
|
188
198
|
withReconcileNotes(base: string, notes: readonly string[]): string;
|
|
189
199
|
claimSettleNotice(key: string): boolean;
|
|
@@ -222,6 +232,9 @@ export function createPoller(h: PollerHost): Poller {
|
|
|
222
232
|
): Promise<void> {
|
|
223
233
|
const startedAt = now();
|
|
224
234
|
let lastActivityAt = now();
|
|
235
|
+
/** Claimed settle key: on a rejected injection the claim stays ours, so the next tick retries
|
|
236
|
+
* the notice instead of suppressing it as someone else's. */
|
|
237
|
+
let claimedKey: string | null = null;
|
|
225
238
|
/** Stop watching this pane: persist + ledger first, or a restart replays a ghost running row. */
|
|
226
239
|
const consume = (outcome: string | null, via: string, at: number): void => {
|
|
227
240
|
const row = h.subs.get(paneId);
|
|
@@ -323,13 +336,6 @@ export function createPoller(h: PollerHost): Poller {
|
|
|
323
336
|
|
|
324
337
|
if (isSettlementCandidate(s)) {
|
|
325
338
|
const closing = s.text;
|
|
326
|
-
if (!entry.observationStartedAt) {
|
|
327
|
-
entry.observationStartedAt = now();
|
|
328
|
-
entry.lastAgentStatus = 'idle';
|
|
329
|
-
h.persistSubs();
|
|
330
|
-
await doSleep(OBSERVATION_TICK_MS);
|
|
331
|
-
continue;
|
|
332
|
-
}
|
|
333
339
|
let agentStatus: string | null = null;
|
|
334
340
|
try {
|
|
335
341
|
agentStatus = (await h.client.listAgents()).find((a) => a.paneId === paneId)?.status ?? null;
|
|
@@ -342,7 +348,15 @@ export function createPoller(h: PollerHost): Poller {
|
|
|
342
348
|
agentStatus,
|
|
343
349
|
machineInjectAgoMs: now() - (h.lastMachineInjectAt.get(paneId) ?? 0),
|
|
344
350
|
machineInjectGraceMs,
|
|
351
|
+
finalized: closing != null && !s.pendingTool,
|
|
345
352
|
});
|
|
353
|
+
if (obs.kind === 'start-observation') {
|
|
354
|
+
entry.observationStartedAt = now();
|
|
355
|
+
entry.lastAgentStatus = 'idle';
|
|
356
|
+
h.persistSubs();
|
|
357
|
+
await doSleep(OBSERVATION_TICK_MS);
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
346
360
|
if (obs.kind === 'user-takeover') {
|
|
347
361
|
entry.userTakeover = true;
|
|
348
362
|
entry.observationStartedAt = now();
|
|
@@ -409,19 +423,39 @@ export function createPoller(h: PollerHost): Poller {
|
|
|
409
423
|
await h.session.resolveSessionFile(paneId, cwd, undefined, { minMtimeMs: current.injectTs - 2_000 }),
|
|
410
424
|
);
|
|
411
425
|
}
|
|
412
|
-
|
|
426
|
+
// Claim before delivering: the pipe fast-path may already own this settlement's notice.
|
|
427
|
+
// The row is consumed only after the notice is accepted, and a claim we own is retried
|
|
428
|
+
// on the next tick — a rejected injection must not strand a finished worker as 'running'
|
|
429
|
+
// with no notice, nor consume the row while the result is still undelivered.
|
|
430
|
+
const claimKey = `${paneId}:${current.requestId}`;
|
|
431
|
+
if (claimedKey !== claimKey) {
|
|
432
|
+
if (!h.claimSettleNotice(claimKey)) {
|
|
433
|
+
consume(closing, 'poll-settle', now());
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
claimedKey = claimKey;
|
|
437
|
+
}
|
|
413
438
|
const notes = h.reconcileOnSettlement(current.description, 'settled');
|
|
414
439
|
const statLine = await h.git.worktreeStatLine(entry);
|
|
440
|
+
// `subagent output` may already have returned this exact report: point there, don't repeat it.
|
|
441
|
+
const delivered = closing && entry.reportDelivered?.fingerprint === reportFingerprint(closing)
|
|
442
|
+
? entry.reportDelivered.at
|
|
443
|
+
: null;
|
|
415
444
|
const notice = h.withReconcileNotes(
|
|
416
|
-
buildSettlementNoticeText(`${paneId} (${current.description})`, closing, statLine, nullReason),
|
|
445
|
+
buildSettlementNoticeText(`${paneId} (${current.description})`, closing, statLine, nullReason, delivered),
|
|
417
446
|
notes,
|
|
418
447
|
);
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
448
|
+
try {
|
|
449
|
+
// Tag the pane: a notice buffered during compaction must keep its row GC-exempt
|
|
450
|
+
// until the post-compaction flush actually delivers it.
|
|
451
|
+
await h.injectNotice(notice, paneId);
|
|
452
|
+
} catch {
|
|
453
|
+
// Notification refused (e.g. the master is compacting): leave the row running and
|
|
454
|
+
// re-detect the same closing text on the next tick.
|
|
455
|
+
await pace();
|
|
456
|
+
continue;
|
|
424
457
|
}
|
|
458
|
+
consume(closing, 'poll-settle', now());
|
|
425
459
|
return;
|
|
426
460
|
}
|
|
427
461
|
}
|
package/src/subagent-session.ts
CHANGED
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
type SessionEntryLike,
|
|
17
17
|
type SubSessionState,
|
|
18
18
|
} from './session-tail.ts';
|
|
19
|
-
import { idParam, sleep, type AliveProbe, type SubEntry } from './subagent-core.ts';
|
|
19
|
+
import { idParam, reportFingerprint, sleep, type AliveProbe, type SubEntry } from './subagent-core.ts';
|
|
20
20
|
import { toolError } from './tool-error.ts';
|
|
21
21
|
|
|
22
22
|
/* ── subagent output delta (pure) ───────────────────────────────── */
|
|
@@ -26,6 +26,61 @@ export const OUTPUT_HARD_CAP_CHARS = 16000;
|
|
|
26
26
|
const OUTPUT_MIN_OVERLAP = 16;
|
|
27
27
|
const OUTPUT_TAIL_CHARS = 500;
|
|
28
28
|
|
|
29
|
+
const BOX_CHARS = '│┃║╭╮╰╯┌┐└┘├┤┬┴┼─━═╌╍';
|
|
30
|
+
const FRAME_ONLY_LINE = new RegExp(`^[\\s${BOX_CHARS}]*[${BOX_CHARS}][\\s${BOX_CHARS}]*$`);
|
|
31
|
+
const SIDE_BORDER = /^\s*[│┃║] ?|\s*[│┃║]\s*$/g;
|
|
32
|
+
/** pi's status footer: `↑43k ↓13k R820k CH99.1% $0.017 …`, preceded by a `~/cwd (branch)` line. */
|
|
33
|
+
const PI_FOOTER_LINE = /^↑[\d.]+[kKmM]?\s+↓[\d.]+[kKmM]?\s/;
|
|
34
|
+
const PI_FOOTER_CWD_LINE = /^~?\/\S*( \([^)]*\))?$/;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Strip TUI chrome from a worker pane read: frame-only box-drawing rows, side borders, right-padding,
|
|
38
|
+
* pi's footer, and blank-line runs. A real read was ~20% whitespace, box glyphs and footer. Applied
|
|
39
|
+
* before the delta, so the cursor tracks the same cleaned text on every call.
|
|
40
|
+
*/
|
|
41
|
+
export function cleanPaneChrome(text: string): string {
|
|
42
|
+
// Frame rows are judged before border stripping, which would otherwise leave them as blank lines.
|
|
43
|
+
const lines = text.split('\n')
|
|
44
|
+
.filter((line) => !FRAME_ONLY_LINE.test(line))
|
|
45
|
+
.map((line) => line.replace(SIDE_BORDER, '').trimEnd());
|
|
46
|
+
const kept: string[] = [];
|
|
47
|
+
for (let i = 0; i < lines.length; i++) {
|
|
48
|
+
const line = lines[i]!;
|
|
49
|
+
if (PI_FOOTER_LINE.test(line)) continue;
|
|
50
|
+
if (PI_FOOTER_CWD_LINE.test(line) && PI_FOOTER_LINE.test(lines[i + 1] ?? '')) continue;
|
|
51
|
+
if (line === '' && kept[kept.length - 1] === '') continue;
|
|
52
|
+
kept.push(line);
|
|
53
|
+
}
|
|
54
|
+
return kept.join('\n');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Normalized-text length of the report opening that identifies the pane's rendered copy. */
|
|
58
|
+
const REPORT_KEY_CHARS = 24;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The pane shows the final report rendered (markdown stripped, long lines cut at pane width), so it
|
|
62
|
+
* is a lossy copy of the transcript text. When the transcript report is attached, cut the pane delta
|
|
63
|
+
* where the report's opening appears (whitespace and markdown markers ignored). Null: no copy found.
|
|
64
|
+
*/
|
|
65
|
+
export function cutPaneReportCopy(delta: string, report: string): string | null {
|
|
66
|
+
const isNoise = (ch: string): boolean => /[\s`*_#>]/.test(ch);
|
|
67
|
+
const key = [...report].filter((ch) => !isNoise(ch)).slice(0, REPORT_KEY_CHARS).join('');
|
|
68
|
+
if (key.length < REPORT_KEY_CHARS) return null;
|
|
69
|
+
let normalized = '';
|
|
70
|
+
const origin: number[] = [];
|
|
71
|
+
for (let i = 0; i < delta.length; i++) {
|
|
72
|
+
if (isNoise(delta[i]!)) continue;
|
|
73
|
+
normalized += delta[i];
|
|
74
|
+
origin.push(i);
|
|
75
|
+
}
|
|
76
|
+
const at = normalized.indexOf(key);
|
|
77
|
+
if (at < 0) return null;
|
|
78
|
+
const lineStart = delta.lastIndexOf('\n', origin[at]!) + 1;
|
|
79
|
+
const before = delta.slice(0, lineStart).trimEnd();
|
|
80
|
+
const marker = '(the pane\'s rendered copy of the final report is omitted — full text in the Subagent Report section below)';
|
|
81
|
+
return before ? `${before}\n${marker}` : marker;
|
|
82
|
+
}
|
|
83
|
+
|
|
29
84
|
export type SubagentStatus = 'running' | 'idle' | 'blocked' | 'settled';
|
|
30
85
|
|
|
31
86
|
export interface SubagentOutputCursor {
|
|
@@ -440,6 +495,8 @@ export async function executeSubagentOutput(
|
|
|
440
495
|
/** Last finalized assistant text from the worker's transcript: the pane read alone cannot
|
|
441
496
|
* recover it when the fullscreen TUI shows only the status overlay. Optional for tests. */
|
|
442
497
|
readFinalReport?: (paneId: string, entryCwd: string) => Promise<string | null>;
|
|
498
|
+
/** Persists `entry.reportDelivered` so the settlement notice can skip a report already shown. */
|
|
499
|
+
persistSubs?: () => void;
|
|
443
500
|
},
|
|
444
501
|
) {
|
|
445
502
|
const rawId = idParam(params, 'agentId', 'taskId');
|
|
@@ -472,8 +529,9 @@ export async function executeSubagentOutput(
|
|
|
472
529
|
const maxChars = typeof params?.max_chars === 'number'
|
|
473
530
|
? params.max_chars
|
|
474
531
|
: typeof params?.maxChars === 'number' ? params.maxChars : undefined;
|
|
475
|
-
const deltaResult = computeSubagentOutputDelta(prevCursor, rawRead.text, { maxChars });
|
|
476
|
-
|
|
532
|
+
const deltaResult = computeSubagentOutputDelta(prevCursor, cleanPaneChrome(rawRead.text), { maxChars });
|
|
533
|
+
// A resumed process has no cursor; the durable delivery record keeps the report from repeating.
|
|
534
|
+
deltaResult.nextCursor.reportTail ??= prevCursor?.reportTail ?? entry.reportDelivered?.fingerprint;
|
|
477
535
|
|
|
478
536
|
// Transcript fallback: the terminal delta can be empty (or footer-only) while the finished
|
|
479
537
|
// report exists only in the session file. Surface it once per distinct report.
|
|
@@ -481,11 +539,16 @@ export async function executeSubagentOutput(
|
|
|
481
539
|
if ((status === 'settled' || status === 'idle') && deps.readFinalReport) {
|
|
482
540
|
try {
|
|
483
541
|
const report = await deps.readFinalReport(entry.paneId, entry.cwd);
|
|
484
|
-
const fingerprint = report ? report
|
|
542
|
+
const fingerprint = report ? reportFingerprint(report) : '';
|
|
485
543
|
if (report && fingerprint && fingerprint !== deltaResult.nextCursor.reportTail) {
|
|
486
544
|
const bounded = boundText(report, Math.max(1, Math.min(maxChars ?? OUTPUT_DEFAULT_MAX_CHARS, OUTPUT_HARD_CAP_CHARS)));
|
|
487
545
|
reportSection = `[Subagent Report | latest finalized message from session transcript${bounded.truncated ? ' | truncated' : ''}]\n${bounded.text}`;
|
|
488
546
|
deltaResult.nextCursor.reportTail = fingerprint;
|
|
547
|
+
deltaResult.delta = cutPaneReportCopy(deltaResult.delta, report) ?? deltaResult.delta;
|
|
548
|
+
if (!bounded.truncated) {
|
|
549
|
+
entry.reportDelivered = { fingerprint, at: Date.now() };
|
|
550
|
+
deps.persistSubs?.();
|
|
551
|
+
}
|
|
489
552
|
}
|
|
490
553
|
} catch {
|
|
491
554
|
}
|