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/subagent-spawn.ts
CHANGED
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
buildLaunchLine,
|
|
28
28
|
buildLaunchParts,
|
|
29
29
|
classifyWorktreeZone,
|
|
30
|
+
formatIsolateLocation,
|
|
30
31
|
formatSubagentResult,
|
|
31
32
|
formatWorktreeStat,
|
|
32
33
|
isAlive,
|
|
@@ -592,7 +593,7 @@ export function createSpawnAction(h: SpawnActionHost) {
|
|
|
592
593
|
delegationEnvPatch = delegationEnv({
|
|
593
594
|
parentPaneId: env?.paneId ?? '',
|
|
594
595
|
parentSessionId: bareSessionId(h.getSessionId()),
|
|
595
|
-
//
|
|
596
|
+
// The master's own pipe so the worker's todo_sync push can dial home (D49).
|
|
596
597
|
parentPipe: pipeNameFor(masterCwd, env?.paneId ?? ''),
|
|
597
598
|
taskId,
|
|
598
599
|
description: spec.description,
|
|
@@ -733,7 +734,7 @@ export function createSpawnAction(h: SpawnActionHost) {
|
|
|
733
734
|
if (background) {
|
|
734
735
|
handToPoller(`prompt-${taskId}`);
|
|
735
736
|
return {
|
|
736
|
-
content: [{ type: 'text', text: `started subagent ${subRef(entry)} (task ${taskId})` }],
|
|
737
|
+
content: [{ type: 'text', text: `started subagent ${subRef(entry)} (task ${taskId})${formatIsolateLocation(isolateMeta)}` }],
|
|
737
738
|
details: { paneId, taskId, background: true, role: kind, slug },
|
|
738
739
|
};
|
|
739
740
|
}
|
|
@@ -752,7 +753,7 @@ export function createSpawnAction(h: SpawnActionHost) {
|
|
|
752
753
|
const question = await readAskFlag(paneId);
|
|
753
754
|
handToPoller(`prompt-${taskId}`);
|
|
754
755
|
return {
|
|
755
|
-
content: [{ type: 'text', text: buildBlockedGateNotice({ paneId, description: spec.description, question }) }],
|
|
756
|
+
content: [{ type: 'text', text: `${buildBlockedGateNotice({ paneId, description: spec.description, question })}${formatIsolateLocation(isolateMeta)}` }],
|
|
756
757
|
details: { paneId, taskId, background: true, blocked: true, role: kind },
|
|
757
758
|
};
|
|
758
759
|
}
|
|
@@ -776,7 +777,7 @@ export function createSpawnAction(h: SpawnActionHost) {
|
|
|
776
777
|
if (isAlive(probe, Date.now())) {
|
|
777
778
|
handToPoller(`prompt-${taskId}`);
|
|
778
779
|
return {
|
|
779
|
-
content: [{ type: 'text', text: buildAliveNotice({ paneId, description: spec.description, scenario: 'moved-to-bg', probe }, Date.now()) }],
|
|
780
|
+
content: [{ type: 'text', text: `${buildAliveNotice({ paneId, description: spec.description, scenario: 'moved-to-bg', probe }, Date.now())}${formatIsolateLocation(isolateMeta)}` }],
|
|
780
781
|
details: { paneId, taskId, background: true, movedToBackground: true, role: kind },
|
|
781
782
|
};
|
|
782
783
|
}
|
|
@@ -791,7 +792,7 @@ export function createSpawnAction(h: SpawnActionHost) {
|
|
|
791
792
|
h.persistSubs();
|
|
792
793
|
h.writeHistory(entry, { outcome: outcome.kind === 'completed' ? text : null }, 'fg-settle');
|
|
793
794
|
return {
|
|
794
|
-
content: [{ type: 'text', text: formatSubagentResult(outcome, spec.description) }],
|
|
795
|
+
content: [{ type: 'text', text: `${formatSubagentResult(outcome, spec.description)}${formatIsolateLocation(isolateMeta)}` }],
|
|
795
796
|
details: { paneId, taskId, background, role: kind },
|
|
796
797
|
};
|
|
797
798
|
} catch (err) {
|
package/src/terminal-core.ts
CHANGED
|
@@ -78,7 +78,8 @@ export interface TerminalEntry {
|
|
|
78
78
|
readRevision?: number | null;
|
|
79
79
|
readLen?: number;
|
|
80
80
|
readTail?: string;
|
|
81
|
-
|
|
81
|
+
readTextLen?: number;
|
|
82
|
+
readTextHash?: number;
|
|
82
83
|
initialized?: boolean;
|
|
83
84
|
}
|
|
84
85
|
|
|
@@ -132,7 +133,8 @@ export function registerTerminal(
|
|
|
132
133
|
readRevision: null,
|
|
133
134
|
readLen: 0,
|
|
134
135
|
readTail: '',
|
|
135
|
-
|
|
136
|
+
readTextLen: 0,
|
|
137
|
+
readTextHash: 0,
|
|
136
138
|
};
|
|
137
139
|
return { ok: true, entries: [...entries, entry], entry };
|
|
138
140
|
}
|
|
@@ -203,12 +205,61 @@ export function validateSignal(key: string): { ok: true; key: SignalKey } | { ok
|
|
|
203
205
|
return { ok: true, key: k };
|
|
204
206
|
}
|
|
205
207
|
|
|
208
|
+
/** Compile a wait pattern with line semantics: herdr matches per line, so `^`/`$` anchors must bind
|
|
209
|
+
* to line starts/ends, not only the start/end of the whole pane buffer. */
|
|
210
|
+
export function compilePattern(pattern: string): RegExp {
|
|
211
|
+
return new RegExp(pattern, 'm');
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** The literal prefix of a regex source (leading `^` ignored, stops at the first metacharacter).
|
|
215
|
+
* Lets the echo guard notice `DONE_[0-9]` in a command that echoes `DONE_$?`, which the full regex
|
|
216
|
+
* cannot match even though the sentinel is plainly present in the typed text. */
|
|
217
|
+
export function regexLiteralPrefix(pattern: string): string {
|
|
218
|
+
let prefix = '';
|
|
219
|
+
for (let i = 0; i < pattern.length; i += 1) {
|
|
220
|
+
const ch = pattern[i]!;
|
|
221
|
+
if (ch === '^' && prefix.length === 0) continue;
|
|
222
|
+
if (ch === '\\') {
|
|
223
|
+
const next = pattern[i + 1];
|
|
224
|
+
if (next == null || /[dDwWsSbBAZzG]/.test(next)) break;
|
|
225
|
+
prefix += next;
|
|
226
|
+
i += 1;
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
if ('.*+?^${}()|[]'.includes(ch)) break;
|
|
230
|
+
prefix += ch;
|
|
231
|
+
}
|
|
232
|
+
return prefix;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** A concrete, echo-safe wait pattern derived from the literal prefix: anchored at a line start and
|
|
236
|
+
* expecting the numeric sentinel suffix (`APN_BUILD2_EXIT_$?` → `^APN_BUILD2_EXIT_[0-9]`). Trailing
|
|
237
|
+
* digits after a separator are dropped so a literal `..._0` still derives `..._[0-9]`; digits glued
|
|
238
|
+
* to a name (`step2`) stay, because they are part of the marker. */
|
|
239
|
+
export function echoSafeWaitSuggestion(literal: string): string | null {
|
|
240
|
+
const base = literal.replace(/([^A-Za-z0-9])\d+$/, '$1');
|
|
241
|
+
if (!base) return null;
|
|
242
|
+
return `^${base.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[0-9]`;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* A `cmd; echo RUN_EXIT_$? >> /tmp/run.log` sentinel lands in the log file, never in the pane a
|
|
247
|
+
* plain wait watches: one session slept 240-420 s at a time polling such a log with bash. Returns
|
|
248
|
+
* the sentinel's literal prefix and the file it is redirected into, or null.
|
|
249
|
+
*/
|
|
250
|
+
export function redirectedSentinel(text: string): { prefix: string; file: string } | null {
|
|
251
|
+
const m = /\becho\s+["']?([A-Za-z][A-Za-z0-9_]*?)\$\?["']?\s*>>?\s*(["']?)([^\s;&|"']+)\2/.exec(text);
|
|
252
|
+
return m ? { prefix: m[1]!, file: m[3]! } : null;
|
|
253
|
+
}
|
|
254
|
+
|
|
206
255
|
/**
|
|
207
256
|
* Whether a wait pattern would match the terminal's own echoed input: the shell echoes typed
|
|
208
257
|
* commands into the pane, so a pattern contained in the last sent text (`…; echo TERM_DONE_$?`
|
|
209
258
|
* waited on as literal `TERM_DONE_`) matches its own echo and returns "matched" before the job
|
|
210
|
-
* has produced anything.
|
|
211
|
-
*
|
|
259
|
+
* has produced anything. A regex whose literal prefix occurs in the echo is also a hazard: the
|
|
260
|
+
* sentinel `DONE_$?` becomes `DONE_<status>` only after the command runs.
|
|
261
|
+
* Detection is per terminal and best-effort: no send in this process lifetime → no hazard known →
|
|
262
|
+
* the wait proceeds unchanged.
|
|
212
263
|
*/
|
|
213
264
|
export function sentinelEchoHazard(opts: {
|
|
214
265
|
pattern: string;
|
|
@@ -219,7 +270,13 @@ export function sentinelEchoHazard(opts: {
|
|
|
219
270
|
if (!sent) return false;
|
|
220
271
|
if (opts.regex) {
|
|
221
272
|
try {
|
|
222
|
-
|
|
273
|
+
const re = compilePattern(opts.pattern);
|
|
274
|
+
if (re.test(sent)) return true;
|
|
275
|
+
// An anchored pattern is already safe when the echo cannot start a line with its literal
|
|
276
|
+
// prefix (`^DONE_[0-9]` vs `cmd; echo DONE_$?`); only unanchored patterns need the substring check.
|
|
277
|
+
const prefix = regexLiteralPrefix(opts.pattern);
|
|
278
|
+
if (!opts.pattern.startsWith('^') && prefix.length >= 3 && sent.includes(prefix)) return true;
|
|
279
|
+
return sent.includes('$?') && re.test(sent.replace(/\$\?/g, '0'));
|
|
223
280
|
} catch {
|
|
224
281
|
return false; // invalid regex is rejected by the caller before this runs
|
|
225
282
|
}
|
|
@@ -242,11 +299,29 @@ export function stripAnsi(raw: string): string {
|
|
|
242
299
|
.replace(/[\x00-\x08\x0b-\x1f\x7f]/g, '');
|
|
243
300
|
}
|
|
244
301
|
|
|
302
|
+
/** Secondary prompts (`dquote>`, `heredoc>`, `cmdand>`, …) mean the shell is still collecting input:
|
|
303
|
+
* the command was incomplete (unbalanced quote, open block, …) and every later line types into it.
|
|
304
|
+
* Only the last non-empty line is inspected, so `>` in ordinary output cannot trip the check. */
|
|
305
|
+
const CONTINUATION_PROMPT_RE = /^(?:quote|dquote|bquote|heredoc|cmdand|cmdor|cmdsubst|subsh|then|else|elif|do|done|for|while|if|fi|case|esac|select|repeat|function|math|brace|foreach|cond)>$/i;
|
|
306
|
+
|
|
307
|
+
export function detectContinuationPrompt(text: string): string | null {
|
|
308
|
+
const lines = text.split('\n');
|
|
309
|
+
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
|
310
|
+
const line = lines[i]!.trim();
|
|
311
|
+
if (!line) continue;
|
|
312
|
+
return CONTINUATION_PROMPT_RE.test(line) ? line : null;
|
|
313
|
+
}
|
|
314
|
+
return null;
|
|
315
|
+
}
|
|
316
|
+
|
|
245
317
|
export interface ReadCursor {
|
|
246
318
|
revision: number;
|
|
247
319
|
len: number;
|
|
248
320
|
tail: string;
|
|
249
|
-
|
|
321
|
+
/** Full pane-text length and FNV-1a hash: herdr's screen window can rewrite earlier lines while
|
|
322
|
+
* the last characters stay identical, so a tail comparison alone is not a change detector. */
|
|
323
|
+
textLen: number;
|
|
324
|
+
textHash: number;
|
|
250
325
|
}
|
|
251
326
|
|
|
252
327
|
export interface ReadIncrement {
|
|
@@ -258,24 +333,36 @@ export interface ReadIncrement {
|
|
|
258
333
|
|
|
259
334
|
const CURSOR_TAIL_CHARS = 64;
|
|
260
335
|
|
|
336
|
+
/** FNV-1a 32-bit: change detection for the read cursor, not a security primitive. */
|
|
337
|
+
export function hashPaneText(text: string): number {
|
|
338
|
+
let hash = 0x811c9dc5;
|
|
339
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
340
|
+
hash ^= text.charCodeAt(i);
|
|
341
|
+
hash = Math.imul(hash, 0x01000193);
|
|
342
|
+
}
|
|
343
|
+
return hash >>> 0;
|
|
344
|
+
}
|
|
345
|
+
|
|
261
346
|
export function computeIncrement(
|
|
262
347
|
prev: ReadCursor | null,
|
|
263
348
|
next: { text: string; revision: number },
|
|
264
349
|
maxChars: number,
|
|
265
350
|
): ReadIncrement {
|
|
266
351
|
const stableLen = next.text.lastIndexOf('\n') + 1;
|
|
352
|
+
const textHash = hashPaneText(next.text);
|
|
267
353
|
const cursor: ReadCursor = {
|
|
268
354
|
revision: next.revision,
|
|
269
355
|
len: stableLen,
|
|
270
356
|
tail: next.text.slice(Math.max(0, stableLen - CURSOR_TAIL_CHARS), stableLen),
|
|
271
|
-
|
|
357
|
+
textLen: next.text.length,
|
|
358
|
+
textHash,
|
|
272
359
|
};
|
|
273
|
-
if (prev && next.text.
|
|
360
|
+
if (prev && prev.textLen === next.text.length && prev.textHash === textHash) {
|
|
274
361
|
return { mode: 'none', text: '', cursor: prev, hardCapped: false };
|
|
275
362
|
}
|
|
276
363
|
if (
|
|
277
364
|
prev &&
|
|
278
|
-
next.text.length
|
|
365
|
+
next.text.length > prev.textLen &&
|
|
279
366
|
next.text.slice(Math.max(0, prev.len - prev.tail.length), prev.len).trimEnd() === prev.tail.trimEnd()
|
|
280
367
|
) {
|
|
281
368
|
const suffix = next.text.slice(prev.len);
|
|
@@ -293,7 +380,7 @@ export function excerptAroundMatch(text: string, pattern: string, regex: boolean
|
|
|
293
380
|
let index = -1;
|
|
294
381
|
let hitLen = pattern.length;
|
|
295
382
|
if (regex) {
|
|
296
|
-
const found =
|
|
383
|
+
const found = compilePattern(pattern).exec(text);
|
|
297
384
|
index = found?.index ?? -1;
|
|
298
385
|
hitLen = found?.[0].length ?? pattern.length;
|
|
299
386
|
} else {
|
|
@@ -315,7 +402,7 @@ export function classifyWaitMatch(
|
|
|
315
402
|
pattern: string,
|
|
316
403
|
regex: boolean,
|
|
317
404
|
): { kind: 'fresh'; excerpt: string } | { kind: 'stale' } | { kind: 'absent' } {
|
|
318
|
-
const match = (text: string) => regex ?
|
|
405
|
+
const match = (text: string) => regex ? compilePattern(pattern).test(text) : text.includes(pattern);
|
|
319
406
|
const inc = computeIncrement(prev, { text: paneText, revision: prev?.revision ?? 0 }, paneText.length + 1);
|
|
320
407
|
const unread = inc.mode === 'append' ? inc.text : inc.mode === 'reset' ? paneText : '';
|
|
321
408
|
if (match(unread)) return { kind: 'fresh', excerpt: excerptAroundMatch(unread, pattern, regex) };
|
|
@@ -378,10 +465,15 @@ export interface ShellInitPlan {
|
|
|
378
465
|
readonly command: string | null;
|
|
379
466
|
}
|
|
380
467
|
|
|
468
|
+
/** History expansion off in both managed shells: bash honours `set +H`, zsh requires
|
|
469
|
+
* `setopt NO_BANG_HIST` (`set +H` leaves `!` expansion on and an unbalanced `"…$!"` wedges the
|
|
470
|
+
* shell at `dquote>`). Both shells parse this one-liner; only the matching branch runs. */
|
|
471
|
+
export const SHELL_INIT_COMMAND = 'if [ -n "$ZSH_VERSION" ]; then setopt NO_BANG_HIST; else set +H; fi';
|
|
472
|
+
|
|
381
473
|
/**
|
|
382
|
-
* Plan the shell init command:
|
|
383
|
-
*
|
|
384
|
-
*
|
|
474
|
+
* Plan the shell init command: disable history expansion, where `!` in ordinary commands (`!|`,
|
|
475
|
+
* `if [ ! -f ... ]`, `!$`) otherwise triggers `zsh: event not found: \|` and wedges the shell.
|
|
476
|
+
* PowerShell rejects the syntax and has no `!` history expansion, so it is skipped.
|
|
385
477
|
* Limit: the top-level shell only — a nested `bash -i` re-enables its own defaults; init is only worth
|
|
386
478
|
* sending once the shell is at its prompt and has not been initialized yet.
|
|
387
479
|
*/
|
|
@@ -395,7 +487,7 @@ export function planShellInit(opts: {
|
|
|
395
487
|
if (opts.readiness !== undefined && opts.readiness !== 'prompt') {
|
|
396
488
|
return { shouldInit: false, command: null };
|
|
397
489
|
}
|
|
398
|
-
return { shouldInit: true, command:
|
|
490
|
+
return { shouldInit: true, command: SHELL_INIT_COMMAND };
|
|
399
491
|
}
|
|
400
492
|
|
|
401
493
|
export function foldTerminalsRegistry(
|
|
@@ -425,7 +517,8 @@ export function foldTerminalsRegistry(
|
|
|
425
517
|
readRevision: typeof t.readRevision === 'number' ? t.readRevision : null,
|
|
426
518
|
readLen: typeof t.readLen === 'number' ? t.readLen : 0,
|
|
427
519
|
readTail: typeof t.readTail === 'string' ? t.readTail : '',
|
|
428
|
-
|
|
520
|
+
readTextLen: typeof t.readTextLen === 'number' ? t.readTextLen : 0,
|
|
521
|
+
readTextHash: typeof t.readTextHash === 'number' ? t.readTextHash : 0,
|
|
429
522
|
initialized: t.initialized === true,
|
|
430
523
|
}));
|
|
431
524
|
}
|
|
@@ -470,7 +563,8 @@ export interface TerminalNudgeInput {
|
|
|
470
563
|
export interface TerminalReminderPlan {
|
|
471
564
|
due: boolean;
|
|
472
565
|
content: string | null;
|
|
473
|
-
/** Terminals covered by this notice; the caller bumps each one's `nudges`
|
|
566
|
+
/** Terminals covered by this notice; the caller bumps each one's `nudges` when the notice is
|
|
567
|
+
* actually delivered (a cancelled grace timer must not consume a nudge). */
|
|
474
568
|
ids: string[];
|
|
475
569
|
}
|
|
476
570
|
|
package/src/todo-core.ts
CHANGED
|
@@ -9,6 +9,17 @@ const PHASE_MAX_LEN = 30;
|
|
|
9
9
|
/** Session entry type for human/automated edits, replayed during branch rebuild (D38). */
|
|
10
10
|
export const TODO_EDIT_CUSTOM_TYPE = 'pi-herdr.todo-edit';
|
|
11
11
|
|
|
12
|
+
/** Session entry type recording that the near-archive rewrite window was already offered for one
|
|
13
|
+
* list; folded on rebuild so a resumed session proceeds to the terminal clear instead of re-warning. */
|
|
14
|
+
export const TODO_ARCHIVE_WINDOW_CUSTOM_TYPE = 'pi-herdr.todo-archive-window';
|
|
15
|
+
|
|
16
|
+
export interface ArchiveWindowMarker {
|
|
17
|
+
version: 1;
|
|
18
|
+
/** Ordered list identity at offer time. The marker only applies after the last snapshot, so any
|
|
19
|
+
* rewrite (new snapshot) or edit (list mutation) invalidates it without a timestamp comparison. */
|
|
20
|
+
itemsHash: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
12
23
|
export type TodoEditOp = 'done' | 'drop' | 'rm' | 'unblock';
|
|
13
24
|
|
|
14
25
|
export interface TodoEdit {
|
|
@@ -115,6 +126,20 @@ export function listsEqual(a: readonly TodoItem[], b: readonly TodoItem[]): bool
|
|
|
115
126
|
return true;
|
|
116
127
|
}
|
|
117
128
|
|
|
129
|
+
/** Stable list identity (FNV-1a over content/status/blocker/phase): keys the archive-window marker
|
|
130
|
+
* so it never applies to a list that was edited after the window was offered. */
|
|
131
|
+
export function todoListHash(items: readonly TodoItem[]): string {
|
|
132
|
+
let hash = 0x811c9dc5;
|
|
133
|
+
for (const it of items) {
|
|
134
|
+
const text = `${it.content}\u0000${it.status}\u0000${it.blocker ?? ''}\u0000${it.phase ?? ''}\u0001`;
|
|
135
|
+
for (let i = 0; i < text.length; i++) {
|
|
136
|
+
hash ^= text.charCodeAt(i);
|
|
137
|
+
hash = Math.imul(hash, 0x01000193);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return (hash >>> 0).toString(16).padStart(8, '0');
|
|
141
|
+
}
|
|
142
|
+
|
|
118
143
|
/** Items completed by this write (D36: surfaced back to the caller). */
|
|
119
144
|
export function completionTransitions(prev: readonly TodoItem[], next: readonly TodoItem[]): string[] {
|
|
120
145
|
const prevMap = new Map(prev.map((it) => [it.content, it.status]));
|
|
@@ -123,8 +148,11 @@ export function completionTransitions(prev: readonly TodoItem[], next: readonly
|
|
|
123
148
|
.map((it) => it.content);
|
|
124
149
|
}
|
|
125
150
|
|
|
126
|
-
/** Completed items pushed back to open by this write (D37: reversible, but warn).
|
|
151
|
+
/** Completed items pushed back to open by this write (D37: reversible, but warn). A previous list
|
|
152
|
+
* with no open item was a finished plan, and a new plan legitimately reuses generic task names, so
|
|
153
|
+
* content-keyed reversion is meaningless there: warn only while the same plan is still in flight. */
|
|
127
154
|
export function revertedCompleted(prev: readonly TodoItem[], next: readonly TodoItem[]): string[] {
|
|
155
|
+
if (!prev.some((it) => it.status === 'pending' || it.status === 'in_progress')) return [];
|
|
128
156
|
const nextMap = new Map(next.map((it) => [it.content, it.status]));
|
|
129
157
|
return prev
|
|
130
158
|
.filter((it) => {
|
|
@@ -206,6 +234,15 @@ export interface FoldedTodos {
|
|
|
206
234
|
items: TodoItem[];
|
|
207
235
|
/** Timestamp of the last write that shaped these items; null when the entry carried none. */
|
|
208
236
|
writtenAt: number | null;
|
|
237
|
+
/** True when the anti-freeze rewrite window was already offered for exactly this untouched list. */
|
|
238
|
+
archiveWindowOffered: boolean;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function extractArchiveWindowMarker(data: unknown): ArchiveWindowMarker | null {
|
|
242
|
+
if (typeof data !== 'object' || data === null) return null;
|
|
243
|
+
const raw = data as Partial<ArchiveWindowMarker>;
|
|
244
|
+
if (raw.version !== 1 || typeof raw.itemsHash !== 'string') return null;
|
|
245
|
+
return { version: 1, itemsHash: raw.itemsHash };
|
|
209
246
|
}
|
|
210
247
|
|
|
211
248
|
function entryTimestamp(entry: BranchEntryLike): number | null {
|
|
@@ -222,6 +259,9 @@ function entryTimestamp(entry: BranchEntryLike): number | null {
|
|
|
222
259
|
export function foldLatestTodosMeta(entries: readonly BranchEntryLike[]): FoldedTodos | null {
|
|
223
260
|
let found: TodoItem[] | null = null;
|
|
224
261
|
let writtenAt: number | null = null;
|
|
262
|
+
// The marker applies only when it follows the last snapshot; a later snapshot resets it, and any
|
|
263
|
+
// edit that actually changes the list makes the hash mismatch (a no-op edit leaves it untouched).
|
|
264
|
+
let windowMarker: ArchiveWindowMarker | null = null;
|
|
225
265
|
for (const entry of entries) {
|
|
226
266
|
if (entry.type === 'message') {
|
|
227
267
|
const msg = entry.message as { role?: unknown; toolName?: unknown; details?: unknown } | null | undefined;
|
|
@@ -231,6 +271,7 @@ export function foldLatestTodosMeta(entries: readonly BranchEntryLike[]): Folded
|
|
|
231
271
|
if (snapshot) {
|
|
232
272
|
found = snapshot.items.map((it) => ({ ...it }));
|
|
233
273
|
writtenAt = entryTimestamp(entry);
|
|
274
|
+
windowMarker = null;
|
|
234
275
|
}
|
|
235
276
|
} else if (entry.type === 'custom' && entry.customType === TODO_EDIT_CUSTOM_TYPE) {
|
|
236
277
|
const payload = extractEditPayload(entry.data);
|
|
@@ -238,9 +279,13 @@ export function foldLatestTodosMeta(entries: readonly BranchEntryLike[]): Folded
|
|
|
238
279
|
found = applyTodoEdits(found, payload.edits);
|
|
239
280
|
writtenAt = entryTimestamp(entry) ?? (payload.ts > 0 ? payload.ts : writtenAt);
|
|
240
281
|
}
|
|
282
|
+
} else if (entry.type === 'custom' && entry.customType === TODO_ARCHIVE_WINDOW_CUSTOM_TYPE && found) {
|
|
283
|
+
windowMarker = extractArchiveWindowMarker(entry.data);
|
|
241
284
|
}
|
|
242
285
|
}
|
|
243
|
-
|
|
286
|
+
if (!found) return null;
|
|
287
|
+
const archiveWindowOffered = windowMarker != null && windowMarker.itemsHash === todoListHash(found);
|
|
288
|
+
return { items: found, writtenAt, archiveWindowOffered };
|
|
244
289
|
}
|
|
245
290
|
|
|
246
291
|
function extractSnapshotFromDetails(details: unknown): TodoSnapshot | null {
|
package/src/todo-read-hook.ts
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Plan the todo read hook (before_agent_start + display:false). Stale-core keeps a fully completed list
|
|
3
3
|
* from being recited forever: stale (turn-based) warns with the old entries as rewrite references,
|
|
4
|
-
* rate-limited and capped; archived (wall-clock) treats the list as absent
|
|
5
|
-
*
|
|
4
|
+
* rate-limited and capped; archived (wall-clock) treats the list as absent — one rewrite window (its
|
|
5
|
+
* offer persisted per list identity so a resume skips it), then the terminal clear on the next run.
|
|
6
6
|
*/
|
|
7
7
|
import { boundedView, currentActivity, type TodoItem } from './todo-core.ts';
|
|
8
8
|
import { countTodos } from './vocab.ts';
|
|
9
9
|
import { STALE_NOTICE_MAX, evaluateStaleness, formatAge } from './stale-core.ts';
|
|
10
|
-
import {
|
|
10
|
+
import { todoLine } from './todo-window.ts';
|
|
11
11
|
|
|
12
|
-
/** Cadence shared by empty-list guards, stale warnings, and archive
|
|
12
|
+
/** Cadence shared by empty-list guards, stale warnings, and the archive rewrite window. */
|
|
13
13
|
const EMPTY_GUARD_EVERY_N = 4;
|
|
14
14
|
const TODO_READ_CUSTOM_TYPE = 'pi-herdr.todo-read';
|
|
15
15
|
|
|
@@ -51,6 +51,8 @@ interface TodoReadPlan {
|
|
|
51
51
|
/** Clear the in-memory list when injecting the archive notice (the caller persists the empty JSONL);
|
|
52
52
|
* a kept dead list would suppress the empty guard that resumes tracking after an idle gap. */
|
|
53
53
|
clearArchived: boolean;
|
|
54
|
+
/** This injection is the one-window rewrite offer; the caller persists the marker so a resume skips it. */
|
|
55
|
+
offerArchiveWindow: boolean;
|
|
54
56
|
message: {
|
|
55
57
|
customType: string;
|
|
56
58
|
content: string;
|
|
@@ -68,6 +70,9 @@ export function planTodoReadHook(opts: {
|
|
|
68
70
|
now: number;
|
|
69
71
|
staleNotices: number;
|
|
70
72
|
lastStaleGuardTurn: number | null;
|
|
73
|
+
/** Whether the near-archive rewrite window was already offered for this exact list (in-process or
|
|
74
|
+
* folded from the persisted marker). The terminal clear no longer waits for the shared cadence. */
|
|
75
|
+
archiveWindowOffered: boolean;
|
|
71
76
|
}): TodoReadPlan {
|
|
72
77
|
const msg = (content: string) => ({
|
|
73
78
|
customType: TODO_READ_CUSTOM_TYPE,
|
|
@@ -83,6 +88,7 @@ export function planTodoReadHook(opts: {
|
|
|
83
88
|
effect: 'empty-guard',
|
|
84
89
|
clearArchived: false,
|
|
85
90
|
archived: false,
|
|
91
|
+
offerArchiveWindow: false,
|
|
86
92
|
message: msg(
|
|
87
93
|
guardDue
|
|
88
94
|
? 'Your todo list is empty. If the current work needs tracking, call todo_write before stopping.'
|
|
@@ -102,30 +108,36 @@ export function planTodoReadHook(opts: {
|
|
|
102
108
|
const c = countTodos(opts.items);
|
|
103
109
|
const age = st.ageMs == null ? '' : ` ${formatAge(st.ageMs)} ago`;
|
|
104
110
|
// Idle periods do not consume turns, so give one rewrite window with the old entries as reference
|
|
105
|
-
// before the terminal notice.
|
|
106
|
-
|
|
107
|
-
|
|
111
|
+
// before the terminal notice. The offer is keyed to the list identity and persisted (in-process it
|
|
112
|
+
// rides TodosService.archiveWindowOffered), so a resumed session goes straight to the clear.
|
|
113
|
+
if (!opts.archiveWindowOffered) {
|
|
114
|
+
if (!guardDue) {
|
|
115
|
+
return { inject: false, effect: 'none', archived: true, clearArchived: false, offerArchiveWindow: false, message: msg('') };
|
|
116
|
+
}
|
|
117
|
+
const lines = opts.items.map((it) => ` ${todoLine(it)}`);
|
|
108
118
|
const head = `todos ✓${c.completed} (all completed, last updated${age}) — about to be archived`;
|
|
109
|
-
const warn = 'One rewrite window before archiving: if the work you are doing now is multi-step, rewrite the full list with todo_write to track it (old entries below as reference — reuse what still applies). Single-step Q&A may skip tracking. If the list stays untouched, it will be archived and cleared on
|
|
119
|
+
const warn = 'One rewrite window before archiving: if the work you are doing now is multi-step, rewrite the full list with todo_write to track it (old entries below as reference — reuse what still applies). Single-step Q&A may skip tracking. If the list stays untouched, it will be archived and cleared on your next message.';
|
|
110
120
|
return {
|
|
111
121
|
inject: true,
|
|
112
122
|
effect: 'stale-notice',
|
|
113
123
|
archived: true,
|
|
114
124
|
clearArchived: false,
|
|
125
|
+
offerArchiveWindow: true,
|
|
115
126
|
message: msg([head, ...lines, warn].join('\n')),
|
|
116
127
|
};
|
|
117
128
|
}
|
|
118
129
|
// The terminal notice carries no details and no "skip tracking" escape hatch — multi-step work
|
|
119
|
-
// must get a fresh list, single-step Q&A may proceed untracked.
|
|
130
|
+
// must get a fresh list, single-step Q&A may proceed untracked. It fires on the very next run:
|
|
131
|
+
// the near-archive already consumed the shared cadence tick, and waiting 4 more runs leaves the
|
|
132
|
+
// dead list authoritative with no todo guidance at all.
|
|
120
133
|
return {
|
|
121
|
-
inject:
|
|
134
|
+
inject: true,
|
|
122
135
|
effect: 'archive-notice',
|
|
123
136
|
archived: true,
|
|
124
|
-
clearArchived:
|
|
137
|
+
clearArchived: true,
|
|
138
|
+
offerArchiveWindow: false,
|
|
125
139
|
message: msg(
|
|
126
|
-
|
|
127
|
-
? `Your previous todo list (${c.completed} completed entries, last updated${age}) has been archived and cleared from tracking; the list is now empty. If the current work is multi-step, call todo_write with a fresh list now — multi-step work must be tracked. Single-step answers may proceed without a list.`
|
|
128
|
-
: '',
|
|
140
|
+
`Your previous todo list (${c.completed} completed entries, last updated${age}) has been archived and cleared from tracking; the list is now empty. If the current work is multi-step, call todo_write with a fresh list now — multi-step work must be tracked. Single-step answers may proceed without a list.`,
|
|
129
141
|
),
|
|
130
142
|
};
|
|
131
143
|
}
|
|
@@ -134,18 +146,19 @@ export function planTodoReadHook(opts: {
|
|
|
134
146
|
const staleDue = opts.lastStaleGuardTurn == null
|
|
135
147
|
|| opts.turn - opts.lastStaleGuardTurn >= EMPTY_GUARD_EVERY_N;
|
|
136
148
|
if (!staleDue || opts.staleNotices >= STALE_NOTICE_MAX) {
|
|
137
|
-
return { inject: false, effect: 'none', archived: false, clearArchived: false, message: msg('') };
|
|
149
|
+
return { inject: false, effect: 'none', archived: false, clearArchived: false, offerArchiveWindow: false, message: msg('') };
|
|
138
150
|
}
|
|
139
151
|
const c = countTodos(opts.items);
|
|
140
152
|
const head = `todos ▶${c.inProgress} ○${c.pending} ■${c.blocked} ✓${c.completed}`
|
|
141
153
|
+ ` · unchanged for ${opts.turnsSinceWrite} turns, nothing open`;
|
|
142
|
-
const lines = opts.items.map((it) => ` ${
|
|
154
|
+
const lines = opts.items.map((it) => ` ${todoLine(it)}`);
|
|
143
155
|
const warn = 'This list no longer reflects the work you are doing. Rewrite the full list with todo_write to match current work, or send [] to clear it.';
|
|
144
156
|
return {
|
|
145
157
|
inject: true,
|
|
146
158
|
effect: 'stale-notice',
|
|
147
159
|
clearArchived: false,
|
|
148
160
|
archived: false,
|
|
161
|
+
offerArchiveWindow: false,
|
|
149
162
|
message: msg([head, ...lines, warn].join('\n')),
|
|
150
163
|
};
|
|
151
164
|
}
|
|
@@ -153,7 +166,7 @@ export function planTodoReadHook(opts: {
|
|
|
153
166
|
// Fresh lists are recited every turn to keep current work visible.
|
|
154
167
|
const c = countTodos(opts.items);
|
|
155
168
|
const view = boundedView(opts.items, 6);
|
|
156
|
-
const lines = view.visible.map((it) =>
|
|
169
|
+
const lines = view.visible.map((it) => todoLine(it));
|
|
157
170
|
const activity = currentActivity(opts.items);
|
|
158
171
|
const head = `todos ▶${c.inProgress} ○${c.pending} ■${c.blocked} ✓${c.completed}`
|
|
159
172
|
+ (activity ? ` · ${activity}` : '');
|
|
@@ -162,6 +175,7 @@ export function planTodoReadHook(opts: {
|
|
|
162
175
|
effect: 'recite',
|
|
163
176
|
clearArchived: false,
|
|
164
177
|
archived: false,
|
|
178
|
+
offerArchiveWindow: false,
|
|
165
179
|
message: msg([head, ...lines].join('\n')),
|
|
166
180
|
};
|
|
167
181
|
}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Reminders about unfinished todos when stopping go out as sendMessage(custom) and ask for
|
|
3
3
|
* reconciliation rather than continuation, so human-blocked items are a first-class outcome. Guards:
|
|
4
|
-
* suppress on abort, cap reminders, skip while a subagent runs, a human gate is
|
|
5
|
-
* item remains.
|
|
4
|
+
* suppress on abort or provider error, cap reminders, skip while a subagent runs, a human gate is
|
|
5
|
+
* blocked, or no open item remains.
|
|
6
6
|
*/
|
|
7
|
-
import { ABORT_STOP_REASON } from './settle-wake-core.ts';
|
|
7
|
+
import { ABORT_STOP_REASON, ERROR_STOP_REASON } from './settle-wake-core.ts';
|
|
8
8
|
import { pierOption } from './pier-options.ts';
|
|
9
9
|
|
|
10
10
|
/** Cap reminders for the lifetime of the process. */
|
|
@@ -25,6 +25,8 @@ export function todoReminderGraceMs(): number {
|
|
|
25
25
|
interface TodoReminderInput {
|
|
26
26
|
/** Stop reason of the last assistant turn (null = natural completion). */
|
|
27
27
|
lastStopReason: string | null;
|
|
28
|
+
/** agent_settled.aborted: authoritative for ESC mid-tool, where no final assistant message set the reason. */
|
|
29
|
+
aborted?: boolean;
|
|
28
30
|
intentionalAbort?: boolean;
|
|
29
31
|
compactionInFlight?: boolean;
|
|
30
32
|
reminders: number;
|
|
@@ -48,8 +50,10 @@ interface TodoReminderPlan {
|
|
|
48
50
|
*/
|
|
49
51
|
export function planStopTodoReminder(input: TodoReminderInput): TodoReminderPlan {
|
|
50
52
|
const noInject: TodoReminderPlan = { due: false, content: null, nextReminders: input.reminders };
|
|
51
|
-
if (input.intentionalAbort || input.compactionInFlight) return noInject;
|
|
52
|
-
|
|
53
|
+
if (input.intentionalAbort || input.compactionInFlight || input.aborted) return noInject;
|
|
54
|
+
// An error stop is an upstream/provider failure that pi retries; the reminder's triggerTurn would
|
|
55
|
+
// fire another request into the failing window (observed: 3 reminders during one outage).
|
|
56
|
+
if (input.lastStopReason === ABORT_STOP_REASON || input.lastStopReason === ERROR_STOP_REASON) return noInject;
|
|
53
57
|
if (input.reminders >= REMINDERS_MAX) return noInject;
|
|
54
58
|
if (input.runningSubs > 0) return noInject;
|
|
55
59
|
if (input.blockedDepth > 0) return noInject;
|
package/src/todo-window.ts
CHANGED
|
@@ -20,6 +20,13 @@ export function formatTodoSummary(items: readonly TodoItem[]): string {
|
|
|
20
20
|
return `todo: ${c.inProgress}▶ ${c.pending}○ ${c.blocked}■ ${c.completed}✓`;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
/** One item line shared by the widget/tree renderer and the read-hook recitation: the blocker text is
|
|
24
|
+
* part of the item (D43), not a widget-only decoration, so a resumed model keeps the reason. */
|
|
25
|
+
export function todoLine(it: TodoItem): string {
|
|
26
|
+
const suffix = it.status === 'blocked' && it.blocker ? ` — ${it.blocker}` : '';
|
|
27
|
+
return `${todoMark(it.status)} ${it.content}${suffix}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
23
30
|
/** Shared phase rendering so widget and overlay order entries identically. */
|
|
24
31
|
export function renderTodoGroups(items: readonly TodoItem[]): string[] {
|
|
25
32
|
const groups = new Map<string, TodoItem[]>();
|
|
@@ -32,10 +39,7 @@ export function renderTodoGroups(items: readonly TodoItem[]): string[] {
|
|
|
32
39
|
const lines: string[] = [];
|
|
33
40
|
for (const [phase, list] of groups) {
|
|
34
41
|
if (phase) lines.push(` [${phase}]`);
|
|
35
|
-
for (const it of list) {
|
|
36
|
-
const suffix = it.status === 'blocked' && it.blocker ? ` — ${it.blocker}` : '';
|
|
37
|
-
lines.push(` ${todoMark(it.status)} ${it.content}${suffix}`);
|
|
38
|
-
}
|
|
42
|
+
for (const it of list) lines.push(` ${todoLine(it)}`);
|
|
39
43
|
}
|
|
40
44
|
return lines;
|
|
41
45
|
}
|
package/src/todos-service.ts
CHANGED
|
@@ -18,6 +18,15 @@ export class TodosService extends EventEmitter {
|
|
|
18
18
|
}
|
|
19
19
|
/** Clock anchor for staleness/archiving; null until the first write. */
|
|
20
20
|
lastWriteAt: number | null = null;
|
|
21
|
+
/** True when the anti-freeze rewrite window was offered for the current list (reached through the
|
|
22
|
+
* persisted marker on rebuild, so a resumed session skips straight to the terminal clear). */
|
|
23
|
+
private _archiveWindowOffered = false;
|
|
24
|
+
get archiveWindowOffered(): boolean {
|
|
25
|
+
return this._archiveWindowOffered;
|
|
26
|
+
}
|
|
27
|
+
markArchiveWindowOffered(): void {
|
|
28
|
+
this._archiveWindowOffered = true;
|
|
29
|
+
}
|
|
21
30
|
readonly config: TodosConfig;
|
|
22
31
|
|
|
23
32
|
constructor(config: TodosConfig) {
|
|
@@ -40,6 +49,7 @@ export class TodosService extends EventEmitter {
|
|
|
40
49
|
if (listsEqual(this._items, next)) return { changed: false };
|
|
41
50
|
const before = this._items;
|
|
42
51
|
this._items = next;
|
|
52
|
+
this._archiveWindowOffered = false;
|
|
43
53
|
this.lastWriteAt = Date.now();
|
|
44
54
|
this.emitCompletedTransitions(before, this._items, opts?.source ?? 'tool');
|
|
45
55
|
this.emit('todo.updated', { items: this._items });
|
|
@@ -51,6 +61,7 @@ export class TodosService extends EventEmitter {
|
|
|
51
61
|
const next = applyTodoEdits(this._items, edits);
|
|
52
62
|
if (listsEqual(before, next)) return { changed: false };
|
|
53
63
|
this._items = next;
|
|
64
|
+
this._archiveWindowOffered = false;
|
|
54
65
|
this.lastWriteAt = Date.now();
|
|
55
66
|
this.emitCompletedTransitions(before, this._items, opts?.source ?? 'reconcile');
|
|
56
67
|
this.emit('todo.edited', { edits, items: this._items });
|
|
@@ -74,6 +85,7 @@ export class TodosService extends EventEmitter {
|
|
|
74
85
|
const folded = foldLatestTodosMeta(entries as Parameters<typeof foldLatestTodosMeta>[0]);
|
|
75
86
|
this._items = folded ? folded.items : [];
|
|
76
87
|
this.lastWriteAt = folded ? folded.writtenAt : null;
|
|
88
|
+
this._archiveWindowOffered = folded ? folded.archiveWindowOffered : false;
|
|
77
89
|
this.emit('todo.updated', { items: this._items, reason: 'rebuild' });
|
|
78
90
|
}
|
|
79
91
|
}
|
package/src/vocab.ts
CHANGED
|
@@ -98,13 +98,19 @@ const NULL_CLOSING_SENTENCE: Record<SettlementNullReason, string> = {
|
|
|
98
98
|
silent: 'It left no closing message.',
|
|
99
99
|
};
|
|
100
100
|
|
|
101
|
-
/** Subagent settlement notice (aligned with DSH).
|
|
101
|
+
/** Subagent settlement notice (aligned with DSH). `reportDeliveredAt`: the master already received
|
|
102
|
+
* this exact closing message through `subagent output`, so the notice points there instead. */
|
|
102
103
|
export function formatSettlementNotice(
|
|
103
104
|
agentId: string,
|
|
104
105
|
closingMessage: string | null,
|
|
105
106
|
nullReason: SettlementNullReason = 'silent',
|
|
107
|
+
reportDeliveredAt: number | null = null,
|
|
106
108
|
): string {
|
|
107
109
|
const head = `Background subagent ${agentId} finished and will do no further work unless you send it more.`;
|
|
110
|
+
if (closingMessage && reportDeliveredAt !== null) {
|
|
111
|
+
const at = new Date(reportDeliveredAt).toTimeString().slice(0, 8);
|
|
112
|
+
return `${head} Its closing message is the report subagent(action:"output") already returned at ${at}; it is not repeated here.`;
|
|
113
|
+
}
|
|
108
114
|
return closingMessage
|
|
109
115
|
? `${head} Its closing message: ${closingMessage}`
|
|
110
116
|
: `${head} ${NULL_CLOSING_SENTENCE[nullReason]}`;
|