pi-pier 0.3.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 +7 -5
- package/package.json +3 -3
- package/schemas/efficiency-config.schema.json +6 -0
- package/src/ask-dialog.ts +9 -2
- package/src/ask-user.ts +142 -74
- package/src/bootstrap.ts +2 -1
- package/src/compact-coordinator.ts +207 -35
- package/src/compact-economics-core.ts +62 -29
- package/src/config-catalog-core.ts +1 -0
- package/src/cordis-services.ts +64 -0
- package/src/dashboard-command.ts +40 -15
- package/src/delegation-core.ts +115 -0
- package/src/efficiency-config-core.ts +17 -6
- package/src/efficiency-store.ts +43 -6
- package/src/guards.ts +6 -0
- package/src/herdr-client.ts +141 -36
- package/src/history-store.ts +2 -0
- package/src/index-gates.ts +6 -12
- package/src/index-master.ts +12 -16
- package/src/index-pipe.ts +6 -0
- package/src/index-runtime.ts +14 -0
- package/src/index-worker.ts +4 -10
- package/src/index.ts +310 -76
- package/src/jev-client.ts +32 -7
- package/src/jev-core.ts +71 -36
- package/src/observation-core.ts +13 -1
- package/src/panel-open.ts +47 -0
- package/src/pier-options.ts +1 -2
- package/src/pipe-channel.ts +9 -0
- package/src/plugins/heat-plan.ts +18 -3
- package/src/plugins/observation.ts +118 -46
- package/src/plugins/subagent.ts +119 -68
- package/src/plugins/terminal.ts +221 -57
- package/src/plugins/todo.ts +63 -63
- package/src/reducer-core.ts +3 -5
- package/src/reducer-invoker.ts +27 -13
- package/src/renderers.ts +25 -2
- package/src/role-manifest.ts +2 -3
- package/src/roles/worker-default.json +2 -2
- package/src/settle-wake-core.ts +17 -3
- package/src/subagent-core.ts +209 -3
- package/src/subagent-poller.ts +69 -19
- package/src/subagent-session.ts +67 -4
- package/src/subagent-spawn.ts +55 -7
- package/src/terminal-core.ts +178 -50
- package/src/todo-core.ts +47 -2
- package/src/todo-read-hook.ts +54 -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 +162 -0
- package/src/vocab.ts +7 -1
- package/src/worker-marker.ts +76 -0
- package/src/focus-poller.ts +0 -223
package/src/subagent-spawn.ts
CHANGED
|
@@ -8,6 +8,8 @@ import { mkdirSync, stat as statCb } from 'node:fs';
|
|
|
8
8
|
import { basename, dirname, join, resolve as pathResolve } from 'node:path';
|
|
9
9
|
import { promisify } from 'node:util';
|
|
10
10
|
import type { HerdrClientLike } from './herdr-client.ts';
|
|
11
|
+
import { delegationEnv } from './delegation-core.ts';
|
|
12
|
+
import { bareSessionId } from './session-tail.ts';
|
|
11
13
|
import { pipeNameCandidates, pipeNameFor, pipeRequest, pipeRequestTo } from './pipe-channel.ts';
|
|
12
14
|
import { composeForRole } from './manifest-compose.ts';
|
|
13
15
|
import { platformPaths } from './platform-paths.ts';
|
|
@@ -18,12 +20,14 @@ import type { HistoryEntry } from './history-store.ts';
|
|
|
18
20
|
import {
|
|
19
21
|
FOREGROUND_POLL_MS,
|
|
20
22
|
Semaphore,
|
|
23
|
+
allocateSubSlug,
|
|
21
24
|
buildAliveNotice,
|
|
22
25
|
buildBlockedGateNotice,
|
|
23
26
|
buildIsolatePreamble,
|
|
24
27
|
buildLaunchLine,
|
|
25
28
|
buildLaunchParts,
|
|
26
29
|
classifyWorktreeZone,
|
|
30
|
+
formatIsolateLocation,
|
|
27
31
|
formatSubagentResult,
|
|
28
32
|
formatWorktreeStat,
|
|
29
33
|
isAlive,
|
|
@@ -32,8 +36,10 @@ import {
|
|
|
32
36
|
planForegroundTick,
|
|
33
37
|
planIsolateWorktree,
|
|
34
38
|
planLaunchValidation,
|
|
39
|
+
planShellReady,
|
|
35
40
|
planTabPlacement,
|
|
36
41
|
sleep,
|
|
42
|
+
subRef,
|
|
37
43
|
type SubEntry,
|
|
38
44
|
type TabPlacementPlan,
|
|
39
45
|
type WorktreeZone,
|
|
@@ -45,6 +51,11 @@ import type { SessionIo } from './subagent-session.ts';
|
|
|
45
51
|
|
|
46
52
|
const statAsync = promisify(statCb);
|
|
47
53
|
|
|
54
|
+
/** Bound on waiting for a fresh pane's shell to reach its prompt. On timeout the launch line is sent
|
|
55
|
+
* anyway: `waitSubReady` still diagnoses a failed boot, and an unsupported probe must not stall spawn. */
|
|
56
|
+
export const SHELL_READY_TIMEOUT_MS = 2_000;
|
|
57
|
+
const SHELL_READY_POLL_MS = 100;
|
|
58
|
+
|
|
48
59
|
/* ── git adapter ────────────────────────────────────────────────── */
|
|
49
60
|
|
|
50
61
|
export interface GitResult {
|
|
@@ -302,6 +313,18 @@ export function createSpawner(h: SpawnerHost): Spawner {
|
|
|
302
313
|
return buildLaunchLine(buildLaunchParts(h.runtime, { resumeFile, roleModel, approve }));
|
|
303
314
|
}
|
|
304
315
|
|
|
316
|
+
/** herdr 0.9.4 refuses over-long commands on a shell that is still starting; `pane.send_text`
|
|
317
|
+
* has no such guard. Poll the foreground process until the shell owns the PTY (or give up). */
|
|
318
|
+
async function waitShellReady(paneId: string): Promise<void> {
|
|
319
|
+
const deadline = Date.now() + SHELL_READY_TIMEOUT_MS;
|
|
320
|
+
while (Date.now() < deadline) {
|
|
321
|
+
const info = await h.client.processInfo(paneId).catch(() => null);
|
|
322
|
+
if (info === null) return; // older server or pane already gone: keep the old best-effort path
|
|
323
|
+
if (planShellReady(info.foreground) === 'ready') return;
|
|
324
|
+
await sleep(SHELL_READY_POLL_MS);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
305
328
|
async function approveFor(cwd: string, masterCwd: string): Promise<boolean> {
|
|
306
329
|
if (isPathUnder(cwd, masterCwd)) return true;
|
|
307
330
|
const zone = classifyWorktreeZone({ cwd, masterCwd, worktrees: await h.git.listWorktrees(masterCwd) });
|
|
@@ -471,6 +494,7 @@ export function createSpawner(h: SpawnerHost): Spawner {
|
|
|
471
494
|
focus: false,
|
|
472
495
|
...(ratio != null ? { ratio } : {}),
|
|
473
496
|
});
|
|
497
|
+
await waitShellReady(paneId);
|
|
474
498
|
await h.client.sendPaneText(paneId, launch);
|
|
475
499
|
return { tabId: plan.tabId, paneId, tabName: plan.tabName };
|
|
476
500
|
}
|
|
@@ -482,6 +506,7 @@ export function createSpawner(h: SpawnerHost): Spawner {
|
|
|
482
506
|
cwd,
|
|
483
507
|
env: envOver,
|
|
484
508
|
});
|
|
509
|
+
await waitShellReady(created.paneId);
|
|
485
510
|
await h.client.sendPaneText(created.paneId, launch);
|
|
486
511
|
return { tabId: created.tabId, paneId: created.paneId, tabName: plan.tabName };
|
|
487
512
|
} finally {
|
|
@@ -517,11 +542,13 @@ interface SpawnActionHost {
|
|
|
517
542
|
writeHistory(entry: SubEntry, patch?: Partial<HistoryEntry>, via?: string): void;
|
|
518
543
|
pendingIsolateBranches: Set<string>;
|
|
519
544
|
lastMachineInjectAt: Map<string, number>;
|
|
520
|
-
lastRequestIdByPane: Map<string, string>;
|
|
545
|
+
lastRequestIdByPane: Map<string, number | string>;
|
|
521
546
|
git: GitIo;
|
|
522
547
|
session: SessionIo;
|
|
523
548
|
spawn: Spawner;
|
|
524
549
|
poller: Poller;
|
|
550
|
+
/** Master session id for the delegation identity handed to the worker. */
|
|
551
|
+
getSessionId: () => string;
|
|
525
552
|
/** Phase 0 routing telemetry (RFC rfc-jev-role-routing §8): best-effort spawn profile; absent in tests. */
|
|
526
553
|
logRouting?: (row: RoutingTelemetryRecord) => void;
|
|
527
554
|
}
|
|
@@ -545,6 +572,8 @@ export function createSpawnAction(h: SpawnActionHost) {
|
|
|
545
572
|
// Validate the role before any isolate worktree exists: this early return has no cleanup path.
|
|
546
573
|
let roleManifestEnv: Record<string, string> = {};
|
|
547
574
|
let roleModel: string | null = null;
|
|
575
|
+
// Delegation identity (RFC rfc-delegation-identity §2.1): the worker learns who delegated it.
|
|
576
|
+
let delegationEnvPatch: Record<string, string> = {};
|
|
548
577
|
try {
|
|
549
578
|
const { role, manifest } = composeForRole(manifestRole ?? 'worker-default', suggested, { loadRoleOpts: { baseDir: masterCwd } });
|
|
550
579
|
roleManifestEnv = {
|
|
@@ -561,6 +590,15 @@ export function createSpawnAction(h: SpawnActionHost) {
|
|
|
561
590
|
// Role base: /pier-role and the pipe switch resolve .pi-herdr/roles/ against the MASTER's checkout.
|
|
562
591
|
PI_HERDR_ROLE_BASE: masterCwd,
|
|
563
592
|
};
|
|
593
|
+
delegationEnvPatch = delegationEnv({
|
|
594
|
+
parentPaneId: env?.paneId ?? '',
|
|
595
|
+
parentSessionId: bareSessionId(h.getSessionId()),
|
|
596
|
+
// The master's own pipe so the worker's todo_sync push can dial home (D49).
|
|
597
|
+
parentPipe: pipeNameFor(masterCwd, env?.paneId ?? ''),
|
|
598
|
+
taskId,
|
|
599
|
+
description: spec.description,
|
|
600
|
+
role: role.role,
|
|
601
|
+
});
|
|
564
602
|
if (typeof role.model === 'string' && role.model.trim()) roleModel = role.model.trim();
|
|
565
603
|
h.logRouting?.(
|
|
566
604
|
planSpawnProfileRow({
|
|
@@ -631,14 +669,24 @@ export function createSpawnAction(h: SpawnActionHost) {
|
|
|
631
669
|
const spawned = await spawnPaneInTaskTab(
|
|
632
670
|
{ desiredTab: tab, description: spec.description, zone },
|
|
633
671
|
cwd,
|
|
634
|
-
|
|
672
|
+
// pier owns worker state reporting (report_agent with todo/blocked badges); pi 1.1+'s own
|
|
673
|
+
// OSC 7501 program-status emission would become a second, competing source once a terminal
|
|
674
|
+
// or dashboard starts reading it. One reporter per pane.
|
|
675
|
+
{ PI_HERDR_SUBAGENT: '1', PI_PROGRAM_STATUS: '0', ...roleManifestEnv, ...delegationEnvPatch },
|
|
635
676
|
launchLine(null, roleModel, approve),
|
|
636
677
|
);
|
|
637
678
|
paneId = spawned.paneId;
|
|
679
|
+
// Phase 2: allocate once per spawn; revives/history keep the row's slug (stability beats beauty).
|
|
680
|
+
const slug = allocateSubSlug(
|
|
681
|
+
spec.description,
|
|
682
|
+
taskId,
|
|
683
|
+
new Set([...subs.values()].map((s) => s.slug ?? '').filter(Boolean)),
|
|
684
|
+
);
|
|
638
685
|
const entry: SubEntry = {
|
|
639
686
|
taskId,
|
|
640
687
|
kind,
|
|
641
688
|
paneId,
|
|
689
|
+
slug,
|
|
642
690
|
tabId: spawned.tabId,
|
|
643
691
|
tabName: spawned.tabName,
|
|
644
692
|
cwd,
|
|
@@ -686,8 +734,8 @@ export function createSpawnAction(h: SpawnActionHost) {
|
|
|
686
734
|
if (background) {
|
|
687
735
|
handToPoller(`prompt-${taskId}`);
|
|
688
736
|
return {
|
|
689
|
-
content: [{ type: 'text', text: `started subagent ${
|
|
690
|
-
details: { paneId, taskId, background: true, role: kind },
|
|
737
|
+
content: [{ type: 'text', text: `started subagent ${subRef(entry)} (task ${taskId})${formatIsolateLocation(isolateMeta)}` }],
|
|
738
|
+
details: { paneId, taskId, background: true, role: kind, slug },
|
|
691
739
|
};
|
|
692
740
|
}
|
|
693
741
|
|
|
@@ -705,7 +753,7 @@ export function createSpawnAction(h: SpawnActionHost) {
|
|
|
705
753
|
const question = await readAskFlag(paneId);
|
|
706
754
|
handToPoller(`prompt-${taskId}`);
|
|
707
755
|
return {
|
|
708
|
-
content: [{ type: 'text', text: buildBlockedGateNotice({ paneId, description: spec.description, question }) }],
|
|
756
|
+
content: [{ type: 'text', text: `${buildBlockedGateNotice({ paneId, description: spec.description, question })}${formatIsolateLocation(isolateMeta)}` }],
|
|
709
757
|
details: { paneId, taskId, background: true, blocked: true, role: kind },
|
|
710
758
|
};
|
|
711
759
|
}
|
|
@@ -729,7 +777,7 @@ export function createSpawnAction(h: SpawnActionHost) {
|
|
|
729
777
|
if (isAlive(probe, Date.now())) {
|
|
730
778
|
handToPoller(`prompt-${taskId}`);
|
|
731
779
|
return {
|
|
732
|
-
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)}` }],
|
|
733
781
|
details: { paneId, taskId, background: true, movedToBackground: true, role: kind },
|
|
734
782
|
};
|
|
735
783
|
}
|
|
@@ -744,7 +792,7 @@ export function createSpawnAction(h: SpawnActionHost) {
|
|
|
744
792
|
h.persistSubs();
|
|
745
793
|
h.writeHistory(entry, { outcome: outcome.kind === 'completed' ? text : null }, 'fg-settle');
|
|
746
794
|
return {
|
|
747
|
-
content: [{ type: 'text', text: formatSubagentResult(outcome, spec.description) }],
|
|
795
|
+
content: [{ type: 'text', text: `${formatSubagentResult(outcome, spec.description)}${formatIsolateLocation(isolateMeta)}` }],
|
|
748
796
|
details: { paneId, taskId, background, role: kind },
|
|
749
797
|
};
|
|
750
798
|
} catch (err) {
|
package/src/terminal-core.ts
CHANGED
|
@@ -69,14 +69,17 @@ export interface TerminalEntry {
|
|
|
69
69
|
label: string | null;
|
|
70
70
|
createdAt: number;
|
|
71
71
|
lastActivityAt: number;
|
|
72
|
-
/**
|
|
72
|
+
/** Nudges delivered for this terminal (0..TERM_REMINDERS_MAX), persisted. */
|
|
73
|
+
nudges: number;
|
|
74
|
+
/** Timestamp of the last nudge; kept alongside `nudges` for legacy records and debugging. */
|
|
73
75
|
nudgedAt: number | null;
|
|
74
76
|
status: 'open' | 'closed';
|
|
75
77
|
closedAt: number | null;
|
|
76
78
|
readRevision?: number | null;
|
|
77
79
|
readLen?: number;
|
|
78
80
|
readTail?: string;
|
|
79
|
-
|
|
81
|
+
readTextLen?: number;
|
|
82
|
+
readTextHash?: number;
|
|
80
83
|
initialized?: boolean;
|
|
81
84
|
}
|
|
82
85
|
|
|
@@ -123,13 +126,15 @@ export function registerTerminal(
|
|
|
123
126
|
label: opts.label ?? (opts.cwd ? basename(opts.cwd.replace(/\\/g, '/')) || opts.cwd : null),
|
|
124
127
|
createdAt: opts.createdAt,
|
|
125
128
|
lastActivityAt: opts.createdAt,
|
|
129
|
+
nudges: 0,
|
|
126
130
|
nudgedAt: null,
|
|
127
131
|
status: 'open',
|
|
128
132
|
closedAt: null,
|
|
129
133
|
readRevision: null,
|
|
130
134
|
readLen: 0,
|
|
131
135
|
readTail: '',
|
|
132
|
-
|
|
136
|
+
readTextLen: 0,
|
|
137
|
+
readTextHash: 0,
|
|
133
138
|
};
|
|
134
139
|
return { ok: true, entries: [...entries, entry], entry };
|
|
135
140
|
}
|
|
@@ -200,12 +205,61 @@ export function validateSignal(key: string): { ok: true; key: SignalKey } | { ok
|
|
|
200
205
|
return { ok: true, key: k };
|
|
201
206
|
}
|
|
202
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
|
+
|
|
203
255
|
/**
|
|
204
256
|
* Whether a wait pattern would match the terminal's own echoed input: the shell echoes typed
|
|
205
257
|
* commands into the pane, so a pattern contained in the last sent text (`…; echo TERM_DONE_$?`
|
|
206
258
|
* waited on as literal `TERM_DONE_`) matches its own echo and returns "matched" before the job
|
|
207
|
-
* has produced anything.
|
|
208
|
-
*
|
|
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.
|
|
209
263
|
*/
|
|
210
264
|
export function sentinelEchoHazard(opts: {
|
|
211
265
|
pattern: string;
|
|
@@ -216,7 +270,13 @@ export function sentinelEchoHazard(opts: {
|
|
|
216
270
|
if (!sent) return false;
|
|
217
271
|
if (opts.regex) {
|
|
218
272
|
try {
|
|
219
|
-
|
|
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'));
|
|
220
280
|
} catch {
|
|
221
281
|
return false; // invalid regex is rejected by the caller before this runs
|
|
222
282
|
}
|
|
@@ -239,11 +299,29 @@ export function stripAnsi(raw: string): string {
|
|
|
239
299
|
.replace(/[\x00-\x08\x0b-\x1f\x7f]/g, '');
|
|
240
300
|
}
|
|
241
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
|
+
|
|
242
317
|
export interface ReadCursor {
|
|
243
318
|
revision: number;
|
|
244
319
|
len: number;
|
|
245
320
|
tail: string;
|
|
246
|
-
|
|
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;
|
|
247
325
|
}
|
|
248
326
|
|
|
249
327
|
export interface ReadIncrement {
|
|
@@ -255,24 +333,36 @@ export interface ReadIncrement {
|
|
|
255
333
|
|
|
256
334
|
const CURSOR_TAIL_CHARS = 64;
|
|
257
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
|
+
|
|
258
346
|
export function computeIncrement(
|
|
259
347
|
prev: ReadCursor | null,
|
|
260
348
|
next: { text: string; revision: number },
|
|
261
349
|
maxChars: number,
|
|
262
350
|
): ReadIncrement {
|
|
263
351
|
const stableLen = next.text.lastIndexOf('\n') + 1;
|
|
352
|
+
const textHash = hashPaneText(next.text);
|
|
264
353
|
const cursor: ReadCursor = {
|
|
265
354
|
revision: next.revision,
|
|
266
355
|
len: stableLen,
|
|
267
356
|
tail: next.text.slice(Math.max(0, stableLen - CURSOR_TAIL_CHARS), stableLen),
|
|
268
|
-
|
|
357
|
+
textLen: next.text.length,
|
|
358
|
+
textHash,
|
|
269
359
|
};
|
|
270
|
-
if (prev && next.text.
|
|
360
|
+
if (prev && prev.textLen === next.text.length && prev.textHash === textHash) {
|
|
271
361
|
return { mode: 'none', text: '', cursor: prev, hardCapped: false };
|
|
272
362
|
}
|
|
273
363
|
if (
|
|
274
364
|
prev &&
|
|
275
|
-
next.text.length
|
|
365
|
+
next.text.length > prev.textLen &&
|
|
276
366
|
next.text.slice(Math.max(0, prev.len - prev.tail.length), prev.len).trimEnd() === prev.tail.trimEnd()
|
|
277
367
|
) {
|
|
278
368
|
const suffix = next.text.slice(prev.len);
|
|
@@ -290,7 +380,7 @@ export function excerptAroundMatch(text: string, pattern: string, regex: boolean
|
|
|
290
380
|
let index = -1;
|
|
291
381
|
let hitLen = pattern.length;
|
|
292
382
|
if (regex) {
|
|
293
|
-
const found =
|
|
383
|
+
const found = compilePattern(pattern).exec(text);
|
|
294
384
|
index = found?.index ?? -1;
|
|
295
385
|
hitLen = found?.[0].length ?? pattern.length;
|
|
296
386
|
} else {
|
|
@@ -312,7 +402,7 @@ export function classifyWaitMatch(
|
|
|
312
402
|
pattern: string,
|
|
313
403
|
regex: boolean,
|
|
314
404
|
): { kind: 'fresh'; excerpt: string } | { kind: 'stale' } | { kind: 'absent' } {
|
|
315
|
-
const match = (text: string) => regex ?
|
|
405
|
+
const match = (text: string) => regex ? compilePattern(pattern).test(text) : text.includes(pattern);
|
|
316
406
|
const inc = computeIncrement(prev, { text: paneText, revision: prev?.revision ?? 0 }, paneText.length + 1);
|
|
317
407
|
const unread = inc.mode === 'append' ? inc.text : inc.mode === 'reset' ? paneText : '';
|
|
318
408
|
if (match(unread)) return { kind: 'fresh', excerpt: excerptAroundMatch(unread, pattern, regex) };
|
|
@@ -375,10 +465,15 @@ export interface ShellInitPlan {
|
|
|
375
465
|
readonly command: string | null;
|
|
376
466
|
}
|
|
377
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
|
+
|
|
378
473
|
/**
|
|
379
|
-
* Plan the shell init command:
|
|
380
|
-
*
|
|
381
|
-
*
|
|
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.
|
|
382
477
|
* Limit: the top-level shell only — a nested `bash -i` re-enables its own defaults; init is only worth
|
|
383
478
|
* sending once the shell is at its prompt and has not been initialized yet.
|
|
384
479
|
*/
|
|
@@ -392,7 +487,7 @@ export function planShellInit(opts: {
|
|
|
392
487
|
if (opts.readiness !== undefined && opts.readiness !== 'prompt') {
|
|
393
488
|
return { shouldInit: false, command: null };
|
|
394
489
|
}
|
|
395
|
-
return { shouldInit: true, command:
|
|
490
|
+
return { shouldInit: true, command: SHELL_INIT_COMMAND };
|
|
396
491
|
}
|
|
397
492
|
|
|
398
493
|
export function foldTerminalsRegistry(
|
|
@@ -413,30 +508,37 @@ export function foldTerminalsRegistry(
|
|
|
413
508
|
label: typeof t.label === 'string' ? t.label : null,
|
|
414
509
|
createdAt: typeof t.createdAt === 'number' ? t.createdAt : 0,
|
|
415
510
|
lastActivityAt: typeof t.lastActivityAt === 'number' ? t.lastActivityAt : 0,
|
|
511
|
+
nudges: typeof t.nudges === 'number'
|
|
512
|
+
? Math.max(0, Math.min(TERM_REMINDERS_MAX, Math.floor(t.nudges)))
|
|
513
|
+
: (typeof t.nudgedAt === 'number' ? 1 : 0),
|
|
416
514
|
nudgedAt: typeof t.nudgedAt === 'number' ? t.nudgedAt : null,
|
|
417
515
|
status: t.status === 'closed' ? ('closed' as const) : ('open' as const),
|
|
418
516
|
closedAt: typeof t.closedAt === 'number' ? t.closedAt : null,
|
|
419
517
|
readRevision: typeof t.readRevision === 'number' ? t.readRevision : null,
|
|
420
518
|
readLen: typeof t.readLen === 'number' ? t.readLen : 0,
|
|
421
519
|
readTail: typeof t.readTail === 'string' ? t.readTail : '',
|
|
422
|
-
|
|
520
|
+
readTextLen: typeof t.readTextLen === 'number' ? t.readTextLen : 0,
|
|
521
|
+
readTextHash: typeof t.readTextHash === 'number' ? t.readTextHash : 0,
|
|
423
522
|
initialized: t.initialized === true,
|
|
424
523
|
}));
|
|
425
524
|
}
|
|
426
525
|
return found;
|
|
427
526
|
}
|
|
428
527
|
|
|
429
|
-
/* ──
|
|
430
|
-
* Terminals persist by design, so a finished shell lingers as a dead split
|
|
431
|
-
* the
|
|
528
|
+
/* ── Terminal nudge: turn-end self-cleanup for shells the model forgot to close ──
|
|
529
|
+
* Terminals persist by design, so a finished shell lingers as a dead split. Two notices per terminal:
|
|
530
|
+
* 1. at the end of the turn that used it ("used this turn") — or the first turn that finds it idle
|
|
531
|
+
* past the threshold, for a shell nobody touched recently;
|
|
532
|
+
* 2. on a later settle once it has been idle past PIER_TERM_IDLE_MS.
|
|
533
|
+
* Delivery wiring mirrors the todo stop-reminder in plugins/terminal. */
|
|
432
534
|
|
|
433
|
-
/**
|
|
535
|
+
/** Per-terminal nudge cap: one after use, one after the idle threshold (goodbye-loop guard). */
|
|
434
536
|
export const TERM_REMINDERS_MAX = 2;
|
|
435
537
|
|
|
436
|
-
/** Idle threshold
|
|
538
|
+
/** Idle threshold for the second nudge; the env var is read per call so tests can use small values. */
|
|
437
539
|
export function terminalIdleMs(): number {
|
|
438
540
|
// B10: canonical PIER_TERM_IDLE_MS, legacy PI_HERDR_TERM_IDLE_MS.
|
|
439
|
-
return Number(pierOption('PIER_TERM_IDLE_MS') ??
|
|
541
|
+
return Number(pierOption('PIER_TERM_IDLE_MS') ?? 5 * 60_000) || 5 * 60_000;
|
|
440
542
|
}
|
|
441
543
|
|
|
442
544
|
/** Grace from agent_settled to injection, letting the user read the answer and intervene; cancel on agent_start. */
|
|
@@ -446,55 +548,81 @@ export function terminalReminderGraceMs(): number {
|
|
|
446
548
|
|
|
447
549
|
export const TERM_REMINDER_CUSTOM_TYPE = 'pi-herdr.term-reminder';
|
|
448
550
|
|
|
449
|
-
export interface
|
|
551
|
+
export interface TerminalNudgeInput {
|
|
450
552
|
terminalId: string;
|
|
451
553
|
cwd: string;
|
|
452
554
|
label: string | null;
|
|
453
555
|
lastActivityAt: number;
|
|
454
|
-
/**
|
|
556
|
+
/** Nudges already delivered; when absent, legacy `nudgedAt` counts as one. */
|
|
557
|
+
nudges?: number;
|
|
455
558
|
nudgedAt?: number | null;
|
|
559
|
+
/** A non-shell foreground process owns the pane (dev server / running job); never nudge it. */
|
|
560
|
+
busy?: boolean;
|
|
456
561
|
}
|
|
457
562
|
|
|
458
|
-
export interface
|
|
563
|
+
export interface TerminalReminderPlan {
|
|
459
564
|
due: boolean;
|
|
460
565
|
content: string | null;
|
|
461
|
-
/**
|
|
462
|
-
|
|
463
|
-
/** Terminal ids this nudge covers; the caller marks them nudged at schedule time. */
|
|
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). */
|
|
464
568
|
ids: string[];
|
|
465
569
|
}
|
|
466
570
|
|
|
467
|
-
function
|
|
468
|
-
return { due: false, content: null,
|
|
571
|
+
function noTerminalInject(): TerminalReminderPlan {
|
|
572
|
+
return { due: false, content: null, ids: [] };
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function nudgesOf(t: TerminalNudgeInput): number {
|
|
576
|
+
return t.nudges ?? (t.nudgedAt != null ? 1 : 0);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/** `72m` / `3h12m`: the reason column reads at a glance without a timestamp. */
|
|
580
|
+
function formatIdleAge(ms: number): string {
|
|
581
|
+
const minutes = Math.max(0, Math.floor(ms / 60_000));
|
|
582
|
+
if (minutes < 60) return `${minutes}m`;
|
|
583
|
+
const hours = Math.floor(minutes / 60);
|
|
584
|
+
return `${hours}h${String(minutes % 60).padStart(2, '0')}m`;
|
|
469
585
|
}
|
|
470
586
|
|
|
471
587
|
/**
|
|
472
|
-
*
|
|
473
|
-
*
|
|
588
|
+
* First notice: the terminal was used by the agent run that just settled, or it is already idle past
|
|
589
|
+
* the threshold. Second notice: a terminal with one nudge is idle past the threshold again. A shell
|
|
590
|
+
* touched recently is never nudged, so a dev server in active use stays out.
|
|
474
591
|
*/
|
|
475
|
-
export function
|
|
476
|
-
input: { open: readonly
|
|
477
|
-
):
|
|
592
|
+
export function planTerminalReminder(
|
|
593
|
+
input: { open: readonly TerminalNudgeInput[]; now: number; turnStartedAt?: number | null },
|
|
594
|
+
): TerminalReminderPlan {
|
|
478
595
|
const idleMs = terminalIdleMs();
|
|
479
|
-
const
|
|
480
|
-
|
|
481
|
-
)
|
|
482
|
-
|
|
483
|
-
|
|
596
|
+
const rows: string[] = [];
|
|
597
|
+
const ids: string[] = [];
|
|
598
|
+
for (const t of input.open) {
|
|
599
|
+
if (t.busy) continue;
|
|
600
|
+
const nudges = nudgesOf(t);
|
|
601
|
+
if (nudges >= TERM_REMINDERS_MAX) continue;
|
|
602
|
+
const idleFor = input.now - t.lastActivityAt;
|
|
603
|
+
const sinceNudge = t.nudgedAt != null ? input.now - t.nudgedAt : Number.POSITIVE_INFINITY;
|
|
604
|
+
const usedThisTurn = input.turnStartedAt != null && t.lastActivityAt >= input.turnStartedAt;
|
|
605
|
+
// Second notice waits another full idle window after the first one, so a pane that was already
|
|
606
|
+
// idle for an hour at nudge 1 is not nudged twice back-to-back on the next settle.
|
|
607
|
+
const reason = nudges === 0 && usedThisTurn
|
|
608
|
+
? 'used this turn'
|
|
609
|
+
: nudges === 0 && idleFor >= idleMs
|
|
610
|
+
? `idle ${formatIdleAge(idleFor)}`
|
|
611
|
+
: nudges === 1 && idleFor >= idleMs && sinceNudge >= idleMs
|
|
612
|
+
? `still idle ${formatIdleAge(idleFor)}`
|
|
613
|
+
: null;
|
|
614
|
+
if (reason == null) continue;
|
|
615
|
+
rows.push(`- ${t.terminalId} (${t.label ?? t.cwd}) — ${reason} · nudge ${nudges + 1}/${TERM_REMINDERS_MAX}`);
|
|
616
|
+
ids.push(t.terminalId);
|
|
484
617
|
}
|
|
485
|
-
|
|
618
|
+
if (ids.length === 0) return noTerminalInject();
|
|
486
619
|
const content = [
|
|
487
|
-
|
|
488
|
-
|
|
620
|
+
'<system-reminder>Open terminal(s) that may be finished:',
|
|
621
|
+
...rows,
|
|
489
622
|
'This is an internal housekeeping notice — do NOT reply to the user and do NOT send any farewell on this notice.',
|
|
490
623
|
'If the work in them is done, close them now with terminal(action: "close", terminal_id: "…").',
|
|
491
624
|
'Keep a terminal only if a long-running process still needs that shell.',
|
|
492
625
|
'</system-reminder>',
|
|
493
626
|
].join('\n');
|
|
494
|
-
return {
|
|
495
|
-
due: true,
|
|
496
|
-
content,
|
|
497
|
-
nextReminders: input.reminders + 1,
|
|
498
|
-
ids: idle.map((t) => t.terminalId),
|
|
499
|
-
};
|
|
627
|
+
return { due: true, content, ids };
|
|
500
628
|
}
|
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 {
|