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-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'), Type.Literal('help'),
|
|
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;
|
|
@@ -87,6 +195,14 @@ export interface AliveProbe {
|
|
|
87
195
|
foregroundCwd?: string | null;
|
|
88
196
|
}
|
|
89
197
|
|
|
198
|
+
/** Trailing line for isolate spawn results: the branch (`pier/<slug>`) reads like a directory and
|
|
199
|
+
* masters have cd'd into `<repo>/pier/<slug>`; only the worktree path is an actual directory. */
|
|
200
|
+
export function formatIsolateLocation(
|
|
201
|
+
isolate: Pick<NonNullable<SubEntry['isolate']>, 'worktreePath' | 'branch'> | null | undefined,
|
|
202
|
+
): string {
|
|
203
|
+
return isolate ? `\nWorktree: ${isolate.worktreePath} (branch ${isolate.branch})` : '';
|
|
204
|
+
}
|
|
205
|
+
|
|
90
206
|
/**
|
|
91
207
|
* A subagent outlives its "no output" result: a working/blocked agent is alive regardless of session
|
|
92
208
|
* age, otherwise recent session writes decide. `paneExists: false` is death — an unavailable probe is
|
|
@@ -116,7 +232,7 @@ export function buildAliveNotice(
|
|
|
116
232
|
return [
|
|
117
233
|
`Subagent "${description}" is still running in pane ${paneId} (agent_status=${status}${activity}).`,
|
|
118
234
|
'The foreground wait has moved it to background so this call returns now.',
|
|
119
|
-
'Do NOT redo its work
|
|
235
|
+
'Do NOT redo its work and do NOT sleep or poll: when it settles, its result is delivered automatically as a settlement notice at your next turn boundary.',
|
|
120
236
|
'Use subagent(action: "list") to check its live state; subagent(action: "send") to give it follow-up work.',
|
|
121
237
|
].join(' ');
|
|
122
238
|
}
|
|
@@ -147,6 +263,9 @@ export interface SubEntry {
|
|
|
147
263
|
/** 'task' or a role name; only a display label for GC. */
|
|
148
264
|
kind: string;
|
|
149
265
|
paneId: string;
|
|
266
|
+
/** Phase 2 task slug (kebab-case of description; RFC rfc-delegation-identity §3.1): tool args
|
|
267
|
+
* accept `slug@paneId` or the bare slug; allocated once at spawn and stable across revives. */
|
|
268
|
+
slug?: string;
|
|
150
269
|
tabId: string;
|
|
151
270
|
tabName: string;
|
|
152
271
|
cwd: string;
|
|
@@ -163,6 +282,9 @@ export interface SubEntry {
|
|
|
163
282
|
userTakeover?: boolean;
|
|
164
283
|
observationStartedAt?: number | null;
|
|
165
284
|
lastAgentStatus?: string | null;
|
|
285
|
+
/** The final report `subagent output` already returned (tail fingerprint + time): the settlement
|
|
286
|
+
* notice then points at that delivery instead of repeating the report. Durable across resume. */
|
|
287
|
+
reportDelivered?: { fingerprint: string; at: number } | null;
|
|
166
288
|
/** D98: isolate worktree metadata; `releasedAt` set once the worktree is removed. */
|
|
167
289
|
isolate?: {
|
|
168
290
|
worktreePath: string;
|
|
@@ -173,6 +295,64 @@ export interface SubEntry {
|
|
|
173
295
|
};
|
|
174
296
|
}
|
|
175
297
|
|
|
298
|
+
/* ── task slugs (RFC rfc-delegation-identity §3.1) ───────────────── */
|
|
299
|
+
|
|
300
|
+
/** Slug cap: short enough for tool args and list rows, long enough to stay distinctive. */
|
|
301
|
+
const SLUG_MAX = 24;
|
|
302
|
+
|
|
303
|
+
/** Kebab-case a task description; non-ASCII (CJK descriptions) sanitizes to '' — the allocator
|
|
304
|
+
* then falls back to a taskId-derived ascii slug, because tool args must stay typeable. */
|
|
305
|
+
export function slugifyTask(description: string): string {
|
|
306
|
+
return description
|
|
307
|
+
.toLowerCase()
|
|
308
|
+
.replace(/[''']/g, '')
|
|
309
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
310
|
+
.replace(/^-+|-+$/g, '')
|
|
311
|
+
.slice(0, SLUG_MAX)
|
|
312
|
+
.replace(/-+$/g, '');
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Allocate a unique slug among live rows: base, then base-2, base-3, …; a description that
|
|
316
|
+
* sanitizes to nothing (CJK, emoji-only) falls back to `task-<taskId prefix>`. */
|
|
317
|
+
export function allocateSubSlug(description: string, taskId: string, taken: ReadonlySet<string>): string {
|
|
318
|
+
const base = slugifyTask(description) || `task-${taskId.slice(0, 6)}`;
|
|
319
|
+
if (!taken.has(base)) return base;
|
|
320
|
+
for (let n = 2; ; n++) {
|
|
321
|
+
const next = `${base}-${n}`;
|
|
322
|
+
if (!taken.has(next)) return next;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** Stable display/argument form for a subagent row: `slug@paneId` when a slug exists. */
|
|
327
|
+
export function subRef(sub: { paneId: string; slug?: string }): string {
|
|
328
|
+
return sub.slug ? `${sub.slug}@${sub.paneId}` : sub.paneId;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Identity of a final report across the output action and the settlement notice: its tail. */
|
|
332
|
+
export function reportFingerprint(report: string): string {
|
|
333
|
+
return report.slice(-64);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** Shorter sleeps are ordinary polling of builds/logs; from a minute up, they were waits on workers. */
|
|
337
|
+
const SLEEP_NOTE_MIN_SECONDS = 60;
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Note for a master bash call that sleeps ≥ SLEEP_NOTE_MIN_SECONDS while background workers run:
|
|
341
|
+
* masters slept 660-840 s per worker although the settlement notice arrives on its own. Worded so a
|
|
342
|
+
* sleep that waits on something else (a load test, a build) is left alone.
|
|
343
|
+
*/
|
|
344
|
+
export function sleepWhileSubsRunningNote(
|
|
345
|
+
command: string,
|
|
346
|
+
running: ReadonlyArray<{ paneId: string; slug?: string }>,
|
|
347
|
+
): string | null {
|
|
348
|
+
if (running.length === 0) return null;
|
|
349
|
+
const longest = Math.max(0, ...[...command.matchAll(/\bsleep\s+(\d+)s?\b/g)].map((m) => Number(m[1])));
|
|
350
|
+
if (longest < SLEEP_NOTE_MIN_SECONDS) return null;
|
|
351
|
+
return `[pier] ${running.length} background subagent(s) running (${running.map(subRef).join(', ')}): their settlement `
|
|
352
|
+
+ 'notice arrives automatically at your next tool boundary, so do not sleep to wait for them — keep working or end '
|
|
353
|
+
+ 'your turn. Sleeping is only needed for other work (builds, logs).';
|
|
354
|
+
}
|
|
355
|
+
|
|
176
356
|
export interface SubsRegistry {
|
|
177
357
|
version: 2;
|
|
178
358
|
subs: SubEntry[];
|
|
@@ -198,6 +378,7 @@ export function foldSubsRegistry(entries: ReadonlyArray<{ type?: string; customT
|
|
|
198
378
|
taskId: typeof s.taskId === 'string' ? s.taskId : s.paneId,
|
|
199
379
|
kind: normalizeEntryKind(typeof s.kind === 'string' ? s.kind : undefined),
|
|
200
380
|
paneId: s.paneId,
|
|
381
|
+
slug: typeof s.slug === 'string' && s.slug ? s.slug : undefined,
|
|
201
382
|
tabId: typeof s.tabId === 'string' ? s.tabId : '',
|
|
202
383
|
tabName: typeof s.tabName === 'string' ? s.tabName : '',
|
|
203
384
|
cwd: typeof s.cwd === 'string' ? s.cwd : '',
|
|
@@ -225,6 +406,10 @@ export function foldSubsRegistry(entries: ReadonlyArray<{ type?: string; customT
|
|
|
225
406
|
retainNotified: s.isolate.retainNotified === true,
|
|
226
407
|
}
|
|
227
408
|
: undefined,
|
|
409
|
+
reportDelivered: s.reportDelivered && typeof s.reportDelivered === 'object'
|
|
410
|
+
&& typeof s.reportDelivered.fingerprint === 'string' && typeof s.reportDelivered.at === 'number'
|
|
411
|
+
? { fingerprint: s.reportDelivered.fingerprint, at: s.reportDelivered.at }
|
|
412
|
+
: undefined,
|
|
228
413
|
}));
|
|
229
414
|
found = { version: 2, subs };
|
|
230
415
|
}
|
|
@@ -395,6 +580,12 @@ export function buildLaunchParts(
|
|
|
395
580
|
|
|
396
581
|
const ISOLATE_SLUG_MAX = 40;
|
|
397
582
|
|
|
583
|
+
/**
|
|
584
|
+
* D-3 (verified 2026-10-20): herdr 0.9.5 `worktree create --workspace <parent>` still opens a new
|
|
585
|
+
* top-level workspace, not a tab inside the parent workspace, so it cannot replace pier's isolate
|
|
586
|
+
* placement. Keep `git worktree add` + the master-workspace tab; native worktree management remains
|
|
587
|
+
* an option only for workflows that accept a separate workspace.
|
|
588
|
+
*/
|
|
398
589
|
export function planIsolateWorktree(opts: {
|
|
399
590
|
description: string;
|
|
400
591
|
taskHex: string;
|
|
@@ -553,7 +744,7 @@ export function planForegroundTick(input: {
|
|
|
553
744
|
|
|
554
745
|
/* ── task-id resolution ─────────────────────────────────────────── */
|
|
555
746
|
|
|
556
|
-
type TaskIdResolutionResult =
|
|
747
|
+
export type TaskIdResolutionResult =
|
|
557
748
|
| { kind: 'resolved'; taskId: string }
|
|
558
749
|
| { kind: 'ambiguous'; query: string; candidates: string[] }
|
|
559
750
|
| { kind: 'too_short'; query: string }
|
|
@@ -583,13 +774,28 @@ export function resolveTaskIdPrefix(
|
|
|
583
774
|
return { kind: 'not_found', query: trimmed };
|
|
584
775
|
}
|
|
585
776
|
|
|
777
|
+
/** Error text for a non-resolved `resolveTaskIdPrefix` outcome; null for `resolved` (each caller owns
|
|
778
|
+
* its success path) and `not_found` (each caller words its own unknown-id hint). `rawQuery` keeps
|
|
779
|
+
* the caller's original string in the message even though matching works on the trimmed form. */
|
|
780
|
+
export function taskIdResolutionError(
|
|
781
|
+
res: TaskIdResolutionResult,
|
|
782
|
+
label: 'task id' | 'subagent id',
|
|
783
|
+
rawQuery: string,
|
|
784
|
+
): string | null {
|
|
785
|
+
if (res.kind === 'too_short') return `Error: ${label} prefix "${rawQuery}" is too short (minimum 4 characters).`;
|
|
786
|
+
if (res.kind === 'ambiguous') return ambiguousIdError(label, rawQuery, res.candidates);
|
|
787
|
+
return null;
|
|
788
|
+
}
|
|
789
|
+
|
|
586
790
|
/* ── outbound port (composition root → plugin) ──────────────────── */
|
|
587
791
|
|
|
588
792
|
/** Bound atomically at session_start: consumers read `current` and must tolerate null. */
|
|
589
793
|
export interface SubagentPort {
|
|
590
794
|
applyReplySession(paneId: string, sessionFile: string | null): void;
|
|
591
795
|
reconcileOnReply(paneId: string): string[];
|
|
592
|
-
listRunningSubs(): Array<{ paneId: string; description: string }>;
|
|
796
|
+
listRunningSubs(): Array<{ paneId: string; slug?: string; description: string; kind: string }>;
|
|
797
|
+
/** D113: non-closed rows for the /pier-tree view (settled nodes carry their settle time). */
|
|
798
|
+
listRows(): Array<{ paneId: string; slug?: string; description: string; kind: string; status: SubEntry['status']; settledAt: number | null }>;
|
|
593
799
|
settleStatLine(paneId: string): Promise<string | null>;
|
|
594
800
|
/** Pipe settlement: drop the row out of listRunningSubs before the finish notice wakes the master. */
|
|
595
801
|
consumeReply(paneId: string, outcome: string | null): void;
|
package/src/subagent-poller.ts
CHANGED
|
@@ -10,7 +10,7 @@ import { runtimePolicy, type RuntimePolicy } from './runtime-policy.ts';
|
|
|
10
10
|
import type { JevRuntime } from './jev-client.ts';
|
|
11
11
|
import { evaluateSettleVerdict, settleAskIsSafe, settleVerdictRequest } from './jev-core.ts';
|
|
12
12
|
import { formatSettlementNotice, type SettlementNullReason } from './vocab.ts';
|
|
13
|
-
import { buildBlockedGateNotice, sleep, type SubEntry } from './subagent-core.ts';
|
|
13
|
+
import { buildBlockedGateNotice, reportFingerprint, sleep, type SubEntry } from './subagent-core.ts';
|
|
14
14
|
import type { SessionIo } from './subagent-session.ts';
|
|
15
15
|
import type { GitIo } from './subagent-spawn.ts';
|
|
16
16
|
|
|
@@ -70,8 +70,17 @@ export function planObservationTick(input: {
|
|
|
70
70
|
agentStatus: string | null;
|
|
71
71
|
machineInjectAgoMs: number;
|
|
72
72
|
machineInjectGraceMs: number;
|
|
73
|
+
/** The transcript already holds a finalized answer (stopReason 'stop', no pending tool call). */
|
|
74
|
+
finalized?: boolean;
|
|
73
75
|
}): ObservationTickPlan {
|
|
74
|
-
if (!input.observationStartedAt)
|
|
76
|
+
if (!input.observationStartedAt) {
|
|
77
|
+
// A finalized answer on a pane that is not working settles on the detecting tick: the window
|
|
78
|
+
// would only delay the result, and the transcript cannot grow under an idle pane. A working or
|
|
79
|
+
// blocked pane keeps the window path — that is the human-takeover guard.
|
|
80
|
+
const paneBusy = input.agentStatus === 'working' || input.agentStatus === 'blocked';
|
|
81
|
+
if (input.finalized && !paneBusy) return { kind: 'settle' };
|
|
82
|
+
return { kind: 'start-observation' };
|
|
83
|
+
}
|
|
75
84
|
if (input.agentStatus === 'working') {
|
|
76
85
|
if (input.machineInjectAgoMs > input.machineInjectGraceMs) return { kind: 'user-takeover' };
|
|
77
86
|
return { kind: 'machine-inject-reset' };
|
|
@@ -118,8 +127,9 @@ export function buildSettlementNoticeText(
|
|
|
118
127
|
closing: string | null,
|
|
119
128
|
statLine: string | null,
|
|
120
129
|
nullReason: SettlementNullReason = 'silent',
|
|
130
|
+
reportDeliveredAt: number | null = null,
|
|
121
131
|
): string {
|
|
122
|
-
const base = formatSettlementNotice(agentLabel, closing, nullReason);
|
|
132
|
+
const base = formatSettlementNotice(agentLabel, closing, nullReason, reportDeliveredAt);
|
|
123
133
|
return statLine ? `${base}\n${statLine}` : base;
|
|
124
134
|
}
|
|
125
135
|
|
|
@@ -183,7 +193,7 @@ export interface PollerHost {
|
|
|
183
193
|
lastMachineInjectAt: Map<string, number>;
|
|
184
194
|
session: SessionIo;
|
|
185
195
|
git: GitIo;
|
|
186
|
-
injectNotice(content: string): Promise<void>;
|
|
196
|
+
injectNotice(content: string, paneId?: string): Promise<void>;
|
|
187
197
|
reconcileOnSettlement(description: string, outcome: 'settled' | 'failed'): string[];
|
|
188
198
|
withReconcileNotes(base: string, notes: readonly string[]): string;
|
|
189
199
|
claimSettleNotice(key: string): boolean;
|
|
@@ -222,6 +232,9 @@ export function createPoller(h: PollerHost): Poller {
|
|
|
222
232
|
): Promise<void> {
|
|
223
233
|
const startedAt = now();
|
|
224
234
|
let lastActivityAt = now();
|
|
235
|
+
/** Claimed settle key: on a rejected injection the claim stays ours, so the next tick retries
|
|
236
|
+
* the notice instead of suppressing it as someone else's. */
|
|
237
|
+
let claimedKey: string | null = null;
|
|
225
238
|
/** Stop watching this pane: persist + ledger first, or a restart replays a ghost running row. */
|
|
226
239
|
const consume = (outcome: string | null, via: string, at: number): void => {
|
|
227
240
|
const row = h.subs.get(paneId);
|
|
@@ -323,13 +336,6 @@ export function createPoller(h: PollerHost): Poller {
|
|
|
323
336
|
|
|
324
337
|
if (isSettlementCandidate(s)) {
|
|
325
338
|
const closing = s.text;
|
|
326
|
-
if (!entry.observationStartedAt) {
|
|
327
|
-
entry.observationStartedAt = now();
|
|
328
|
-
entry.lastAgentStatus = 'idle';
|
|
329
|
-
h.persistSubs();
|
|
330
|
-
await doSleep(OBSERVATION_TICK_MS);
|
|
331
|
-
continue;
|
|
332
|
-
}
|
|
333
339
|
let agentStatus: string | null = null;
|
|
334
340
|
try {
|
|
335
341
|
agentStatus = (await h.client.listAgents()).find((a) => a.paneId === paneId)?.status ?? null;
|
|
@@ -342,7 +348,15 @@ export function createPoller(h: PollerHost): Poller {
|
|
|
342
348
|
agentStatus,
|
|
343
349
|
machineInjectAgoMs: now() - (h.lastMachineInjectAt.get(paneId) ?? 0),
|
|
344
350
|
machineInjectGraceMs,
|
|
351
|
+
finalized: closing != null && !s.pendingTool,
|
|
345
352
|
});
|
|
353
|
+
if (obs.kind === 'start-observation') {
|
|
354
|
+
entry.observationStartedAt = now();
|
|
355
|
+
entry.lastAgentStatus = 'idle';
|
|
356
|
+
h.persistSubs();
|
|
357
|
+
await doSleep(OBSERVATION_TICK_MS);
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
346
360
|
if (obs.kind === 'user-takeover') {
|
|
347
361
|
entry.userTakeover = true;
|
|
348
362
|
entry.observationStartedAt = now();
|
|
@@ -375,7 +389,23 @@ export function createPoller(h: PollerHost): Poller {
|
|
|
375
389
|
if (tail && settleAskIsSafe(tail)) {
|
|
376
390
|
const res = await h.jev.ask(
|
|
377
391
|
settleVerdictRequest({ description: current.description, tail }),
|
|
378
|
-
{
|
|
392
|
+
{
|
|
393
|
+
questionId: 'settle-attribution',
|
|
394
|
+
timeoutMs: 2500,
|
|
395
|
+
// Calibration fields (D115): noul answers carry no confidence, so the
|
|
396
|
+
// probabilities and the composed verdict are what SETTLE_*_MIN_NOUL
|
|
397
|
+
// tuning reads from jev.jsonl.
|
|
398
|
+
enrich: ({ ok, answers }) => {
|
|
399
|
+
if (!ok || !answers) return {};
|
|
400
|
+
const match = answers.tail_matches_task;
|
|
401
|
+
const fin = answers.tail_has_final_answer;
|
|
402
|
+
return {
|
|
403
|
+
tailMatchesTaskNoul: match?.type === 'noul' ? match.noul : null,
|
|
404
|
+
tailHasFinalAnswerNoul: fin?.type === 'noul' ? fin.noul : null,
|
|
405
|
+
settleVerdict: evaluateSettleVerdict(answers) ?? 'unanswered',
|
|
406
|
+
};
|
|
407
|
+
},
|
|
408
|
+
},
|
|
379
409
|
);
|
|
380
410
|
if (res.ok) {
|
|
381
411
|
const verdict = evaluateSettleVerdict(res.answers);
|
|
@@ -393,19 +423,39 @@ export function createPoller(h: PollerHost): Poller {
|
|
|
393
423
|
await h.session.resolveSessionFile(paneId, cwd, undefined, { minMtimeMs: current.injectTs - 2_000 }),
|
|
394
424
|
);
|
|
395
425
|
}
|
|
396
|
-
|
|
426
|
+
// Claim before delivering: the pipe fast-path may already own this settlement's notice.
|
|
427
|
+
// The row is consumed only after the notice is accepted, and a claim we own is retried
|
|
428
|
+
// on the next tick — a rejected injection must not strand a finished worker as 'running'
|
|
429
|
+
// with no notice, nor consume the row while the result is still undelivered.
|
|
430
|
+
const claimKey = `${paneId}:${current.requestId}`;
|
|
431
|
+
if (claimedKey !== claimKey) {
|
|
432
|
+
if (!h.claimSettleNotice(claimKey)) {
|
|
433
|
+
consume(closing, 'poll-settle', now());
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
claimedKey = claimKey;
|
|
437
|
+
}
|
|
397
438
|
const notes = h.reconcileOnSettlement(current.description, 'settled');
|
|
398
439
|
const statLine = await h.git.worktreeStatLine(entry);
|
|
440
|
+
// `subagent output` may already have returned this exact report: point there, don't repeat it.
|
|
441
|
+
const delivered = closing && entry.reportDelivered?.fingerprint === reportFingerprint(closing)
|
|
442
|
+
? entry.reportDelivered.at
|
|
443
|
+
: null;
|
|
399
444
|
const notice = h.withReconcileNotes(
|
|
400
|
-
buildSettlementNoticeText(`${paneId} (${current.description})`, closing, statLine, nullReason),
|
|
445
|
+
buildSettlementNoticeText(`${paneId} (${current.description})`, closing, statLine, nullReason, delivered),
|
|
401
446
|
notes,
|
|
402
447
|
);
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
448
|
+
try {
|
|
449
|
+
// Tag the pane: a notice buffered during compaction must keep its row GC-exempt
|
|
450
|
+
// until the post-compaction flush actually delivers it.
|
|
451
|
+
await h.injectNotice(notice, paneId);
|
|
452
|
+
} catch {
|
|
453
|
+
// Notification refused (e.g. the master is compacting): leave the row running and
|
|
454
|
+
// re-detect the same closing text on the next tick.
|
|
455
|
+
await pace();
|
|
456
|
+
continue;
|
|
408
457
|
}
|
|
458
|
+
consume(closing, 'poll-settle', now());
|
|
409
459
|
return;
|
|
410
460
|
}
|
|
411
461
|
}
|
package/src/subagent-session.ts
CHANGED
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
type SessionEntryLike,
|
|
17
17
|
type SubSessionState,
|
|
18
18
|
} from './session-tail.ts';
|
|
19
|
-
import { idParam, sleep, type AliveProbe, type SubEntry } from './subagent-core.ts';
|
|
19
|
+
import { idParam, reportFingerprint, sleep, type AliveProbe, type SubEntry } from './subagent-core.ts';
|
|
20
20
|
import { toolError } from './tool-error.ts';
|
|
21
21
|
|
|
22
22
|
/* ── subagent output delta (pure) ───────────────────────────────── */
|
|
@@ -26,6 +26,61 @@ export const OUTPUT_HARD_CAP_CHARS = 16000;
|
|
|
26
26
|
const OUTPUT_MIN_OVERLAP = 16;
|
|
27
27
|
const OUTPUT_TAIL_CHARS = 500;
|
|
28
28
|
|
|
29
|
+
const BOX_CHARS = '│┃║╭╮╰╯┌┐└┘├┤┬┴┼─━═╌╍';
|
|
30
|
+
const FRAME_ONLY_LINE = new RegExp(`^[\\s${BOX_CHARS}]*[${BOX_CHARS}][\\s${BOX_CHARS}]*$`);
|
|
31
|
+
const SIDE_BORDER = /^\s*[│┃║] ?|\s*[│┃║]\s*$/g;
|
|
32
|
+
/** pi's status footer: `↑43k ↓13k R820k CH99.1% $0.017 …`, preceded by a `~/cwd (branch)` line. */
|
|
33
|
+
const PI_FOOTER_LINE = /^↑[\d.]+[kKmM]?\s+↓[\d.]+[kKmM]?\s/;
|
|
34
|
+
const PI_FOOTER_CWD_LINE = /^~?\/\S*( \([^)]*\))?$/;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Strip TUI chrome from a worker pane read: frame-only box-drawing rows, side borders, right-padding,
|
|
38
|
+
* pi's footer, and blank-line runs. A real read was ~20% whitespace, box glyphs and footer. Applied
|
|
39
|
+
* before the delta, so the cursor tracks the same cleaned text on every call.
|
|
40
|
+
*/
|
|
41
|
+
export function cleanPaneChrome(text: string): string {
|
|
42
|
+
// Frame rows are judged before border stripping, which would otherwise leave them as blank lines.
|
|
43
|
+
const lines = text.split('\n')
|
|
44
|
+
.filter((line) => !FRAME_ONLY_LINE.test(line))
|
|
45
|
+
.map((line) => line.replace(SIDE_BORDER, '').trimEnd());
|
|
46
|
+
const kept: string[] = [];
|
|
47
|
+
for (let i = 0; i < lines.length; i++) {
|
|
48
|
+
const line = lines[i]!;
|
|
49
|
+
if (PI_FOOTER_LINE.test(line)) continue;
|
|
50
|
+
if (PI_FOOTER_CWD_LINE.test(line) && PI_FOOTER_LINE.test(lines[i + 1] ?? '')) continue;
|
|
51
|
+
if (line === '' && kept[kept.length - 1] === '') continue;
|
|
52
|
+
kept.push(line);
|
|
53
|
+
}
|
|
54
|
+
return kept.join('\n');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Normalized-text length of the report opening that identifies the pane's rendered copy. */
|
|
58
|
+
const REPORT_KEY_CHARS = 24;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The pane shows the final report rendered (markdown stripped, long lines cut at pane width), so it
|
|
62
|
+
* is a lossy copy of the transcript text. When the transcript report is attached, cut the pane delta
|
|
63
|
+
* where the report's opening appears (whitespace and markdown markers ignored). Null: no copy found.
|
|
64
|
+
*/
|
|
65
|
+
export function cutPaneReportCopy(delta: string, report: string): string | null {
|
|
66
|
+
const isNoise = (ch: string): boolean => /[\s`*_#>]/.test(ch);
|
|
67
|
+
const key = [...report].filter((ch) => !isNoise(ch)).slice(0, REPORT_KEY_CHARS).join('');
|
|
68
|
+
if (key.length < REPORT_KEY_CHARS) return null;
|
|
69
|
+
let normalized = '';
|
|
70
|
+
const origin: number[] = [];
|
|
71
|
+
for (let i = 0; i < delta.length; i++) {
|
|
72
|
+
if (isNoise(delta[i]!)) continue;
|
|
73
|
+
normalized += delta[i];
|
|
74
|
+
origin.push(i);
|
|
75
|
+
}
|
|
76
|
+
const at = normalized.indexOf(key);
|
|
77
|
+
if (at < 0) return null;
|
|
78
|
+
const lineStart = delta.lastIndexOf('\n', origin[at]!) + 1;
|
|
79
|
+
const before = delta.slice(0, lineStart).trimEnd();
|
|
80
|
+
const marker = '(the pane\'s rendered copy of the final report is omitted — full text in the Subagent Report section below)';
|
|
81
|
+
return before ? `${before}\n${marker}` : marker;
|
|
82
|
+
}
|
|
83
|
+
|
|
29
84
|
export type SubagentStatus = 'running' | 'idle' | 'blocked' | 'settled';
|
|
30
85
|
|
|
31
86
|
export interface SubagentOutputCursor {
|
|
@@ -440,6 +495,8 @@ export async function executeSubagentOutput(
|
|
|
440
495
|
/** Last finalized assistant text from the worker's transcript: the pane read alone cannot
|
|
441
496
|
* recover it when the fullscreen TUI shows only the status overlay. Optional for tests. */
|
|
442
497
|
readFinalReport?: (paneId: string, entryCwd: string) => Promise<string | null>;
|
|
498
|
+
/** Persists `entry.reportDelivered` so the settlement notice can skip a report already shown. */
|
|
499
|
+
persistSubs?: () => void;
|
|
443
500
|
},
|
|
444
501
|
) {
|
|
445
502
|
const rawId = idParam(params, 'agentId', 'taskId');
|
|
@@ -472,8 +529,9 @@ export async function executeSubagentOutput(
|
|
|
472
529
|
const maxChars = typeof params?.max_chars === 'number'
|
|
473
530
|
? params.max_chars
|
|
474
531
|
: typeof params?.maxChars === 'number' ? params.maxChars : undefined;
|
|
475
|
-
const deltaResult = computeSubagentOutputDelta(prevCursor, rawRead.text, { maxChars });
|
|
476
|
-
|
|
532
|
+
const deltaResult = computeSubagentOutputDelta(prevCursor, cleanPaneChrome(rawRead.text), { maxChars });
|
|
533
|
+
// A resumed process has no cursor; the durable delivery record keeps the report from repeating.
|
|
534
|
+
deltaResult.nextCursor.reportTail ??= prevCursor?.reportTail ?? entry.reportDelivered?.fingerprint;
|
|
477
535
|
|
|
478
536
|
// Transcript fallback: the terminal delta can be empty (or footer-only) while the finished
|
|
479
537
|
// report exists only in the session file. Surface it once per distinct report.
|
|
@@ -481,11 +539,16 @@ export async function executeSubagentOutput(
|
|
|
481
539
|
if ((status === 'settled' || status === 'idle') && deps.readFinalReport) {
|
|
482
540
|
try {
|
|
483
541
|
const report = await deps.readFinalReport(entry.paneId, entry.cwd);
|
|
484
|
-
const fingerprint = report ? report
|
|
542
|
+
const fingerprint = report ? reportFingerprint(report) : '';
|
|
485
543
|
if (report && fingerprint && fingerprint !== deltaResult.nextCursor.reportTail) {
|
|
486
544
|
const bounded = boundText(report, Math.max(1, Math.min(maxChars ?? OUTPUT_DEFAULT_MAX_CHARS, OUTPUT_HARD_CAP_CHARS)));
|
|
487
545
|
reportSection = `[Subagent Report | latest finalized message from session transcript${bounded.truncated ? ' | truncated' : ''}]\n${bounded.text}`;
|
|
488
546
|
deltaResult.nextCursor.reportTail = fingerprint;
|
|
547
|
+
deltaResult.delta = cutPaneReportCopy(deltaResult.delta, report) ?? deltaResult.delta;
|
|
548
|
+
if (!bounded.truncated) {
|
|
549
|
+
entry.reportDelivered = { fingerprint, at: Date.now() };
|
|
550
|
+
deps.persistSubs?.();
|
|
551
|
+
}
|
|
489
552
|
}
|
|
490
553
|
} catch {
|
|
491
554
|
}
|