pi-pier 0.4.1 → 0.4.3
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 +25 -19
- package/schemas/role-manifest.schema.json +9 -9
- 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 +34 -20
- package/src/efficiency-store.ts +42 -5
- package/src/herdr-client.ts +15 -5
- package/src/index-notices.ts +1 -1
- package/src/index-pipe.ts +0 -8
- package/src/index.ts +71 -87
- package/src/jev-client.ts +32 -7
- package/src/jev-core.ts +70 -33
- package/src/manifest-compose.ts +4 -4
- 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 +44 -22
- package/src/reducer-invoker.ts +28 -14
- package/src/renderers.ts +1 -13
- package/src/role-loader.ts +8 -8
- package/src/role-manifest.ts +22 -22
- package/src/roles/master.json +1 -1
- 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/tree-view.ts +10 -10
- package/src/vocab.ts +7 -1
package/src/plugins/terminal.ts
CHANGED
|
@@ -4,6 +4,9 @@
|
|
|
4
4
|
* pi surface, the herdr client/environment and the state slot used by index GC; terminal-core holds the
|
|
5
5
|
* pure, independently testable logic.
|
|
6
6
|
*/
|
|
7
|
+
import { open } from 'node:fs/promises';
|
|
8
|
+
import { homedir } from 'node:os';
|
|
9
|
+
import { resolve as resolvePath } from 'node:path';
|
|
7
10
|
import { Context } from '@deepseek-ai/cordis';
|
|
8
11
|
import { Type } from 'typebox';
|
|
9
12
|
import { cordisGet } from '../cordis-services.ts';
|
|
@@ -18,14 +21,20 @@ import {
|
|
|
18
21
|
activeTerminalPaneIds,
|
|
19
22
|
classifyReadiness,
|
|
20
23
|
closeTerminal,
|
|
24
|
+
compilePattern,
|
|
21
25
|
computeIncrement,
|
|
22
26
|
classifyWaitMatch,
|
|
27
|
+
detectContinuationPrompt,
|
|
23
28
|
detectFullscreenTUI,
|
|
29
|
+
echoSafeWaitSuggestion,
|
|
30
|
+
excerptAroundMatch,
|
|
24
31
|
foldTerminalsRegistry,
|
|
25
32
|
makeTerminalsRegistry,
|
|
26
33
|
planTerminalReminder,
|
|
27
34
|
planShellInit,
|
|
28
35
|
promptStrategyFor,
|
|
36
|
+
regexLiteralPrefix,
|
|
37
|
+
redirectedSentinel,
|
|
29
38
|
registerTerminal,
|
|
30
39
|
sentinelEchoHazard,
|
|
31
40
|
stripAnsi,
|
|
@@ -45,7 +54,31 @@ type ToolParams = Record<string, unknown> | undefined;
|
|
|
45
54
|
/** Terminal tool actions; the tool schema union and the handler map are both keyed by this list. */
|
|
46
55
|
const TERMINAL_ACTIONS = ['open', 'send', 'wait', 'read', 'signal', 'close', 'list'] as const;
|
|
47
56
|
type TerminalAction = (typeof TERMINAL_ACTIONS)[number];
|
|
48
|
-
type ActionHandler = (params: ToolParams, ctx: unknown) => Promise<unknown>;
|
|
57
|
+
type ActionHandler = (params: ToolParams, ctx: unknown, signal?: AbortSignal) => Promise<unknown>;
|
|
58
|
+
|
|
59
|
+
/** File waits poll the log in-process; one tick per second keeps a 10-minute wait to ~600 reads. */
|
|
60
|
+
const FILE_WAIT_POLL_MS = 1000;
|
|
61
|
+
/** Only the log's tail is matched: a sentinel is the newest line, and a multi-GB log must not be read whole. */
|
|
62
|
+
const FILE_WAIT_TAIL_BYTES = 1024 * 1024;
|
|
63
|
+
|
|
64
|
+
/** The last `maxBytes` of a file as UTF-8, or null when it does not exist (yet). */
|
|
65
|
+
async function readFileTail(path: string, maxBytes: number): Promise<string | null> {
|
|
66
|
+
let handle;
|
|
67
|
+
try {
|
|
68
|
+
handle = await open(path, 'r');
|
|
69
|
+
} catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
try {
|
|
73
|
+
const { size } = await handle.stat();
|
|
74
|
+
const length = Math.min(size, maxBytes);
|
|
75
|
+
const buffer = Buffer.alloc(length);
|
|
76
|
+
await handle.read(buffer, 0, length, size - length);
|
|
77
|
+
return buffer.toString('utf8');
|
|
78
|
+
} finally {
|
|
79
|
+
await handle.close();
|
|
80
|
+
}
|
|
81
|
+
}
|
|
49
82
|
|
|
50
83
|
export interface TerminalStateSlot {
|
|
51
84
|
/** Lets index GC preserve panes that host active terminals. */
|
|
@@ -172,19 +205,20 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
172
205
|
});
|
|
173
206
|
turnStartedAt = null;
|
|
174
207
|
if (!plan.due || plan.content == null) return;
|
|
175
|
-
//
|
|
176
|
-
//
|
|
177
|
-
|
|
178
|
-
const ids = new Set(plan.ids);
|
|
179
|
-
terminals = terminals.map((t) => (ids.has(t.terminalId)
|
|
180
|
-
? { ...t, nudges: (t.nudges ?? (t.nudgedAt != null ? 1 : 0)) + 1, nudgedAt: now }
|
|
181
|
-
: t));
|
|
182
|
-
persistTerminals();
|
|
208
|
+
// Deliver the notice as a queued followUp WITHOUT triggerTurn — a reminder must never wake the
|
|
209
|
+
// agent. The per-terminal count is bumped only when the timer fires: agent_start cancels the
|
|
210
|
+
// grace timer, and a cancelled reminder must not consume one of the two allowed nudges.
|
|
183
211
|
const content = plan.content;
|
|
184
212
|
idleReminderTimer = setTimeout(() => {
|
|
185
213
|
idleReminderTimer = null;
|
|
186
214
|
const send = piSend.sendMessage;
|
|
187
215
|
if (typeof send !== 'function') return;
|
|
216
|
+
const now = Date.now();
|
|
217
|
+
const ids = new Set(plan.ids);
|
|
218
|
+
terminals = terminals.map((t) => (ids.has(t.terminalId) && t.status === 'open'
|
|
219
|
+
? { ...t, nudges: (t.nudges ?? (t.nudgedAt != null ? 1 : 0)) + 1, nudgedAt: now }
|
|
220
|
+
: t));
|
|
221
|
+
persistTerminals();
|
|
188
222
|
try {
|
|
189
223
|
// Fire-and-forget in pi (void); a synchronous throw is still swallowed here.
|
|
190
224
|
send(
|
|
@@ -222,31 +256,29 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
222
256
|
name: 'terminal',
|
|
223
257
|
label: 'Terminal',
|
|
224
258
|
description: [
|
|
225
|
-
'
|
|
226
|
-
'
|
|
227
|
-
'
|
|
228
|
-
'Long job pattern: send "cmd; echo TERM_DONE_$?" then wait with regex "^TERM_DONE_[0-9]" — the shell echoes the typed command, so a literal wait on "TERM_DONE_" would match its own echo instantly; keep the sentinel distinguishable from its echo. Redirect verbose output to a log file and read the file, because output between two reads is lost.',
|
|
259
|
+
'Persistent interactive shells in dedicated herdr panes; cwd, environment and background processes survive across calls.',
|
|
260
|
+
'Actions: open, send (type a command), wait (block until output matches), read, signal (ctrl+c/ctrl+d/ctrl+z/esc/enter), close, list.',
|
|
261
|
+
'Long job: send "cmd; echo TERM_DONE_$?" and wait with regex "^TERM_DONE_[0-9]" — the shell echoes typed commands, so a literal "TERM_DONE_" would match its own echo. When output and sentinel go to a log ("cmd > run.log 2>&1; echo DONE_$? >> run.log"), wait with file: "run.log" instead of sleeping in bash.',
|
|
229
262
|
].join(' '),
|
|
230
263
|
promptSnippet: 'terminal: a persistent shell in its own pane — use for dev servers, REPLs, and multi-step shell work that must keep state between calls.',
|
|
231
264
|
promptGuidelines: [
|
|
232
|
-
'Prefer bash for one-shot commands
|
|
233
|
-
'
|
|
234
|
-
'
|
|
235
|
-
'wait is for output you expect (a sentinel, a startup line); it returns the recent tail on timeout so you can decide instead of re-polling.',
|
|
236
|
-
'Close the terminal as soon as the work is done — a finished terminal keeps occupying a pane and is never auto-reclaimed.',
|
|
265
|
+
'Prefer bash for one-shot commands. Use terminal only when state must persist (server, REPL, watch loop, interactive prompt) or output appears later; open once and reuse the terminal_id.',
|
|
266
|
+
'wait returns the recent tail on timeout, so decide from it instead of re-polling.',
|
|
267
|
+
'Close the terminal when the work is done — it occupies a pane and is never auto-reclaimed.',
|
|
237
268
|
],
|
|
238
269
|
parameters: Type.Object({
|
|
239
|
-
action: Type.Union(TERMINAL_ACTIONS.map((name) => Type.Literal(name))
|
|
240
|
-
cwd: Type.Optional(Type.String({ description: '[open]
|
|
241
|
-
terminal_id: Type.Optional(Type.String({ description: '[open]
|
|
242
|
-
text: Type.Optional(Type.String({ description: '[send] Command text
|
|
243
|
-
wait_prompt: Type.Optional(Type.Boolean({ description: '[send]
|
|
244
|
-
pattern: Type.Optional(Type.String({ description: '[wait] Literal text or
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
270
|
+
action: Type.Union(TERMINAL_ACTIONS.map((name) => Type.Literal(name))),
|
|
271
|
+
cwd: Type.Optional(Type.String({ description: '[open] Shell working directory (default: session cwd)' })),
|
|
272
|
+
terminal_id: Type.Optional(Type.String({ description: '[open] Optional id for the new terminal (default term-N); [others] id returned by open' })),
|
|
273
|
+
text: Type.Optional(Type.String({ description: '[send] Command text (Enter appended; multi-line runs line by line)' })),
|
|
274
|
+
wait_prompt: Type.Optional(Type.Boolean({ description: '[send] Refuse instead of queueing when the shell is not at a prompt' })),
|
|
275
|
+
pattern: Type.Optional(Type.String({ description: '[wait] Literal text or regex to wait for (must not occur in the sent command text)' })),
|
|
276
|
+
file: Type.Optional(Type.String({ description: '[wait] Watch this log file instead of the pane (relative to the terminal cwd; may not exist yet)' })),
|
|
277
|
+
regex: Type.Optional(Type.Boolean({ description: '[wait] pattern is a regex (default literal)' })),
|
|
278
|
+
timeout_ms: Type.Optional(Type.Number({ description: '[wait] Default 120000, max 600000' })),
|
|
279
|
+
pane_id: Type.Optional(Type.String({ description: '[read] Read another pane in your own tab' })),
|
|
280
|
+
max_chars: Type.Optional(Type.Number({ description: `[read] Output cap (default ${READ_MAX_CHARS})` })),
|
|
281
|
+
key: Type.Optional(Type.String({ description: '[signal] ctrl+c | ctrl+d | ctrl+z | esc | enter' })),
|
|
250
282
|
}),
|
|
251
283
|
async execute(_tc: string, params: ToolParams, _sig: AbortSignal | undefined, _upd: unknown, ctx: unknown) {
|
|
252
284
|
const action = typeof params?.action === 'string' ? params.action : '';
|
|
@@ -257,7 +289,7 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
257
289
|
? `unknown action "${action}" (valid: ${valid})`
|
|
258
290
|
: `action is required (${valid})`);
|
|
259
291
|
}
|
|
260
|
-
return handler(params, ctx);
|
|
292
|
+
return handler(params, ctx, _sig);
|
|
261
293
|
},
|
|
262
294
|
});
|
|
263
295
|
|
|
@@ -274,8 +306,9 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
274
306
|
}
|
|
275
307
|
}
|
|
276
308
|
|
|
277
|
-
/** Best-effort
|
|
278
|
-
* returns whether the command was injected, so a shell not
|
|
309
|
+
/** Best-effort history-expansion init right after a shell becomes usable (zsh needs
|
|
310
|
+
* `setopt NO_BANG_HIST`, bash `set +H`); returns whether the command was injected, so a shell not
|
|
311
|
+
* yet at its prompt retries later. */
|
|
279
312
|
async function injectShellInit(entry: TerminalEntry, readiness?: ReadinessTier): Promise<boolean> {
|
|
280
313
|
const init = planShellInit({ strategy: promptStrategyFor(), readiness });
|
|
281
314
|
if (!init.shouldInit || !init.command) return false;
|
|
@@ -301,12 +334,28 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
301
334
|
createdAt: Date.now(),
|
|
302
335
|
});
|
|
303
336
|
if (!r.ok) return fail(r.error);
|
|
337
|
+
// Stack new terminals below the most recent one instead of halving the pi master pane again:
|
|
338
|
+
// every right-split of the master shrinks both (45 → 22 → 11 columns), which breaks output
|
|
339
|
+
// wrapping and multi-word waits. The first terminal still splits the master to the right.
|
|
340
|
+
const anchor = [...terminals].reverse().find((t) => t.status === 'open') ?? null;
|
|
341
|
+
const targetPaneId = anchor?.paneId ?? env.paneId;
|
|
342
|
+
const direction = anchor ? 'down' : 'right';
|
|
304
343
|
let paneId: string;
|
|
305
344
|
try {
|
|
306
|
-
paneId = await client.splitPane({ direction
|
|
345
|
+
paneId = await client.splitPane({ direction, cwd, focus: false, targetPaneId });
|
|
307
346
|
} catch (e) {
|
|
308
|
-
|
|
309
|
-
|
|
347
|
+
if (!anchor) {
|
|
348
|
+
const hint = herdrUnavailableHint(e);
|
|
349
|
+
return fail(hint ?? `failed to split terminal pane: ${(e as Error).message}`);
|
|
350
|
+
}
|
|
351
|
+
// A terminal restored from an earlier session can point at a pane herdr no longer has: fall
|
|
352
|
+
// back to the master instead of failing to open at all.
|
|
353
|
+
try {
|
|
354
|
+
paneId = await client.splitPane({ direction: 'right', cwd, focus: false, targetPaneId: env.paneId });
|
|
355
|
+
} catch (fallbackError) {
|
|
356
|
+
const hint = herdrUnavailableHint(fallbackError);
|
|
357
|
+
return fail(hint ?? `failed to split terminal pane: ${(fallbackError as Error).message}`);
|
|
358
|
+
}
|
|
310
359
|
}
|
|
311
360
|
r.entry.paneId = paneId;
|
|
312
361
|
terminals = r.entries;
|
|
@@ -330,9 +379,10 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
330
379
|
const v = validateSendText(typeof params?.text === 'string' ? params.text : '');
|
|
331
380
|
if (!v.ok) return fail(v.error);
|
|
332
381
|
if (!entry.initialized) {
|
|
333
|
-
await injectShellInit(entry)
|
|
334
|
-
|
|
335
|
-
|
|
382
|
+
if (await injectShellInit(entry)) {
|
|
383
|
+
entry.initialized = true;
|
|
384
|
+
persistTerminals();
|
|
385
|
+
}
|
|
336
386
|
}
|
|
337
387
|
if (params?.wait_prompt === true) {
|
|
338
388
|
// Orchestration convention: read before writing. Text typed while a previous command still owns the
|
|
@@ -356,10 +406,31 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
356
406
|
// received, because the shell echoes typed commands back into the pane.
|
|
357
407
|
lastSentText.set(entry.terminalId, v.text);
|
|
358
408
|
touchTerminal(entry);
|
|
359
|
-
|
|
409
|
+
// An incomplete command leaves the shell at a secondary prompt (`dquote>`, `heredoc>`, …):
|
|
410
|
+
// every later send is swallowed by it, so surface the wedge instead of reporting success.
|
|
411
|
+
let pending: string | null = null;
|
|
412
|
+
try {
|
|
413
|
+
const snapshot = await client.readPane(entry.paneId, { stripAnsi: false });
|
|
414
|
+
pending = detectContinuationPrompt(stripAnsi(snapshot.text));
|
|
415
|
+
} catch {
|
|
416
|
+
/* A pane read is advisory here: a transport failure must not turn a successful send into an error. */
|
|
417
|
+
}
|
|
418
|
+
if (pending) {
|
|
419
|
+
return fail(
|
|
420
|
+
`shell is waiting for more input (${pending}) — the sent command left an incomplete quote, heredoc, or block, `
|
|
421
|
+
+ 'and later text is swallowed by that continuation prompt. Send the missing terminator, or interrupt with '
|
|
422
|
+
+ 'terminal(action: "signal", key: "ctrl+c"), then re-send a complete command.',
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
const hint = redirectedSentinel(v.text);
|
|
426
|
+
const fileHint = hint
|
|
427
|
+
? `\nnote: the sentinel ${hint.prefix}$? is written to ${hint.file}, not the pane — wait for it with `
|
|
428
|
+
+ `terminal(action: "wait", terminal_id: "${entry.terminalId}", file: "${hint.file}", pattern: "${echoSafeWaitSuggestion(hint.prefix) ?? hint.prefix}", regex: true) instead of sleeping.`
|
|
429
|
+
: '';
|
|
430
|
+
return { content: [{ type: 'text', text: `sent to ${entry.terminalId} (${v.text.length} chars)${fileHint}` }], details: { terminal_id: entry.terminalId } };
|
|
360
431
|
}
|
|
361
432
|
|
|
362
|
-
async function executeTerminalWait(params: ToolParams) {
|
|
433
|
+
async function executeTerminalWait(params: ToolParams, _ctx?: unknown, signal?: AbortSignal) {
|
|
363
434
|
const entry = findOpenTerminal(params?.terminal_id);
|
|
364
435
|
if (!entry) return fail(`unknown or closed terminal "${String(params?.terminal_id)}" (see action list)`);
|
|
365
436
|
// Waiting on a pane is use: keep it out of the "used this turn" cleanup nudge at settle.
|
|
@@ -369,23 +440,34 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
369
440
|
const useRegex = params?.regex === true;
|
|
370
441
|
if (useRegex) {
|
|
371
442
|
try {
|
|
372
|
-
|
|
443
|
+
compilePattern(raw);
|
|
373
444
|
} catch (e) {
|
|
374
445
|
return fail(`invalid regex: ${(e as Error).message}`);
|
|
375
446
|
}
|
|
376
447
|
}
|
|
448
|
+
if (typeof params?.file === 'string' && params.file.trim()) {
|
|
449
|
+
const file = params.file.trim();
|
|
450
|
+
const path = file.startsWith('~/') ? resolvePath(homedir(), file.slice(2)) : resolvePath(entry.cwd, file);
|
|
451
|
+
return waitForFile(entry, path, raw, useRegex, params, signal);
|
|
452
|
+
}
|
|
377
453
|
// The shell echoes typed commands into the pane, so a pattern contained in the last sent text
|
|
378
454
|
// matches its own echo and "succeeds" instantly, before the job finishes (observed: a
|
|
379
455
|
// minutes-long batch matched in 112ms on the echo of its own `echo SENTINEL`).
|
|
380
456
|
if (sentinelEchoHazard({ pattern: raw, regex: useRegex, lastSentText: lastSentText.get(entry.terminalId) })) {
|
|
381
457
|
const shown = raw.length > 80 ? `${raw.slice(0, 80)}…` : raw;
|
|
458
|
+
const literal = useRegex ? regexLiteralPrefix(raw) : raw;
|
|
459
|
+
const suggestion = echoSafeWaitSuggestion(literal);
|
|
460
|
+
const advice = suggestion
|
|
461
|
+
? `For future sends, use a sentinel distinguishable from its echo: send \`cmd; echo ${literal}$?\` and wait with regex "${suggestion}" `
|
|
462
|
+
+ '(the echoed command shows `$?`, which that regex cannot match).'
|
|
463
|
+
: 'For future sends, use a sentinel distinguishable from its echo: send `cmd; echo DONE_$?` and wait with regex "^DONE_[0-9]" '
|
|
464
|
+
+ '(the echo contains `DONE_$?`, which that regex cannot match).';
|
|
382
465
|
return fail(
|
|
383
466
|
`pattern "${shown}" also appears in the command text sent to ${entry.terminalId} — the shell echoes typed commands, `
|
|
384
467
|
+ 'so the wait would match its own echo and return before the job finishes. '
|
|
385
468
|
+ 'That command is still running: do NOT re-send it (text typed behind a running command just queues). '
|
|
386
469
|
+ 'Watch progress with action read, or wait for a marker the running command itself prints that does not occur in the command text. '
|
|
387
|
-
+
|
|
388
|
-
+ '(the echo contains `DONE_$?`, which that regex cannot match).',
|
|
470
|
+
+ advice,
|
|
389
471
|
);
|
|
390
472
|
}
|
|
391
473
|
const requested = typeof params?.timeout_ms === 'number' && params.timeout_ms > 0 ? params.timeout_ms : WAIT_DEFAULT_MS;
|
|
@@ -406,7 +488,10 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
406
488
|
tail = stripAnsi(read.text).slice(-WAIT_TAIL_CHARS);
|
|
407
489
|
} catch {
|
|
408
490
|
}
|
|
409
|
-
|
|
491
|
+
// A wait that consumed its budget is a timeout even when the failing RPC reports itself as
|
|
492
|
+
// unavailable: the distinction tells the model whether to re-check the command or the server.
|
|
493
|
+
const consumedBudget = Date.now() - started >= timeoutMs * 0.95;
|
|
494
|
+
const reason = waitResult.reason === 'timeout' || consumedBudget ? 'timeout' : 'wait unavailable';
|
|
410
495
|
return {
|
|
411
496
|
content: [{ type: 'text', text: `no match within ${timeoutMs}ms (${reason}).${tail ? `\nrecent output tail:\n${tail}` : ''}` }],
|
|
412
497
|
details: { terminal_id: entry.terminalId, matched: false, pattern: raw, reason },
|
|
@@ -418,7 +503,13 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
418
503
|
// waitForOutput (that busy-loops on the old match).
|
|
419
504
|
const deadline = started + timeoutMs;
|
|
420
505
|
const cursor: ReadCursor | null = entry.readRevision != null
|
|
421
|
-
? {
|
|
506
|
+
? {
|
|
507
|
+
revision: entry.readRevision,
|
|
508
|
+
len: entry.readLen ?? 0,
|
|
509
|
+
tail: entry.readTail ?? '',
|
|
510
|
+
textLen: entry.readTextLen ?? 0,
|
|
511
|
+
textHash: entry.readTextHash ?? 0,
|
|
512
|
+
}
|
|
422
513
|
: null;
|
|
423
514
|
const shown = raw.length > 80 ? `${raw.slice(0, 80)}…` : raw;
|
|
424
515
|
let sawStale = false;
|
|
@@ -460,6 +551,53 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
460
551
|
}
|
|
461
552
|
}
|
|
462
553
|
|
|
554
|
+
/** `wait` on a log file: the sentinel of a redirected job never reaches the pane. A match already in
|
|
555
|
+
* the file when the wait starts returns at once, labelled, so a leftover from an earlier run is visible. */
|
|
556
|
+
async function waitForFile(
|
|
557
|
+
entry: TerminalEntry,
|
|
558
|
+
path: string,
|
|
559
|
+
pattern: string,
|
|
560
|
+
useRegex: boolean,
|
|
561
|
+
params: ToolParams,
|
|
562
|
+
signal: AbortSignal | undefined,
|
|
563
|
+
) {
|
|
564
|
+
const requested = typeof params?.timeout_ms === 'number' && params.timeout_ms > 0 ? params.timeout_ms : WAIT_DEFAULT_MS;
|
|
565
|
+
const timeoutMs = Math.min(Math.max(requested, 1000), WAIT_MAX_MS);
|
|
566
|
+
const started = Date.now();
|
|
567
|
+
const matches = (text: string) => (useRegex ? compilePattern(pattern).test(text) : text.includes(pattern));
|
|
568
|
+
let text: string | null = null;
|
|
569
|
+
let wake: (() => void) | null = null;
|
|
570
|
+
const onAbort = () => wake?.();
|
|
571
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
572
|
+
try {
|
|
573
|
+
for (let tick = 0; ; tick += 1) {
|
|
574
|
+
text = await readFileTail(path, FILE_WAIT_TAIL_BYTES);
|
|
575
|
+
if (text !== null && matches(text)) {
|
|
576
|
+
const when = tick === 0 ? 'already present when the wait started (truncate the log per run if this is an old one)' : `appeared after ${Math.round((Date.now() - started) / 1000)}s`;
|
|
577
|
+
return {
|
|
578
|
+
content: [{ type: 'text', text: `matched in ${path} — ${when}:\n${excerptAroundMatch(text, pattern, useRegex)}` }],
|
|
579
|
+
details: { terminal_id: entry.terminalId, matched: true, pattern, file: path, alreadyPresent: tick === 0 },
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
const left = started + timeoutMs - Date.now();
|
|
583
|
+
if (left <= 0 || signal?.aborted) break;
|
|
584
|
+
const { promise, resolve } = Promise.withResolvers<void>();
|
|
585
|
+
wake = resolve;
|
|
586
|
+
const timer = setTimeout(resolve, Math.min(FILE_WAIT_POLL_MS, left));
|
|
587
|
+
await promise;
|
|
588
|
+
clearTimeout(timer);
|
|
589
|
+
}
|
|
590
|
+
} finally {
|
|
591
|
+
signal?.removeEventListener('abort', onAbort);
|
|
592
|
+
}
|
|
593
|
+
const reason = signal?.aborted ? 'aborted' : 'timeout';
|
|
594
|
+
const tail = text === null ? `\n${path} does not exist.` : text ? `\nrecent file tail:\n${text.slice(-WAIT_TAIL_CHARS)}` : `\n${path} is empty.`;
|
|
595
|
+
return {
|
|
596
|
+
content: [{ type: 'text', text: `no match in ${path} within ${timeoutMs}ms (${reason}).${tail}` }],
|
|
597
|
+
details: { terminal_id: entry.terminalId, matched: false, pattern, file: path, reason },
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
|
|
463
601
|
/** T5: a direct `pane_id` read is limited to panes in this session's own tab or to self-created terminal
|
|
464
602
|
* panes, so one task can never read another's pane. Returns the error text instead of throwing. */
|
|
465
603
|
async function resolveDirectPaneId(paneId: string): Promise<{ ok: true; paneId: string } | { ok: false; error: string }> {
|
|
@@ -514,7 +652,13 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
514
652
|
}
|
|
515
653
|
const prev: ReadCursor | null = entry
|
|
516
654
|
? (entry.readRevision != null
|
|
517
|
-
? {
|
|
655
|
+
? {
|
|
656
|
+
revision: entry.readRevision,
|
|
657
|
+
len: entry.readLen ?? 0,
|
|
658
|
+
tail: entry.readTail ?? '',
|
|
659
|
+
textLen: entry.readTextLen ?? 0,
|
|
660
|
+
textHash: entry.readTextHash ?? 0,
|
|
661
|
+
}
|
|
518
662
|
: null)
|
|
519
663
|
: null;
|
|
520
664
|
const inc = computeIncrement(prev, { text: read.text, revision: read.revision }, maxChars);
|
|
@@ -522,7 +666,8 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
522
666
|
entry.readRevision = inc.cursor.revision;
|
|
523
667
|
entry.readLen = inc.cursor.len;
|
|
524
668
|
entry.readTail = inc.cursor.tail;
|
|
525
|
-
entry.
|
|
669
|
+
entry.readTextLen = inc.cursor.textLen;
|
|
670
|
+
entry.readTextHash = inc.cursor.textHash;
|
|
526
671
|
touchTerminal(entry);
|
|
527
672
|
}
|
|
528
673
|
if (inc.mode === 'none') {
|
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) {
|
|
@@ -354,7 +376,7 @@ export default function todoPlugin(ctx: Context): void {
|
|
|
354
376
|
}
|
|
355
377
|
const age = archivedAgeMs();
|
|
356
378
|
const head = formatTodoSummary(todos.items)
|
|
357
|
-
+ (age != null ? ` · archived ${formatAge(age)}
|
|
379
|
+
+ (age != null ? ` · archived ${formatAge(age)} (no longer injected)` : '');
|
|
358
380
|
ui?.notify?.([head, ...body].join('\n'), 'info');
|
|
359
381
|
},
|
|
360
382
|
});
|
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']>;
|
|
@@ -103,7 +105,7 @@ function resolveCandidate(
|
|
|
103
105
|
if (!isTrusted) {
|
|
104
106
|
if (!untrustedWarningEmitted) {
|
|
105
107
|
untrustedWarningEmitted = true;
|
|
106
|
-
console.warn('[pi-herdr] EPR
|
|
108
|
+
console.warn('[pi-herdr] EPR refinement is safely disabled in untrusted projects');
|
|
107
109
|
}
|
|
108
110
|
return undefined;
|
|
109
111
|
}
|
|
@@ -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,
|