pi-pier 0.3.1 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -5
- package/package.json +3 -3
- package/src/bootstrap.ts +2 -1
- package/src/compact-coordinator.ts +3 -2
- 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 +1 -4
- package/src/efficiency-store.ts +1 -1
- package/src/guards.ts +6 -0
- package/src/herdr-client.ts +126 -31
- 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 +14 -0
- package/src/index-runtime.ts +14 -0
- package/src/index-worker.ts +4 -10
- package/src/index.ts +298 -48
- package/src/jev-core.ts +1 -3
- package/src/panel-open.ts +47 -0
- package/src/pier-options.ts +1 -2
- package/src/pipe-channel.ts +17 -0
- package/src/plugins/subagent.ts +68 -30
- package/src/plugins/terminal.ts +34 -15
- package/src/plugins/todo.ts +20 -42
- package/src/reducer-core.ts +3 -5
- package/src/renderers.ts +37 -2
- package/src/role-manifest.ts +2 -3
- package/src/roles/worker-default.json +3 -3
- package/src/settle-wake-core.ts +9 -1
- package/src/subagent-core.ts +170 -2
- package/src/subagent-poller.ts +17 -1
- package/src/subagent-spawn.ts +51 -4
- package/src/terminal-core.ts +68 -34
- package/src/todo-read-hook.ts +23 -0
- package/src/tree-view.ts +162 -0
- package/src/worker-marker.ts +76 -0
- package/src/focus-poller.ts +0 -223
package/src/settle-wake-core.ts
CHANGED
|
@@ -15,6 +15,11 @@ export interface SettleWakeInput {
|
|
|
15
15
|
lastStopReason: string | null;
|
|
16
16
|
/** True when abort was triggered intentionally by internal mechanisms like OCC. */
|
|
17
17
|
intentionalAbort?: boolean;
|
|
18
|
+
/**
|
|
19
|
+
* pi ≥ 1.1 carries `aborted` on agent_settled itself: authoritative for runs cancelled without a
|
|
20
|
+
* final assistant message (ESC mid-tool), where stopReason tracking sees a stale natural reason.
|
|
21
|
+
*/
|
|
22
|
+
aborted?: boolean;
|
|
18
23
|
running: ReadonlyArray<{ paneId: string }>;
|
|
19
24
|
lastNoticeKey: string | null;
|
|
20
25
|
lastNoticeAt: number;
|
|
@@ -50,7 +55,7 @@ export function planSettleWake(input: SettleWakeInput): SettleWakePlan {
|
|
|
50
55
|
noticeAt: input.lastNoticeAt,
|
|
51
56
|
};
|
|
52
57
|
}
|
|
53
|
-
if (input.intentionalAbort || input.lastStopReason === ABORT_STOP_REASON) {
|
|
58
|
+
if (input.aborted === true || input.intentionalAbort || input.lastStopReason === ABORT_STOP_REASON) {
|
|
54
59
|
return {
|
|
55
60
|
wake: false,
|
|
56
61
|
compact: input.intentionalAbort === true,
|
|
@@ -79,10 +84,13 @@ export function planSettleWake(input: SettleWakeInput): SettleWakePlan {
|
|
|
79
84
|
*/
|
|
80
85
|
export function shouldPushSettleReply(input: {
|
|
81
86
|
intentionalAbort?: boolean;
|
|
87
|
+
/** pi ≥ 1.1 agent_settled.aborted: the run was cancelled — the closing message may still come after a re-run. */
|
|
88
|
+
aborted?: boolean;
|
|
82
89
|
compactionInFlight?: boolean;
|
|
83
90
|
lastStopReason: string | null;
|
|
84
91
|
}): boolean {
|
|
85
92
|
return !input.intentionalAbort
|
|
93
|
+
&& input.aborted !== true
|
|
86
94
|
&& !input.compactionInFlight
|
|
87
95
|
&& input.lastStopReason !== ABORT_STOP_REASON;
|
|
88
96
|
}
|
package/src/subagent-core.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* decisions, task-id resolution, and the outbound port types. Pure: planners decide, adapters do I/O.
|
|
4
4
|
*/
|
|
5
5
|
import { normalizeEntryKind } from './history-store.ts';
|
|
6
|
+
import { Type } from 'typebox';
|
|
6
7
|
|
|
7
8
|
export function sleep(ms: number): Promise<void> {
|
|
8
9
|
const { promise, resolve } = Promise.withResolvers<void>();
|
|
@@ -24,6 +25,113 @@ export function buildLaunchLine(parts: readonly string[], platform: NodeJS.Platf
|
|
|
24
25
|
return (win ? '& ' : '') + quoted.join(' ');
|
|
25
26
|
}
|
|
26
27
|
|
|
28
|
+
/** Shells that mean a freshly split pane is at its prompt and can accept a launch line. */
|
|
29
|
+
const SHELL_PROCESS_NAMES: ReadonlySet<string> = new Set([
|
|
30
|
+
'sh', 'bash', 'zsh', 'fish', 'dash', 'ksh', 'mksh', 'tcsh', 'csh', 'nu', 'login',
|
|
31
|
+
'pwsh', 'powershell', 'cmd',
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
/** Normalize `name` / `argv0` (`/bin/zsh`, `C:\\Windows\\System32\\cmd.exe`, `-zsh`) to a shell key. */
|
|
35
|
+
export function shellProcessName(value: string | null | undefined): string {
|
|
36
|
+
const base = String(value ?? '').replace(/\\/g, '/').split('/').pop()?.toLowerCase() ?? '';
|
|
37
|
+
const stripped = base.replace(/^-/, '').replace(/\.exe$/, '');
|
|
38
|
+
return stripped;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Decide whether a pane's foreground set means its shell is ready for input. `null`/`undefined`
|
|
43
|
+
* means the probe was unavailable: return ready so an older herdr never adds spawn latency.
|
|
44
|
+
* An empty set is not proof (the shell may still be starting), so callers wait for the timeout.
|
|
45
|
+
*/
|
|
46
|
+
export function planShellReady(
|
|
47
|
+
foreground: ReadonlyArray<{ name?: string | null; argv0?: string | null }> | null | undefined,
|
|
48
|
+
): 'ready' | 'wait' {
|
|
49
|
+
if (foreground == null) return 'ready';
|
|
50
|
+
if (foreground.length === 0) return 'wait';
|
|
51
|
+
return foreground.some((p) => SHELL_PROCESS_NAMES.has(shellProcessName(p.name)) || SHELL_PROCESS_NAMES.has(shellProcessName(p.argv0)))
|
|
52
|
+
? 'ready'
|
|
53
|
+
: 'wait';
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Flags pier passes at spawn (buildLaunchParts) that a herdr 0.9.2+ resume command must replay. */
|
|
57
|
+
const RESUME_FLAG_ARITY: Record<string, number> = {
|
|
58
|
+
'-a': 0,
|
|
59
|
+
'-e': 1,
|
|
60
|
+
'--extension': 1,
|
|
61
|
+
'--tui-mode': 1,
|
|
62
|
+
'--provider': 1,
|
|
63
|
+
'--model': 1,
|
|
64
|
+
'--session': 1,
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const RESUME_MAX_ARGS = 64;
|
|
68
|
+
const RESUME_MAX_BYTES = 8 * 1024;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* herdr 0.9.2 self-report (docs "Add Herdr support to your agent"): rebuild this worker's launch
|
|
72
|
+
* line as a `resume_argv` herdr runs after a server restart. The worker's own process.argv is the
|
|
73
|
+
* source of truth — interpreter and cli.js paths are dropped, whitelisted spawn flags are mirrored
|
|
74
|
+
* (`--flag=value` splits), argv[0] becomes a plain PATH command name. herdr rules: argv[0] plain,
|
|
75
|
+
* no apostrophes/control characters, ≤64 args, ≤8 KiB; violations (or a missing `-e`, which could
|
|
76
|
+
* not restore the extension) return null and the caller skips the report — restore stays best-effort.
|
|
77
|
+
*/
|
|
78
|
+
export function buildResumeArgv(workerArgv: readonly string[], piBin = 'pi'): string[] | null {
|
|
79
|
+
if (!/^[A-Za-z0-9._-]+$/.test(piBin)) return null;
|
|
80
|
+
const collected: string[] = [];
|
|
81
|
+
let index = 0;
|
|
82
|
+
// Skip interpreter + cli script (everything before the first flag).
|
|
83
|
+
while (index < workerArgv.length && !workerArgv[index]!.startsWith('-')) index++;
|
|
84
|
+
for (; index < workerArgv.length; index++) {
|
|
85
|
+
const raw = workerArgv[index]!;
|
|
86
|
+
if (!raw.startsWith('-')) continue; // positional (unknown flag's value) — not pier spawn shape
|
|
87
|
+
const eq = raw.indexOf('=');
|
|
88
|
+
const flag = eq > 0 ? raw.slice(0, eq) : raw;
|
|
89
|
+
const inlineValue = eq > 0 ? raw.slice(eq + 1) : null;
|
|
90
|
+
const arity = RESUME_FLAG_ARITY[flag];
|
|
91
|
+
if (arity === undefined) continue; // pi-internal flags are not replayed
|
|
92
|
+
if (arity === 0) {
|
|
93
|
+
collected.push(flag);
|
|
94
|
+
} else if (inlineValue !== null) {
|
|
95
|
+
collected.push(flag, inlineValue);
|
|
96
|
+
} else if (index + 1 < workerArgv.length) {
|
|
97
|
+
collected.push(flag, workerArgv[++index]!);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (!collected.some((f, i) => (f === '-e' || f === '--extension') && i + 1 < collected.length)) return null;
|
|
101
|
+
const argv = [piBin, ...collected];
|
|
102
|
+
if (argv.length > RESUME_MAX_ARGS) return null;
|
|
103
|
+
if (argv.some((a) => /['\u0000-\u001f\u007f]/.test(a))) return null;
|
|
104
|
+
if (argv.reduce((sum, a) => sum + a.length + 1, 0) > RESUME_MAX_BYTES) return null;
|
|
105
|
+
return argv;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* pi ≥ 0.99 outputSchema for the subagent tool: the `details` each action already produces, stamped
|
|
110
|
+
* with the normalized action. Codemode scripts (and any structured consumer) read this instead of
|
|
111
|
+
* parsing the prose reply; the text content stays authoritative for the transcript. Hard failures
|
|
112
|
+
* throw (toolError), so every returned value is a success and always carries structuredContent.
|
|
113
|
+
*/
|
|
114
|
+
export const SUBAGENT_OUTPUT_SCHEMA = Type.Object({
|
|
115
|
+
action: Type.Union([
|
|
116
|
+
Type.Literal('spawn'), Type.Literal('resume'), Type.Literal('list'),
|
|
117
|
+
Type.Literal('send'), Type.Literal('interrupt'), Type.Literal('output'), Type.Literal('role'),
|
|
118
|
+
]),
|
|
119
|
+
paneId: Type.Optional(Type.String()),
|
|
120
|
+
taskId: Type.Optional(Type.String()),
|
|
121
|
+
role: Type.Optional(Type.String()),
|
|
122
|
+
background: Type.Optional(Type.Boolean()),
|
|
123
|
+
alreadyRunning: Type.Optional(Type.Boolean()),
|
|
124
|
+
}, { additionalProperties: true });
|
|
125
|
+
|
|
126
|
+
export function attachSubagentStructuredContent(action: string, result: unknown): unknown {
|
|
127
|
+
if (result === null || typeof result !== 'object' || Array.isArray(result)) return result;
|
|
128
|
+
const record = result as Record<string, unknown>;
|
|
129
|
+
const details = record.details !== null && typeof record.details === 'object' && !Array.isArray(record.details)
|
|
130
|
+
? record.details as Record<string, unknown>
|
|
131
|
+
: {};
|
|
132
|
+
return { ...record, structuredContent: { action, ...details } };
|
|
133
|
+
}
|
|
134
|
+
|
|
27
135
|
/** Bounds concurrent delegation spawns (SUBAGENT_CONCURRENCY). */
|
|
28
136
|
export class Semaphore {
|
|
29
137
|
private active = 0;
|
|
@@ -147,6 +255,9 @@ export interface SubEntry {
|
|
|
147
255
|
/** 'task' or a role name; only a display label for GC. */
|
|
148
256
|
kind: string;
|
|
149
257
|
paneId: string;
|
|
258
|
+
/** Phase 2 task slug (kebab-case of description; RFC rfc-delegation-identity §3.1): tool args
|
|
259
|
+
* accept `slug@paneId` or the bare slug; allocated once at spawn and stable across revives. */
|
|
260
|
+
slug?: string;
|
|
150
261
|
tabId: string;
|
|
151
262
|
tabName: string;
|
|
152
263
|
cwd: string;
|
|
@@ -173,6 +284,41 @@ export interface SubEntry {
|
|
|
173
284
|
};
|
|
174
285
|
}
|
|
175
286
|
|
|
287
|
+
/* ── task slugs (RFC rfc-delegation-identity §3.1) ───────────────── */
|
|
288
|
+
|
|
289
|
+
/** Slug cap: short enough for tool args and list rows, long enough to stay distinctive. */
|
|
290
|
+
const SLUG_MAX = 24;
|
|
291
|
+
|
|
292
|
+
/** Kebab-case a task description; non-ASCII (CJK descriptions) sanitizes to '' — the allocator
|
|
293
|
+
* then falls back to a taskId-derived ascii slug, because tool args must stay typeable. */
|
|
294
|
+
export function slugifyTask(description: string): string {
|
|
295
|
+
return description
|
|
296
|
+
.toLowerCase()
|
|
297
|
+
.replace(/[''']/g, '')
|
|
298
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
299
|
+
.replace(/^-+|-+$/g, '')
|
|
300
|
+
.slice(0, SLUG_MAX)
|
|
301
|
+
.replace(/-+$/g, '');
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** Allocate a unique slug among live rows: base, then base-2, base-3, …; a description that
|
|
305
|
+
* sanitizes to nothing (CJK, emoji-only) falls back to `task-<taskId prefix>`. */
|
|
306
|
+
export function allocateSubSlug(description: string, taskId: string, taken: ReadonlySet<string>): string {
|
|
307
|
+
const base = slugifyTask(description) || `task-${taskId.slice(0, 6)}`;
|
|
308
|
+
if (!taken.has(base)) return base;
|
|
309
|
+
for (let n = 2; ; n++) {
|
|
310
|
+
const next = `${base}-${n}`;
|
|
311
|
+
if (!taken.has(next)) return next;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Stable display/argument form for a subagent row: `slug@paneId` when a slug exists. */
|
|
316
|
+
export function subRef(sub: { paneId: string; slug?: string }): string {
|
|
317
|
+
return sub.slug ? `${sub.slug}@${sub.paneId}` : sub.paneId;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
export const PROGRESS_CUSTOM_TYPE = 'pi-herdr.progress';
|
|
321
|
+
|
|
176
322
|
export interface SubsRegistry {
|
|
177
323
|
version: 2;
|
|
178
324
|
subs: SubEntry[];
|
|
@@ -198,6 +344,7 @@ export function foldSubsRegistry(entries: ReadonlyArray<{ type?: string; customT
|
|
|
198
344
|
taskId: typeof s.taskId === 'string' ? s.taskId : s.paneId,
|
|
199
345
|
kind: normalizeEntryKind(typeof s.kind === 'string' ? s.kind : undefined),
|
|
200
346
|
paneId: s.paneId,
|
|
347
|
+
slug: typeof s.slug === 'string' && s.slug ? s.slug : undefined,
|
|
201
348
|
tabId: typeof s.tabId === 'string' ? s.tabId : '',
|
|
202
349
|
tabName: typeof s.tabName === 'string' ? s.tabName : '',
|
|
203
350
|
cwd: typeof s.cwd === 'string' ? s.cwd : '',
|
|
@@ -395,6 +542,12 @@ export function buildLaunchParts(
|
|
|
395
542
|
|
|
396
543
|
const ISOLATE_SLUG_MAX = 40;
|
|
397
544
|
|
|
545
|
+
/**
|
|
546
|
+
* D-3 (verified 2026-10-20): herdr 0.9.5 `worktree create --workspace <parent>` still opens a new
|
|
547
|
+
* top-level workspace, not a tab inside the parent workspace, so it cannot replace pier's isolate
|
|
548
|
+
* placement. Keep `git worktree add` + the master-workspace tab; native worktree management remains
|
|
549
|
+
* an option only for workflows that accept a separate workspace.
|
|
550
|
+
*/
|
|
398
551
|
export function planIsolateWorktree(opts: {
|
|
399
552
|
description: string;
|
|
400
553
|
taskHex: string;
|
|
@@ -553,7 +706,7 @@ export function planForegroundTick(input: {
|
|
|
553
706
|
|
|
554
707
|
/* ── task-id resolution ─────────────────────────────────────────── */
|
|
555
708
|
|
|
556
|
-
type TaskIdResolutionResult =
|
|
709
|
+
export type TaskIdResolutionResult =
|
|
557
710
|
| { kind: 'resolved'; taskId: string }
|
|
558
711
|
| { kind: 'ambiguous'; query: string; candidates: string[] }
|
|
559
712
|
| { kind: 'too_short'; query: string }
|
|
@@ -583,13 +736,28 @@ export function resolveTaskIdPrefix(
|
|
|
583
736
|
return { kind: 'not_found', query: trimmed };
|
|
584
737
|
}
|
|
585
738
|
|
|
739
|
+
/** Error text for a non-resolved `resolveTaskIdPrefix` outcome; null for `resolved` (each caller owns
|
|
740
|
+
* its success path) and `not_found` (each caller words its own unknown-id hint). `rawQuery` keeps
|
|
741
|
+
* the caller's original string in the message even though matching works on the trimmed form. */
|
|
742
|
+
export function taskIdResolutionError(
|
|
743
|
+
res: TaskIdResolutionResult,
|
|
744
|
+
label: 'task id' | 'subagent id',
|
|
745
|
+
rawQuery: string,
|
|
746
|
+
): string | null {
|
|
747
|
+
if (res.kind === 'too_short') return `Error: ${label} prefix "${rawQuery}" is too short (minimum 4 characters).`;
|
|
748
|
+
if (res.kind === 'ambiguous') return ambiguousIdError(label, rawQuery, res.candidates);
|
|
749
|
+
return null;
|
|
750
|
+
}
|
|
751
|
+
|
|
586
752
|
/* ── outbound port (composition root → plugin) ──────────────────── */
|
|
587
753
|
|
|
588
754
|
/** Bound atomically at session_start: consumers read `current` and must tolerate null. */
|
|
589
755
|
export interface SubagentPort {
|
|
590
756
|
applyReplySession(paneId: string, sessionFile: string | null): void;
|
|
591
757
|
reconcileOnReply(paneId: string): string[];
|
|
592
|
-
listRunningSubs(): Array<{ paneId: string; description: string }>;
|
|
758
|
+
listRunningSubs(): Array<{ paneId: string; slug?: string; description: string; kind: string }>;
|
|
759
|
+
/** D113: non-closed rows for the /pier-tree view (settled nodes carry their settle time). */
|
|
760
|
+
listRows(): Array<{ paneId: string; slug?: string; description: string; kind: string; status: SubEntry['status']; settledAt: number | null }>;
|
|
593
761
|
settleStatLine(paneId: string): Promise<string | null>;
|
|
594
762
|
/** Pipe settlement: drop the row out of listRunningSubs before the finish notice wakes the master. */
|
|
595
763
|
consumeReply(paneId: string, outcome: string | null): void;
|
package/src/subagent-poller.ts
CHANGED
|
@@ -375,7 +375,23 @@ export function createPoller(h: PollerHost): Poller {
|
|
|
375
375
|
if (tail && settleAskIsSafe(tail)) {
|
|
376
376
|
const res = await h.jev.ask(
|
|
377
377
|
settleVerdictRequest({ description: current.description, tail }),
|
|
378
|
-
{
|
|
378
|
+
{
|
|
379
|
+
questionId: 'settle-attribution',
|
|
380
|
+
timeoutMs: 2500,
|
|
381
|
+
// Calibration fields (D115): noul answers carry no confidence, so the
|
|
382
|
+
// probabilities and the composed verdict are what SETTLE_*_MIN_NOUL
|
|
383
|
+
// tuning reads from jev.jsonl.
|
|
384
|
+
enrich: ({ ok, answers }) => {
|
|
385
|
+
if (!ok || !answers) return {};
|
|
386
|
+
const match = answers.tail_matches_task;
|
|
387
|
+
const fin = answers.tail_has_final_answer;
|
|
388
|
+
return {
|
|
389
|
+
tailMatchesTaskNoul: match?.type === 'noul' ? match.noul : null,
|
|
390
|
+
tailHasFinalAnswerNoul: fin?.type === 'noul' ? fin.noul : null,
|
|
391
|
+
settleVerdict: evaluateSettleVerdict(answers) ?? 'unanswered',
|
|
392
|
+
};
|
|
393
|
+
},
|
|
394
|
+
},
|
|
379
395
|
);
|
|
380
396
|
if (res.ok) {
|
|
381
397
|
const verdict = evaluateSettleVerdict(res.answers);
|
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,6 +20,7 @@ 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,
|
|
@@ -32,8 +35,10 @@ import {
|
|
|
32
35
|
planForegroundTick,
|
|
33
36
|
planIsolateWorktree,
|
|
34
37
|
planLaunchValidation,
|
|
38
|
+
planShellReady,
|
|
35
39
|
planTabPlacement,
|
|
36
40
|
sleep,
|
|
41
|
+
subRef,
|
|
37
42
|
type SubEntry,
|
|
38
43
|
type TabPlacementPlan,
|
|
39
44
|
type WorktreeZone,
|
|
@@ -45,6 +50,11 @@ import type { SessionIo } from './subagent-session.ts';
|
|
|
45
50
|
|
|
46
51
|
const statAsync = promisify(statCb);
|
|
47
52
|
|
|
53
|
+
/** Bound on waiting for a fresh pane's shell to reach its prompt. On timeout the launch line is sent
|
|
54
|
+
* anyway: `waitSubReady` still diagnoses a failed boot, and an unsupported probe must not stall spawn. */
|
|
55
|
+
export const SHELL_READY_TIMEOUT_MS = 2_000;
|
|
56
|
+
const SHELL_READY_POLL_MS = 100;
|
|
57
|
+
|
|
48
58
|
/* ── git adapter ────────────────────────────────────────────────── */
|
|
49
59
|
|
|
50
60
|
export interface GitResult {
|
|
@@ -302,6 +312,18 @@ export function createSpawner(h: SpawnerHost): Spawner {
|
|
|
302
312
|
return buildLaunchLine(buildLaunchParts(h.runtime, { resumeFile, roleModel, approve }));
|
|
303
313
|
}
|
|
304
314
|
|
|
315
|
+
/** herdr 0.9.4 refuses over-long commands on a shell that is still starting; `pane.send_text`
|
|
316
|
+
* has no such guard. Poll the foreground process until the shell owns the PTY (or give up). */
|
|
317
|
+
async function waitShellReady(paneId: string): Promise<void> {
|
|
318
|
+
const deadline = Date.now() + SHELL_READY_TIMEOUT_MS;
|
|
319
|
+
while (Date.now() < deadline) {
|
|
320
|
+
const info = await h.client.processInfo(paneId).catch(() => null);
|
|
321
|
+
if (info === null) return; // older server or pane already gone: keep the old best-effort path
|
|
322
|
+
if (planShellReady(info.foreground) === 'ready') return;
|
|
323
|
+
await sleep(SHELL_READY_POLL_MS);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
305
327
|
async function approveFor(cwd: string, masterCwd: string): Promise<boolean> {
|
|
306
328
|
if (isPathUnder(cwd, masterCwd)) return true;
|
|
307
329
|
const zone = classifyWorktreeZone({ cwd, masterCwd, worktrees: await h.git.listWorktrees(masterCwd) });
|
|
@@ -471,6 +493,7 @@ export function createSpawner(h: SpawnerHost): Spawner {
|
|
|
471
493
|
focus: false,
|
|
472
494
|
...(ratio != null ? { ratio } : {}),
|
|
473
495
|
});
|
|
496
|
+
await waitShellReady(paneId);
|
|
474
497
|
await h.client.sendPaneText(paneId, launch);
|
|
475
498
|
return { tabId: plan.tabId, paneId, tabName: plan.tabName };
|
|
476
499
|
}
|
|
@@ -482,6 +505,7 @@ export function createSpawner(h: SpawnerHost): Spawner {
|
|
|
482
505
|
cwd,
|
|
483
506
|
env: envOver,
|
|
484
507
|
});
|
|
508
|
+
await waitShellReady(created.paneId);
|
|
485
509
|
await h.client.sendPaneText(created.paneId, launch);
|
|
486
510
|
return { tabId: created.tabId, paneId: created.paneId, tabName: plan.tabName };
|
|
487
511
|
} finally {
|
|
@@ -517,11 +541,13 @@ interface SpawnActionHost {
|
|
|
517
541
|
writeHistory(entry: SubEntry, patch?: Partial<HistoryEntry>, via?: string): void;
|
|
518
542
|
pendingIsolateBranches: Set<string>;
|
|
519
543
|
lastMachineInjectAt: Map<string, number>;
|
|
520
|
-
lastRequestIdByPane: Map<string, string>;
|
|
544
|
+
lastRequestIdByPane: Map<string, number | string>;
|
|
521
545
|
git: GitIo;
|
|
522
546
|
session: SessionIo;
|
|
523
547
|
spawn: Spawner;
|
|
524
548
|
poller: Poller;
|
|
549
|
+
/** Master session id for the delegation identity handed to the worker. */
|
|
550
|
+
getSessionId: () => string;
|
|
525
551
|
/** Phase 0 routing telemetry (RFC rfc-jev-role-routing §8): best-effort spawn profile; absent in tests. */
|
|
526
552
|
logRouting?: (row: RoutingTelemetryRecord) => void;
|
|
527
553
|
}
|
|
@@ -545,6 +571,8 @@ export function createSpawnAction(h: SpawnActionHost) {
|
|
|
545
571
|
// Validate the role before any isolate worktree exists: this early return has no cleanup path.
|
|
546
572
|
let roleManifestEnv: Record<string, string> = {};
|
|
547
573
|
let roleModel: string | null = null;
|
|
574
|
+
// Delegation identity (RFC rfc-delegation-identity §2.1): the worker learns who delegated it.
|
|
575
|
+
let delegationEnvPatch: Record<string, string> = {};
|
|
548
576
|
try {
|
|
549
577
|
const { role, manifest } = composeForRole(manifestRole ?? 'worker-default', suggested, { loadRoleOpts: { baseDir: masterCwd } });
|
|
550
578
|
roleManifestEnv = {
|
|
@@ -561,6 +589,15 @@ export function createSpawnAction(h: SpawnActionHost) {
|
|
|
561
589
|
// Role base: /pier-role and the pipe switch resolve .pi-herdr/roles/ against the MASTER's checkout.
|
|
562
590
|
PI_HERDR_ROLE_BASE: masterCwd,
|
|
563
591
|
};
|
|
592
|
+
delegationEnvPatch = delegationEnv({
|
|
593
|
+
parentPaneId: env?.paneId ?? '',
|
|
594
|
+
parentSessionId: bareSessionId(h.getSessionId()),
|
|
595
|
+
// Phase 2: the master's own pipe so the worker's report_progress can dial home (D49).
|
|
596
|
+
parentPipe: pipeNameFor(masterCwd, env?.paneId ?? ''),
|
|
597
|
+
taskId,
|
|
598
|
+
description: spec.description,
|
|
599
|
+
role: role.role,
|
|
600
|
+
});
|
|
564
601
|
if (typeof role.model === 'string' && role.model.trim()) roleModel = role.model.trim();
|
|
565
602
|
h.logRouting?.(
|
|
566
603
|
planSpawnProfileRow({
|
|
@@ -631,14 +668,24 @@ export function createSpawnAction(h: SpawnActionHost) {
|
|
|
631
668
|
const spawned = await spawnPaneInTaskTab(
|
|
632
669
|
{ desiredTab: tab, description: spec.description, zone },
|
|
633
670
|
cwd,
|
|
634
|
-
|
|
671
|
+
// pier owns worker state reporting (report_agent with todo/blocked badges); pi 1.1+'s own
|
|
672
|
+
// OSC 7501 program-status emission would become a second, competing source once a terminal
|
|
673
|
+
// or dashboard starts reading it. One reporter per pane.
|
|
674
|
+
{ PI_HERDR_SUBAGENT: '1', PI_PROGRAM_STATUS: '0', ...roleManifestEnv, ...delegationEnvPatch },
|
|
635
675
|
launchLine(null, roleModel, approve),
|
|
636
676
|
);
|
|
637
677
|
paneId = spawned.paneId;
|
|
678
|
+
// Phase 2: allocate once per spawn; revives/history keep the row's slug (stability beats beauty).
|
|
679
|
+
const slug = allocateSubSlug(
|
|
680
|
+
spec.description,
|
|
681
|
+
taskId,
|
|
682
|
+
new Set([...subs.values()].map((s) => s.slug ?? '').filter(Boolean)),
|
|
683
|
+
);
|
|
638
684
|
const entry: SubEntry = {
|
|
639
685
|
taskId,
|
|
640
686
|
kind,
|
|
641
687
|
paneId,
|
|
688
|
+
slug,
|
|
642
689
|
tabId: spawned.tabId,
|
|
643
690
|
tabName: spawned.tabName,
|
|
644
691
|
cwd,
|
|
@@ -686,8 +733,8 @@ export function createSpawnAction(h: SpawnActionHost) {
|
|
|
686
733
|
if (background) {
|
|
687
734
|
handToPoller(`prompt-${taskId}`);
|
|
688
735
|
return {
|
|
689
|
-
content: [{ type: 'text', text: `started subagent ${
|
|
690
|
-
details: { paneId, taskId, background: true, role: kind },
|
|
736
|
+
content: [{ type: 'text', text: `started subagent ${subRef(entry)} (task ${taskId})` }],
|
|
737
|
+
details: { paneId, taskId, background: true, role: kind, slug },
|
|
691
738
|
};
|
|
692
739
|
}
|
|
693
740
|
|
package/src/terminal-core.ts
CHANGED
|
@@ -69,7 +69,9 @@ 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;
|
|
@@ -123,6 +125,7 @@ export function registerTerminal(
|
|
|
123
125
|
label: opts.label ?? (opts.cwd ? basename(opts.cwd.replace(/\\/g, '/')) || opts.cwd : null),
|
|
124
126
|
createdAt: opts.createdAt,
|
|
125
127
|
lastActivityAt: opts.createdAt,
|
|
128
|
+
nudges: 0,
|
|
126
129
|
nudgedAt: null,
|
|
127
130
|
status: 'open',
|
|
128
131
|
closedAt: null,
|
|
@@ -413,6 +416,9 @@ export function foldTerminalsRegistry(
|
|
|
413
416
|
label: typeof t.label === 'string' ? t.label : null,
|
|
414
417
|
createdAt: typeof t.createdAt === 'number' ? t.createdAt : 0,
|
|
415
418
|
lastActivityAt: typeof t.lastActivityAt === 'number' ? t.lastActivityAt : 0,
|
|
419
|
+
nudges: typeof t.nudges === 'number'
|
|
420
|
+
? Math.max(0, Math.min(TERM_REMINDERS_MAX, Math.floor(t.nudges)))
|
|
421
|
+
: (typeof t.nudgedAt === 'number' ? 1 : 0),
|
|
416
422
|
nudgedAt: typeof t.nudgedAt === 'number' ? t.nudgedAt : null,
|
|
417
423
|
status: t.status === 'closed' ? ('closed' as const) : ('open' as const),
|
|
418
424
|
closedAt: typeof t.closedAt === 'number' ? t.closedAt : null,
|
|
@@ -426,17 +432,20 @@ export function foldTerminalsRegistry(
|
|
|
426
432
|
return found;
|
|
427
433
|
}
|
|
428
434
|
|
|
429
|
-
/* ──
|
|
430
|
-
* Terminals persist by design, so a finished shell lingers as a dead split
|
|
431
|
-
* the
|
|
435
|
+
/* ── Terminal nudge: turn-end self-cleanup for shells the model forgot to close ──
|
|
436
|
+
* Terminals persist by design, so a finished shell lingers as a dead split. Two notices per terminal:
|
|
437
|
+
* 1. at the end of the turn that used it ("used this turn") — or the first turn that finds it idle
|
|
438
|
+
* past the threshold, for a shell nobody touched recently;
|
|
439
|
+
* 2. on a later settle once it has been idle past PIER_TERM_IDLE_MS.
|
|
440
|
+
* Delivery wiring mirrors the todo stop-reminder in plugins/terminal. */
|
|
432
441
|
|
|
433
|
-
/**
|
|
442
|
+
/** Per-terminal nudge cap: one after use, one after the idle threshold (goodbye-loop guard). */
|
|
434
443
|
export const TERM_REMINDERS_MAX = 2;
|
|
435
444
|
|
|
436
|
-
/** Idle threshold
|
|
445
|
+
/** Idle threshold for the second nudge; the env var is read per call so tests can use small values. */
|
|
437
446
|
export function terminalIdleMs(): number {
|
|
438
447
|
// B10: canonical PIER_TERM_IDLE_MS, legacy PI_HERDR_TERM_IDLE_MS.
|
|
439
|
-
return Number(pierOption('PIER_TERM_IDLE_MS') ??
|
|
448
|
+
return Number(pierOption('PIER_TERM_IDLE_MS') ?? 5 * 60_000) || 5 * 60_000;
|
|
440
449
|
}
|
|
441
450
|
|
|
442
451
|
/** Grace from agent_settled to injection, letting the user read the answer and intervene; cancel on agent_start. */
|
|
@@ -446,55 +455,80 @@ export function terminalReminderGraceMs(): number {
|
|
|
446
455
|
|
|
447
456
|
export const TERM_REMINDER_CUSTOM_TYPE = 'pi-herdr.term-reminder';
|
|
448
457
|
|
|
449
|
-
export interface
|
|
458
|
+
export interface TerminalNudgeInput {
|
|
450
459
|
terminalId: string;
|
|
451
460
|
cwd: string;
|
|
452
461
|
label: string | null;
|
|
453
462
|
lastActivityAt: number;
|
|
454
|
-
/**
|
|
463
|
+
/** Nudges already delivered; when absent, legacy `nudgedAt` counts as one. */
|
|
464
|
+
nudges?: number;
|
|
455
465
|
nudgedAt?: number | null;
|
|
466
|
+
/** A non-shell foreground process owns the pane (dev server / running job); never nudge it. */
|
|
467
|
+
busy?: boolean;
|
|
456
468
|
}
|
|
457
469
|
|
|
458
|
-
export interface
|
|
470
|
+
export interface TerminalReminderPlan {
|
|
459
471
|
due: boolean;
|
|
460
472
|
content: string | null;
|
|
461
|
-
/**
|
|
462
|
-
nextReminders: number;
|
|
463
|
-
/** Terminal ids this nudge covers; the caller marks them nudged at schedule time. */
|
|
473
|
+
/** Terminals covered by this notice; the caller bumps each one's `nudges` at schedule time. */
|
|
464
474
|
ids: string[];
|
|
465
475
|
}
|
|
466
476
|
|
|
467
|
-
function
|
|
468
|
-
return { due: false, content: null,
|
|
477
|
+
function noTerminalInject(): TerminalReminderPlan {
|
|
478
|
+
return { due: false, content: null, ids: [] };
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function nudgesOf(t: TerminalNudgeInput): number {
|
|
482
|
+
return t.nudges ?? (t.nudgedAt != null ? 1 : 0);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/** `72m` / `3h12m`: the reason column reads at a glance without a timestamp. */
|
|
486
|
+
function formatIdleAge(ms: number): string {
|
|
487
|
+
const minutes = Math.max(0, Math.floor(ms / 60_000));
|
|
488
|
+
if (minutes < 60) return `${minutes}m`;
|
|
489
|
+
const hours = Math.floor(minutes / 60);
|
|
490
|
+
return `${hours}h${String(minutes % 60).padStart(2, '0')}m`;
|
|
469
491
|
}
|
|
470
492
|
|
|
471
493
|
/**
|
|
472
|
-
*
|
|
473
|
-
*
|
|
494
|
+
* First notice: the terminal was used by the agent run that just settled, or it is already idle past
|
|
495
|
+
* the threshold. Second notice: a terminal with one nudge is idle past the threshold again. A shell
|
|
496
|
+
* touched recently is never nudged, so a dev server in active use stays out.
|
|
474
497
|
*/
|
|
475
|
-
export function
|
|
476
|
-
input: { open: readonly
|
|
477
|
-
):
|
|
498
|
+
export function planTerminalReminder(
|
|
499
|
+
input: { open: readonly TerminalNudgeInput[]; now: number; turnStartedAt?: number | null },
|
|
500
|
+
): TerminalReminderPlan {
|
|
478
501
|
const idleMs = terminalIdleMs();
|
|
479
|
-
const
|
|
480
|
-
|
|
481
|
-
)
|
|
482
|
-
|
|
483
|
-
|
|
502
|
+
const rows: string[] = [];
|
|
503
|
+
const ids: string[] = [];
|
|
504
|
+
for (const t of input.open) {
|
|
505
|
+
if (t.busy) continue;
|
|
506
|
+
const nudges = nudgesOf(t);
|
|
507
|
+
if (nudges >= TERM_REMINDERS_MAX) continue;
|
|
508
|
+
const idleFor = input.now - t.lastActivityAt;
|
|
509
|
+
const sinceNudge = t.nudgedAt != null ? input.now - t.nudgedAt : Number.POSITIVE_INFINITY;
|
|
510
|
+
const usedThisTurn = input.turnStartedAt != null && t.lastActivityAt >= input.turnStartedAt;
|
|
511
|
+
// Second notice waits another full idle window after the first one, so a pane that was already
|
|
512
|
+
// idle for an hour at nudge 1 is not nudged twice back-to-back on the next settle.
|
|
513
|
+
const reason = nudges === 0 && usedThisTurn
|
|
514
|
+
? 'used this turn'
|
|
515
|
+
: nudges === 0 && idleFor >= idleMs
|
|
516
|
+
? `idle ${formatIdleAge(idleFor)}`
|
|
517
|
+
: nudges === 1 && idleFor >= idleMs && sinceNudge >= idleMs
|
|
518
|
+
? `still idle ${formatIdleAge(idleFor)}`
|
|
519
|
+
: null;
|
|
520
|
+
if (reason == null) continue;
|
|
521
|
+
rows.push(`- ${t.terminalId} (${t.label ?? t.cwd}) — ${reason} · nudge ${nudges + 1}/${TERM_REMINDERS_MAX}`);
|
|
522
|
+
ids.push(t.terminalId);
|
|
484
523
|
}
|
|
485
|
-
|
|
524
|
+
if (ids.length === 0) return noTerminalInject();
|
|
486
525
|
const content = [
|
|
487
|
-
|
|
488
|
-
|
|
526
|
+
'<system-reminder>Open terminal(s) that may be finished:',
|
|
527
|
+
...rows,
|
|
489
528
|
'This is an internal housekeeping notice — do NOT reply to the user and do NOT send any farewell on this notice.',
|
|
490
529
|
'If the work in them is done, close them now with terminal(action: "close", terminal_id: "…").',
|
|
491
530
|
'Keep a terminal only if a long-running process still needs that shell.',
|
|
492
531
|
'</system-reminder>',
|
|
493
532
|
].join('\n');
|
|
494
|
-
return {
|
|
495
|
-
due: true,
|
|
496
|
-
content,
|
|
497
|
-
nextReminders: input.reminders + 1,
|
|
498
|
-
ids: idle.map((t) => t.terminalId),
|
|
499
|
-
};
|
|
533
|
+
return { due: true, content, ids };
|
|
500
534
|
}
|
package/src/todo-read-hook.ts
CHANGED
|
@@ -13,6 +13,29 @@ import { todoMark } from './todo-window.ts';
|
|
|
13
13
|
const EMPTY_GUARD_EVERY_N = 4;
|
|
14
14
|
const TODO_READ_CUSTOM_TYPE = 'pi-herdr.todo-read';
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* pi 1.2 fires `before_agent_start` for runs started by `sendMessage(..., { triggerTurn: true })`
|
|
18
|
+
* too. The todo hook wants the plan in every *user* run and after a compaction continuation (the
|
|
19
|
+
* model needs the plan to resume), but not in its own stop reminder or the config guide: those
|
|
20
|
+
* already carry their own text, and counting them as turns would advance the guard cadence.
|
|
21
|
+
* Keep this list in sync with COMPACTION_CONTINUE_TYPE in compact-coordinator.ts.
|
|
22
|
+
*/
|
|
23
|
+
export const TODO_READ_CUSTOM_TYPES: readonly string[] = ['pi-herdr.compaction-continue'];
|
|
24
|
+
|
|
25
|
+
/** Whether this run should receive the todo read hook. Missing `messageType` is pi < 1.2, where
|
|
26
|
+
* only user runs fired `before_agent_start`; treat it as a user run for compatibility. */
|
|
27
|
+
export function shouldRunTodoRead(event: unknown): boolean {
|
|
28
|
+
if (event === null || typeof event !== 'object' || !('messageType' in event)) return true;
|
|
29
|
+
const messageType = (event as { messageType?: unknown }).messageType;
|
|
30
|
+
if (messageType === 'user') return true;
|
|
31
|
+
if (messageType !== 'custom') return false;
|
|
32
|
+
const message = (event as { message?: unknown }).message;
|
|
33
|
+
const customType = message !== null && typeof message === 'object' && 'customType' in message
|
|
34
|
+
? (message as { customType?: unknown }).customType
|
|
35
|
+
: undefined;
|
|
36
|
+
return typeof customType === 'string' && TODO_READ_CUSTOM_TYPES.includes(customType);
|
|
37
|
+
}
|
|
38
|
+
|
|
16
39
|
type TodoReadEffect =
|
|
17
40
|
| 'recite' // Recite normally while the list is fresh.
|
|
18
41
|
| 'empty-guard' // Guard against an empty list.
|