piher 0.0.0-stage → 0.2.0
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 +60 -2
- package/package.json +58 -4
- package/schemas/role-manifest.schema.json +62 -0
- package/skills/piher-tools/SKILL.md +212 -0
- package/src/ansi-text.ts +143 -0
- package/src/bg-job-core.ts +412 -0
- package/src/bootstrap.ts +102 -0
- package/src/config-catalog-core.ts +200 -0
- package/src/config-command.ts +324 -0
- package/src/cordis-services.ts +38 -0
- package/src/core-client.ts +715 -0
- package/src/delegation-core.ts +94 -0
- package/src/gc-core.ts +150 -0
- package/src/guards.ts +6 -0
- package/src/hc-templates.ts +134 -0
- package/src/index-locks.ts +118 -0
- package/src/index-master.ts +92 -0
- package/src/index-notices.ts +79 -0
- package/src/index-pipe.ts +71 -0
- package/src/index-roles.ts +265 -0
- package/src/index-runtime.ts +23 -0
- package/src/index.ts +460 -0
- package/src/ledger.ts +61 -0
- package/src/lock-core.ts +200 -0
- package/src/manifest-compose.ts +103 -0
- package/src/options.ts +78 -0
- package/src/pi-surface.ts +140 -0
- package/src/pipe-channel.ts +291 -0
- package/src/platform-paths.ts +37 -0
- package/src/plugins/bg-job.ts +560 -0
- package/src/plugins/subagent.ts +1244 -0
- package/src/prompt-store.ts +50 -0
- package/src/renderers.ts +195 -0
- package/src/role-catalog.ts +99 -0
- package/src/role-loader.ts +196 -0
- package/src/role-manifest.ts +163 -0
- package/src/role-state.ts +119 -0
- package/src/roles/master.json +23 -0
- package/src/roles/worker-default.json +23 -0
- package/src/runtime-policy.ts +45 -0
- package/src/session-state.ts +19 -0
- package/src/session-tail.ts +276 -0
- package/src/settle-wake-core.ts +73 -0
- package/src/settlement-card.ts +96 -0
- package/src/storage-layout.ts +92 -0
- package/src/subagent-core.ts +543 -0
- package/src/subagent-gc.ts +312 -0
- package/src/subagent-poller.ts +484 -0
- package/src/subagent-session.ts +548 -0
- package/src/subagent-spawn.ts +695 -0
- package/src/swallow.ts +59 -0
- package/src/task-row.ts +257 -0
- package/src/task-store.ts +173 -0
- package/src/tool-error.ts +19 -0
- package/src/tool-gate.ts +99 -0
- package/src/tree-view.ts +58 -0
- package/src/vocab.ts +41 -0
- package/src/worker-marker.ts +65 -0
- package/src/worktree-io.ts +177 -0
- package/src/worktree.ts +280 -0
|
@@ -0,0 +1,548 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Child-session transcript I/O + pane-output reads for subagents. Path resolution, settlement
|
|
3
|
+
* text and liveness probes share one candidate order (the worker's own `piher.session` label before
|
|
4
|
+
* recent-file fallback, excluding the master's own session and sessions claimed by other live
|
|
5
|
+
* terminals), so settlement text can never cross sessions.
|
|
6
|
+
*/
|
|
7
|
+
import { accessSync, constants, statSync } from 'node:fs';
|
|
8
|
+
import type { CoreClientLike, CoreStatus } from './core-client.ts';
|
|
9
|
+
import {
|
|
10
|
+
bareSessionId,
|
|
11
|
+
deriveSubSessionState,
|
|
12
|
+
listSessionFiles,
|
|
13
|
+
readSessionFile,
|
|
14
|
+
resolveSessionFileValue,
|
|
15
|
+
type SubSessionState,
|
|
16
|
+
} from './session-tail.ts';
|
|
17
|
+
import { idParam, reportFingerprint, type AliveProbe } from './subagent-core.ts';
|
|
18
|
+
import { isClosed, type ResolvedTaskEntry, type TaskEntry } from './task-row.ts';
|
|
19
|
+
import { toolError } from './tool-error.ts';
|
|
20
|
+
|
|
21
|
+
/* ── subagent output delta (pure) ───────────────────────────────── */
|
|
22
|
+
|
|
23
|
+
/** How much of a running worker's pane a default read shows: the latest lines, not the boot chrome. */
|
|
24
|
+
export const OUTPUT_TAIL_LINES = 40;
|
|
25
|
+
export const OUTPUT_TAIL_MAX_CHARS = 2500;
|
|
26
|
+
export const OUTPUT_DEFAULT_MAX_CHARS = 6000;
|
|
27
|
+
export const OUTPUT_HARD_CAP_CHARS = 16000;
|
|
28
|
+
const OUTPUT_MIN_OVERLAP = 16;
|
|
29
|
+
const OUTPUT_TAIL_CHARS = 500;
|
|
30
|
+
|
|
31
|
+
const BOX_CHARS = '│┃║╭╮╰╯┌┐└┘├┤┬┴┼─━═╌╍';
|
|
32
|
+
const FRAME_ONLY_LINE = new RegExp(`^[\\s${BOX_CHARS}]*[${BOX_CHARS}][\\s${BOX_CHARS}]*$`);
|
|
33
|
+
const SIDE_BORDER = /^\s*[│┃║] ?|\s*[│┃║]\s*$/g;
|
|
34
|
+
/** pi's status footer: `↑43k ↓13k R820k CH99.1% $0.017 …`, preceded by a `~/cwd (branch)` line. */
|
|
35
|
+
const PI_FOOTER_LINE = /^↑[\d.]+[kKmM]?\s+↓[\d.]+[kKmM]?\s/;
|
|
36
|
+
const PI_FOOTER_CWD_LINE = /^~?\/\S*( \([^)]*\))?$/;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Strip TUI chrome from a worker pane read: frame-only box-drawing rows, side borders, right-padding,
|
|
40
|
+
* pi's footer, and blank-line runs. A real read was ~20% whitespace, box glyphs and footer. Applied
|
|
41
|
+
* before the delta, so the cursor tracks the same cleaned text on every call.
|
|
42
|
+
*/
|
|
43
|
+
export function cleanPaneChrome(text: string): string {
|
|
44
|
+
// Frame rows are judged before border stripping, which would otherwise leave them as blank lines.
|
|
45
|
+
const lines = text.split('\n')
|
|
46
|
+
.filter((line) => !FRAME_ONLY_LINE.test(line))
|
|
47
|
+
.map((line) => line.replace(SIDE_BORDER, '').trimEnd());
|
|
48
|
+
const kept: string[] = [];
|
|
49
|
+
for (let i = 0; i < lines.length; i++) {
|
|
50
|
+
const line = lines[i]!;
|
|
51
|
+
if (PI_FOOTER_LINE.test(line)) continue;
|
|
52
|
+
if (PI_FOOTER_CWD_LINE.test(line) && PI_FOOTER_LINE.test(lines[i + 1] ?? '')) continue;
|
|
53
|
+
if (line === '' && kept[kept.length - 1] === '') continue;
|
|
54
|
+
kept.push(line);
|
|
55
|
+
}
|
|
56
|
+
return kept.join('\n');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Normalized-text length of the report opening that identifies the pane's rendered copy. */
|
|
60
|
+
const REPORT_KEY_CHARS = 24;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The pane shows the final report rendered (markdown stripped, long lines cut at pane width), so it
|
|
64
|
+
* is a lossy copy of the transcript text. When the transcript report is attached, cut the pane delta
|
|
65
|
+
* where the report's opening appears (whitespace and markdown markers ignored). Null: no copy found.
|
|
66
|
+
*/
|
|
67
|
+
export function cutPaneReportCopy(delta: string, report: string): string | null {
|
|
68
|
+
const isNoise = (ch: string): boolean => /[\s`*_#>]/.test(ch);
|
|
69
|
+
const key = [...report].filter((ch) => !isNoise(ch)).slice(0, REPORT_KEY_CHARS).join('');
|
|
70
|
+
if (key.length < REPORT_KEY_CHARS) return null;
|
|
71
|
+
let normalized = '';
|
|
72
|
+
const origin: number[] = [];
|
|
73
|
+
for (let i = 0; i < delta.length; i++) {
|
|
74
|
+
if (isNoise(delta[i]!)) continue;
|
|
75
|
+
normalized += delta[i];
|
|
76
|
+
origin.push(i);
|
|
77
|
+
}
|
|
78
|
+
const at = normalized.indexOf(key);
|
|
79
|
+
if (at < 0) return null;
|
|
80
|
+
const lineStart = delta.lastIndexOf('\n', origin[at]!) + 1;
|
|
81
|
+
const before = delta.slice(0, lineStart).trimEnd();
|
|
82
|
+
const marker = '(the pane\'s rendered copy of the final report is omitted — full text in the Subagent Report section below)';
|
|
83
|
+
return before ? `${before}\n${marker}` : marker;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export type SubagentStatus = 'running' | 'idle' | 'blocked' | 'settled';
|
|
87
|
+
|
|
88
|
+
export interface SubagentOutputCursor {
|
|
89
|
+
tail: string;
|
|
90
|
+
fullLength: number;
|
|
91
|
+
/** Fingerprint of the last transcript report delivered to this pane; deduplicates the fallback across polls. */
|
|
92
|
+
reportTail?: string;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function boundText(text: string, maxChars: number): { text: string; truncated: boolean } {
|
|
96
|
+
if (text.length <= maxChars) return { text, truncated: false };
|
|
97
|
+
return { text: text.slice(text.length - maxChars), truncated: true };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function extractTail(text: string, tailChars = OUTPUT_TAIL_CHARS): string {
|
|
101
|
+
return text.length <= tailChars ? text : text.slice(text.length - tailChars);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Incremental delta between the previous cursor and the current full buffer: an initial read returns
|
|
106
|
+
* bounded full text, an append the text after the previous tail; ambiguous repeats and scrolled-off
|
|
107
|
+
* boundaries degrade to full text with `restart: true`.
|
|
108
|
+
*/
|
|
109
|
+
export function computeSubagentOutputDelta(
|
|
110
|
+
prevCursor: SubagentOutputCursor | null | undefined,
|
|
111
|
+
currText: string,
|
|
112
|
+
options?: { maxChars?: number; minOverlap?: number; tailChars?: number },
|
|
113
|
+
): { delta: string; restart: boolean; truncated: boolean; nextCursor: SubagentOutputCursor } {
|
|
114
|
+
const maxChars = Math.max(1, Math.min(options?.maxChars ?? OUTPUT_DEFAULT_MAX_CHARS, OUTPUT_HARD_CAP_CHARS));
|
|
115
|
+
const minOverlap = options?.minOverlap ?? OUTPUT_MIN_OVERLAP;
|
|
116
|
+
const tailChars = options?.tailChars ?? OUTPUT_TAIL_CHARS;
|
|
117
|
+
const nextCursor = (): SubagentOutputCursor => ({
|
|
118
|
+
tail: extractTail(currText, tailChars),
|
|
119
|
+
fullLength: currText.length,
|
|
120
|
+
});
|
|
121
|
+
const carry = (text: string, restart: boolean) => {
|
|
122
|
+
const bounded = boundText(text, maxChars);
|
|
123
|
+
return { delta: bounded.text, restart, truncated: bounded.truncated, nextCursor: nextCursor() };
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
if (!prevCursor || !prevCursor.tail) return carry(currText, false);
|
|
127
|
+
|
|
128
|
+
const prevTail = prevCursor.tail;
|
|
129
|
+
// Same length and suffix → nothing new; keep the cursor (and its report fingerprint) as-is.
|
|
130
|
+
if (currText.length === prevCursor.fullLength && currText.endsWith(prevTail)) {
|
|
131
|
+
return { delta: '', restart: false, truncated: false, nextCursor: prevCursor };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const firstIdx = currText.indexOf(prevTail);
|
|
135
|
+
if (firstIdx !== -1) {
|
|
136
|
+
// Repeats of the tail make the boundary ambiguous; fall back to the whole buffer.
|
|
137
|
+
if (firstIdx !== currText.lastIndexOf(prevTail)) return carry(currText, true);
|
|
138
|
+
const rawDelta = currText.slice(firstIdx + prevTail.length);
|
|
139
|
+
if (rawDelta.length === 0) return { delta: '', restart: false, truncated: false, nextCursor: prevCursor };
|
|
140
|
+
return carry(rawDelta, false);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Tail scrolled off the top: the longest suffix of prevTail matching a prefix of currText is the boundary.
|
|
144
|
+
const maxPossibleOverlap = Math.min(prevTail.length - 1, currText.length);
|
|
145
|
+
const effectiveMinOverlap = Math.min(minOverlap, prevTail.length);
|
|
146
|
+
for (let k = maxPossibleOverlap; k >= effectiveMinOverlap; k--) {
|
|
147
|
+
if (currText.startsWith(prevTail.slice(prevTail.length - k))) return carry(currText.slice(k), false);
|
|
148
|
+
}
|
|
149
|
+
return carry(currText, true);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
type StatusInput = { consumed: boolean; closed: boolean; status?: CoreStatus | null };
|
|
153
|
+
|
|
154
|
+
/** First matching rule wins; a worker with no other signal is running. An `error` terminal ended
|
|
155
|
+
* its turn like `done`, so the transcript decides what came out. The local facts are durable
|
|
156
|
+
* ones (a delivered report, the master's decision), never a stored status (S12). */
|
|
157
|
+
const STATUS_RULES: ReadonlyArray<{ status: SubagentStatus; match: (i: StatusInput) => boolean }> = [
|
|
158
|
+
{ status: 'blocked', match: (i) => i.status === 'blocked' },
|
|
159
|
+
{ status: 'running', match: (i) => i.status === 'working' },
|
|
160
|
+
{
|
|
161
|
+
status: 'settled',
|
|
162
|
+
match: (i) => i.closed || i.consumed || i.status === 'done' || i.status === 'error',
|
|
163
|
+
},
|
|
164
|
+
{ status: 'idle', match: (i) => i.status === 'idle' },
|
|
165
|
+
];
|
|
166
|
+
|
|
167
|
+
export function resolveSubagentStatus(input: StatusInput): SubagentStatus {
|
|
168
|
+
return STATUS_RULES.find((rule) => rule.match(input))?.status ?? 'running';
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export interface FormatSubagentOutputOpts {
|
|
172
|
+
paneId: string;
|
|
173
|
+
status: SubagentStatus;
|
|
174
|
+
revision: number;
|
|
175
|
+
bufferTruncated: boolean;
|
|
176
|
+
deltaResult: { delta: string; restart: boolean; truncated: boolean };
|
|
177
|
+
askQuestion?: string | null;
|
|
178
|
+
/** Set for a tail read: the header then says the pane was not dumped in full. */
|
|
179
|
+
tailNote?: string | null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function formatSubagentOutput(opts: FormatSubagentOutputOpts): string {
|
|
183
|
+
const parts: string[] = [];
|
|
184
|
+
const metaItems: string[] = [
|
|
185
|
+
`pane: ${opts.paneId}`,
|
|
186
|
+
`status: ${opts.status}${opts.status === 'blocked' && opts.askQuestion ? ` (question: "${opts.askQuestion}")` : ''}`,
|
|
187
|
+
`revision: ${opts.revision}`,
|
|
188
|
+
];
|
|
189
|
+
if (opts.deltaResult.restart) {
|
|
190
|
+
metaItems.push('buffer scrolled or reset — full text returned (not a process restart)');
|
|
191
|
+
}
|
|
192
|
+
if (opts.bufferTruncated || opts.deltaResult.truncated) metaItems.push('truncated: true');
|
|
193
|
+
if (opts.tailNote) metaItems.push(opts.tailNote);
|
|
194
|
+
parts.push(`[Subagent Output | ${metaItems.join(' | ')}]`);
|
|
195
|
+
parts.push(opts.deltaResult.delta.length === 0 ? '(no new output since last read)' : opts.deltaResult.delta);
|
|
196
|
+
return parts.join('\n');
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** The last lines of a pane read: what a running worker is doing now, without its boot chrome. */
|
|
200
|
+
export function tailOfPaneRead(text: string, lines = OUTPUT_TAIL_LINES, chars = OUTPUT_TAIL_MAX_CHARS): string {
|
|
201
|
+
const all = text.split('\n');
|
|
202
|
+
const joined = (all.length > lines ? all.slice(-lines) : all).join('\n');
|
|
203
|
+
return joined.length > chars ? joined.slice(joined.length - chars) : joined;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/* ── session file resolution + liveness probes ──────────────────── */
|
|
207
|
+
|
|
208
|
+
export interface SessionIoHost {
|
|
209
|
+
client: CoreClientLike;
|
|
210
|
+
getSessionId: () => string;
|
|
211
|
+
sessionsDir: () => string;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export interface ResolveSessionFileOpts {
|
|
215
|
+
/** Reject candidates last written before this timestamp, so a request is never attributed to a
|
|
216
|
+
* session file that was not written after the request was injected (spawn-race mis-attribution). */
|
|
217
|
+
minMtimeMs?: number;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export interface SessionIo {
|
|
221
|
+
resolveSessionFile(paneId: string, cwd: string, preferred?: string | null, opts?: ResolveSessionFileOpts): Promise<string | null>;
|
|
222
|
+
readAskFlag(paneId: string): Promise<string | null>;
|
|
223
|
+
probeAlive(paneId: string, cwd: string): Promise<AliveProbe>;
|
|
224
|
+
subSessionState(paneId: string, cwd: string, sinceTs: number, preferred?: string | null): Promise<SubSessionState>;
|
|
225
|
+
/** Re-attribute a registry sessionFile with no writes since `sinceTs`: keep `preferred` when fresh,
|
|
226
|
+
* else accept herdr's per-pane report when fresh. An existing value is only replaced by the
|
|
227
|
+
* authoritative report; null means "keep the old one" (never an mtime guess). */
|
|
228
|
+
reattributeStaleSessionFile(paneId: string, cwd: string, sinceTs: number, preferred: string | null): Promise<string | null>;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
interface FileStamp {
|
|
232
|
+
size: number;
|
|
233
|
+
mtimeMs: number;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function stampOf(file: string): FileStamp | null {
|
|
237
|
+
try {
|
|
238
|
+
const s = statSync(file);
|
|
239
|
+
if (!s.isFile()) return null;
|
|
240
|
+
accessSync(file, constants.R_OK);
|
|
241
|
+
return { size: s.size, mtimeMs: s.mtimeMs };
|
|
242
|
+
} catch {
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Memo of values derived from a session file, invalidated by (size, mtime, sinceTs): worker
|
|
248
|
+
* sessions reach tens of MB, and the poll loop re-derives the same state every tick. */
|
|
249
|
+
class DerivedCache<T> {
|
|
250
|
+
private readonly entries = new Map<string, { key: string; value: T }>();
|
|
251
|
+
private readonly limit: number;
|
|
252
|
+
|
|
253
|
+
constructor(limit = 32) {
|
|
254
|
+
this.limit = limit;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
get(file: string, stamp: FileStamp, sinceTs: number): T | undefined {
|
|
258
|
+
const hit = this.entries.get(file);
|
|
259
|
+
return hit && hit.key === `${stamp.size}:${stamp.mtimeMs}:${sinceTs}` ? hit.value : undefined;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
set(file: string, stamp: FileStamp, sinceTs: number, value: T): void {
|
|
263
|
+
this.entries.delete(file);
|
|
264
|
+
this.entries.set(file, { key: `${stamp.size}:${stamp.mtimeMs}:${sinceTs}`, value });
|
|
265
|
+
for (const oldest of this.entries.keys()) {
|
|
266
|
+
if (this.entries.size <= this.limit) break;
|
|
267
|
+
this.entries.delete(oldest);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export function createSessionIo(h: SessionIoHost): SessionIo {
|
|
273
|
+
const stateCache = new DerivedCache<SubSessionState>();
|
|
274
|
+
|
|
275
|
+
/** This terminal's `piher.session` label plus the sessions claimed by other terminals; one
|
|
276
|
+
* `terminal.list` serves both, so the poll hot paths pay a single RPC. */
|
|
277
|
+
async function reportedAndTaken(paneId: string): Promise<{ reported: string | null; taken: Set<string> }> {
|
|
278
|
+
const taken = new Set<string>();
|
|
279
|
+
let reported: string | null = null;
|
|
280
|
+
try {
|
|
281
|
+
for (const terminal of await h.client.listTerminals({ all: true })) {
|
|
282
|
+
const sessionFile = terminal.labels['piher.session'];
|
|
283
|
+
if (!sessionFile) continue;
|
|
284
|
+
if (terminal.id === paneId) reported = sessionFile;
|
|
285
|
+
else taken.add(bareSessionId(sessionFile));
|
|
286
|
+
}
|
|
287
|
+
// list may not include a terminal created moments ago: ask for it directly before giving up.
|
|
288
|
+
if (!reported) {
|
|
289
|
+
const own = await h.client.getTerminal(paneId);
|
|
290
|
+
reported = own?.labels['piher.session'] ?? null;
|
|
291
|
+
}
|
|
292
|
+
} catch {
|
|
293
|
+
try {
|
|
294
|
+
reported = (await h.client.getTerminal(paneId))?.labels['piher.session'] ?? null;
|
|
295
|
+
} catch {
|
|
296
|
+
/* both unavailable → no report, own/taken exclusions still hold */
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return { reported, taken };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Ordered candidates: pipe self-report (authoritative), the worker's own label, then the newest session files. */
|
|
303
|
+
async function resolveSessionFileCandidates(paneId: string, cwd: string, preferred?: string | null): Promise<string[]> {
|
|
304
|
+
// A sub's sessionFile must never resolve to the master's own transcript (compare via
|
|
305
|
+
// bareSessionId: herdr reports ids and paths interchangeably).
|
|
306
|
+
const own = bareSessionId(h.getSessionId());
|
|
307
|
+
const { reported, taken } = await reportedAndTaken(paneId);
|
|
308
|
+
const out: string[] = [];
|
|
309
|
+
const push = (file: string | null): void => {
|
|
310
|
+
if (!file || out.includes(file)) return;
|
|
311
|
+
const id = bareSessionId(file);
|
|
312
|
+
if (own && id === own) return;
|
|
313
|
+
if (taken.has(id)) return;
|
|
314
|
+
out.push(file);
|
|
315
|
+
};
|
|
316
|
+
push(resolveSessionFileValue(cwd, h.sessionsDir(), preferred));
|
|
317
|
+
push(resolveSessionFileValue(cwd, h.sessionsDir(), reported));
|
|
318
|
+
for (const f of listSessionFiles(cwd, h.sessionsDir(), 4)) push(f);
|
|
319
|
+
return out;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async function* readableCandidates(paneId: string, cwd: string, preferred?: string | null): AsyncGenerator<{ file: string; stamp: FileStamp }> {
|
|
323
|
+
for (const file of await resolveSessionFileCandidates(paneId, cwd, preferred)) {
|
|
324
|
+
// herdr reports .jsonl paths before the file exists; vanished paths are skipped, never surfaced.
|
|
325
|
+
const stamp = stampOf(file);
|
|
326
|
+
if (stamp) yield { file, stamp };
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
async function resolveSessionFile(
|
|
331
|
+
paneId: string,
|
|
332
|
+
cwd: string,
|
|
333
|
+
preferred?: string | null,
|
|
334
|
+
opts?: ResolveSessionFileOpts,
|
|
335
|
+
): Promise<string | null> {
|
|
336
|
+
for await (const { file, stamp } of readableCandidates(paneId, cwd, preferred)) {
|
|
337
|
+
if (opts?.minMtimeMs != null && stamp.mtimeMs < opts.minMtimeMs) continue;
|
|
338
|
+
return file;
|
|
339
|
+
}
|
|
340
|
+
return null;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
async function reattributeStaleSessionFile(
|
|
344
|
+
paneId: string,
|
|
345
|
+
cwd: string,
|
|
346
|
+
sinceTs: number,
|
|
347
|
+
preferred: string | null,
|
|
348
|
+
): Promise<string | null> {
|
|
349
|
+
// Tolerate fs timestamp granularity against the pre-injection injectTs capture.
|
|
350
|
+
const minMtimeMs = sinceTs - 2_000;
|
|
351
|
+
const own = bareSessionId(h.getSessionId());
|
|
352
|
+
const { reported, taken } = await reportedAndTaken(paneId);
|
|
353
|
+
// Own/taken exclusions apply to `preferred` too: a registry value pointing at the master's own
|
|
354
|
+
// transcript is always "fresh", so the gate cannot veto it and herdr's report takes over.
|
|
355
|
+
const preferredId = preferred ? bareSessionId(preferred) : null;
|
|
356
|
+
const preferredHeld = preferredId != null && ((own != null && preferredId === own) || taken.has(preferredId));
|
|
357
|
+
const preferredStamp = preferred ? stampOf(preferred) : null;
|
|
358
|
+
if (preferred && !preferredHeld && preferredStamp && preferredStamp.mtimeMs >= minMtimeMs) return preferred;
|
|
359
|
+
const file = resolveSessionFileValue(cwd, h.sessionsDir(), reported);
|
|
360
|
+
if (!file) return null;
|
|
361
|
+
const id = bareSessionId(file);
|
|
362
|
+
if (own && id === own) return null;
|
|
363
|
+
if (taken.has(id)) return null;
|
|
364
|
+
const stamp = stampOf(file);
|
|
365
|
+
return stamp && stamp.mtimeMs >= minMtimeMs ? file : null;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** The blocked question text: pi's OSC 7501 reporter carries it as the status message. */
|
|
369
|
+
async function readAskFlag(paneId: string): Promise<string | null> {
|
|
370
|
+
return readBlockedQuestion(h.client, paneId);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
async function probeAlive(paneId: string, cwd: string): Promise<AliveProbe> {
|
|
374
|
+
const probe: AliveProbe = { paneExists: false, agentStatus: null, lastActivityMs: null };
|
|
375
|
+
try {
|
|
376
|
+
const terminal = await h.client.getTerminal(paneId);
|
|
377
|
+
probe.paneExists = terminal != null && terminal.state !== 'finished';
|
|
378
|
+
probe.agentStatus = terminal?.status ?? null;
|
|
379
|
+
} catch {
|
|
380
|
+
/* core unavailable: liveness stays unknown (false), never a guessed death */
|
|
381
|
+
}
|
|
382
|
+
for await (const { stamp } of readableCandidates(paneId, cwd)) {
|
|
383
|
+
if (probe.lastActivityMs == null || stamp.mtimeMs > probe.lastActivityMs) probe.lastActivityMs = stamp.mtimeMs;
|
|
384
|
+
}
|
|
385
|
+
return probe;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
async function subSessionState(
|
|
389
|
+
paneId: string,
|
|
390
|
+
cwd: string,
|
|
391
|
+
sinceTs: number,
|
|
392
|
+
preferred?: string | null,
|
|
393
|
+
): Promise<SubSessionState> {
|
|
394
|
+
for await (const { file, stamp } of readableCandidates(paneId, cwd, preferred)) {
|
|
395
|
+
const cached = stateCache.get(file, stamp, sinceTs);
|
|
396
|
+
if (cached) return cached;
|
|
397
|
+
const entries = readSessionFile(file);
|
|
398
|
+
if (!entries?.length) continue;
|
|
399
|
+
const state = deriveSubSessionState(entries, sinceTs);
|
|
400
|
+
stateCache.set(file, stamp, sinceTs, state);
|
|
401
|
+
return state;
|
|
402
|
+
}
|
|
403
|
+
return { text: null, pendingTool: false, activity: false, turnEnded: false, why: null };
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
return {
|
|
407
|
+
resolveSessionFile,
|
|
408
|
+
readAskFlag,
|
|
409
|
+
probeAlive,
|
|
410
|
+
subSessionState,
|
|
411
|
+
reattributeStaleSessionFile,
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/* ── output action ──────────────────────────────────────────────── */
|
|
416
|
+
|
|
417
|
+
interface SubagentOutputRead {
|
|
418
|
+
text: string;
|
|
419
|
+
/** False when part of what was asked for may be missing (a full-screen app read while working). */
|
|
420
|
+
complete: boolean;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/** Blocked question text: pi's OSC 7501 reporter carries it as the status message. */
|
|
424
|
+
async function readBlockedQuestion(client: CoreClientLike, paneId: string): Promise<string | null> {
|
|
425
|
+
try {
|
|
426
|
+
const terminal = await client.getTerminal(paneId);
|
|
427
|
+
if (!terminal || terminal.status !== 'blocked') return null;
|
|
428
|
+
const msg = terminal.statusMsg?.trim();
|
|
429
|
+
return msg ? msg : null;
|
|
430
|
+
} catch {
|
|
431
|
+
return null;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
export async function executeSubagentOutput(
|
|
436
|
+
params: Record<string, unknown> | undefined,
|
|
437
|
+
toolCtx: unknown,
|
|
438
|
+
deps: {
|
|
439
|
+
client: CoreClientLike;
|
|
440
|
+
resolveEntry: (rawId: string, cwd?: string) => ResolvedTaskEntry;
|
|
441
|
+
outputCursors: Map<string, SubagentOutputCursor>;
|
|
442
|
+
/** Monotonic per-pane read counter shown in the header. */
|
|
443
|
+
nextRevision: (paneId: string) => number;
|
|
444
|
+
/** Last finalized assistant text from the worker's transcript: the pane read alone cannot
|
|
445
|
+
* recover it when the fullscreen TUI shows only the status overlay. Optional for tests. */
|
|
446
|
+
readFinalReport?: (paneId: string, entryCwd: string) => Promise<string | null>;
|
|
447
|
+
/** Persists the generation's `reportDelivered` so the settlement notice can skip a report already shown. */
|
|
448
|
+
writeRecord?: (entry: TaskEntry) => void;
|
|
449
|
+
},
|
|
450
|
+
) {
|
|
451
|
+
const rawId = idParam(params, 'agentId', 'taskId');
|
|
452
|
+
if (!rawId) return toolError('Error: missing agentId for output. Pass it as a top-level argument {"action":"output","agentId":"<pane id>"}, not nested under parameters.');
|
|
453
|
+
|
|
454
|
+
const cwd = (toolCtx as { cwd?: string } | undefined)?.cwd;
|
|
455
|
+
const resolved = deps.resolveEntry(rawId, cwd);
|
|
456
|
+
if ('error' in resolved) return toolError(resolved.error);
|
|
457
|
+
|
|
458
|
+
const entry = resolved.entry;
|
|
459
|
+
const foreign = resolved.foreign;
|
|
460
|
+
let terminalStatus: CoreStatus | null = null;
|
|
461
|
+
try {
|
|
462
|
+
terminalStatus = (await deps.client.getTerminal(entry.gen.paneId))?.status ?? null;
|
|
463
|
+
} catch {
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const status = resolveSubagentStatus({
|
|
467
|
+
consumed: entry.gen.consumedAt !== null,
|
|
468
|
+
closed: isClosed(entry),
|
|
469
|
+
status: terminalStatus,
|
|
470
|
+
});
|
|
471
|
+
const askQuestion = terminalStatus === 'blocked' ? await readBlockedQuestion(deps.client, entry.gen.paneId) : null;
|
|
472
|
+
|
|
473
|
+
let rawRead: SubagentOutputRead;
|
|
474
|
+
try {
|
|
475
|
+
const read = await deps.client.read(entry.gen.paneId);
|
|
476
|
+
rawRead = { text: read.text, complete: read.complete };
|
|
477
|
+
} catch (err) {
|
|
478
|
+
return toolError(`Error: failed to read output for subagent ${entry.gen.paneId}: ${(err as Error).message}`);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const prevCursor = deps.outputCursors.get(entry.gen.paneId);
|
|
482
|
+
const maxChars = typeof params?.max_chars === 'number'
|
|
483
|
+
? params.max_chars
|
|
484
|
+
: typeof params?.maxChars === 'number' ? params.maxChars : undefined;
|
|
485
|
+
const deltaResult = computeSubagentOutputDelta(prevCursor, cleanPaneChrome(rawRead.text), { maxChars });
|
|
486
|
+
// A resumed process has no cursor; the durable delivery record keeps the report from repeating.
|
|
487
|
+
deltaResult.nextCursor.reportTail ??= prevCursor?.reportTail ?? entry.gen.reportDelivered?.fingerprint;
|
|
488
|
+
|
|
489
|
+
// Transcript fallback: the terminal delta can be empty (or footer-only) while the finished
|
|
490
|
+
// report exists only in the session file. Surface it once per distinct report.
|
|
491
|
+
let reportSection: string | null = null;
|
|
492
|
+
if ((status === 'settled' || status === 'idle') && deps.readFinalReport) {
|
|
493
|
+
try {
|
|
494
|
+
const report = await deps.readFinalReport(entry.gen.paneId, entry.cwd);
|
|
495
|
+
const fingerprint = report ? reportFingerprint(report) : '';
|
|
496
|
+
if (report && fingerprint && fingerprint !== deltaResult.nextCursor.reportTail) {
|
|
497
|
+
const bounded = boundText(report, Math.max(1, Math.min(maxChars ?? OUTPUT_DEFAULT_MAX_CHARS, OUTPUT_HARD_CAP_CHARS)));
|
|
498
|
+
reportSection = `[Subagent Report | latest finalized message from session transcript${bounded.truncated ? ' | truncated' : ''}]\n${bounded.text}`;
|
|
499
|
+
deltaResult.nextCursor.reportTail = fingerprint;
|
|
500
|
+
deltaResult.delta = cutPaneReportCopy(deltaResult.delta, report) ?? deltaResult.delta;
|
|
501
|
+
// Another session's delivery marker is its own to persist; reading here stays read-only.
|
|
502
|
+
if (!bounded.truncated && !foreign) {
|
|
503
|
+
entry.gen.reportDelivered = { fingerprint, at: Date.now() };
|
|
504
|
+
deps.writeRecord?.(entry);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
} catch {
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
const revision = deps.nextRevision(entry.gen.paneId);
|
|
511
|
+
|
|
512
|
+
// A finished worker answers with its report; a running one with the last lines it printed. The whole
|
|
513
|
+
// pane buffer is boot chrome (banner, skills, extensions) around the same text, so dumping it by
|
|
514
|
+
// default is what made a master carry a worker's startup into its own context.
|
|
515
|
+
const wantFull = params?.full === true;
|
|
516
|
+
// A tail is a fresh view each time, so it must not spend the buffer before someone asks for it in
|
|
517
|
+
// full. A full read and a delivered report still advance the cursor: that is what makes an
|
|
518
|
+
// unchanged report stay delivered exactly once.
|
|
519
|
+
if (wantFull || reportSection) deps.outputCursors.set(entry.gen.paneId, deltaResult.nextCursor);
|
|
520
|
+
const shown = wantFull || reportSection
|
|
521
|
+
? deltaResult
|
|
522
|
+
: { ...deltaResult, restart: false, delta: tailOfPaneRead(deltaResult.delta) };
|
|
523
|
+
|
|
524
|
+
const formatted = formatSubagentOutput({
|
|
525
|
+
paneId: entry.gen.paneId,
|
|
526
|
+
status,
|
|
527
|
+
revision,
|
|
528
|
+
bufferTruncated: !rawRead.complete,
|
|
529
|
+
deltaResult: shown,
|
|
530
|
+
askQuestion,
|
|
531
|
+
tailNote: wantFull || reportSection
|
|
532
|
+
? null
|
|
533
|
+
: `latest output only — pass full: true for the whole pane (${deltaResult.delta.length} chars held back)`,
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
return {
|
|
537
|
+
content: [{ type: 'text' as const, text: reportSection ? `${formatted}\n${reportSection}` : formatted }],
|
|
538
|
+
details: {
|
|
539
|
+
paneId: entry.gen.paneId,
|
|
540
|
+
status,
|
|
541
|
+
revision,
|
|
542
|
+
truncated: !rawRead.complete || deltaResult.truncated,
|
|
543
|
+
restart: deltaResult.restart,
|
|
544
|
+
deltaLength: deltaResult.delta.length,
|
|
545
|
+
reportDelivered: Boolean(reportSection),
|
|
546
|
+
},
|
|
547
|
+
};
|
|
548
|
+
}
|