pi-pier 0.2.7 → 0.3.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.
Files changed (85) hide show
  1. package/README.md +26 -12
  2. package/package.json +8 -3
  3. package/schemas/efficiency-config.schema.json +77 -0
  4. package/schemas/role-manifest.schema.json +7 -1
  5. package/skills/pi-herdr-workflow/SKILL.md +11 -0
  6. package/src/ansi-text.ts +134 -0
  7. package/src/ask-dialog.ts +308 -0
  8. package/src/ask-user.ts +139 -88
  9. package/src/bootstrap.ts +13 -14
  10. package/src/compact-coordinator.ts +471 -0
  11. package/src/compact-economics-core.ts +328 -0
  12. package/src/config-catalog-core.ts +332 -0
  13. package/src/config-command.ts +388 -0
  14. package/src/dashboard-command.ts +108 -0
  15. package/src/efficiency-config-core.ts +464 -0
  16. package/src/efficiency-store.ts +299 -0
  17. package/src/focus-poller.ts +223 -0
  18. package/src/gc-core.ts +77 -18
  19. package/src/herdr-client.ts +284 -154
  20. package/src/history-store.ts +36 -23
  21. package/src/index-gates.ts +124 -0
  22. package/src/index-locks.ts +29 -16
  23. package/src/index-master.ts +29 -19
  24. package/src/index-notices.ts +35 -11
  25. package/src/index-pipe.ts +17 -7
  26. package/src/index-roles.ts +306 -0
  27. package/src/index-runtime.ts +2 -6
  28. package/src/index-worker.ts +7 -9
  29. package/src/index.ts +425 -388
  30. package/src/jev-client.ts +141 -0
  31. package/src/jev-core.ts +431 -0
  32. package/src/ledger.ts +13 -21
  33. package/src/lock-core.ts +98 -37
  34. package/src/manifest-compose.ts +35 -20
  35. package/src/observation-core.ts +200 -0
  36. package/src/pane-title.ts +19 -38
  37. package/src/pi-surface.ts +34 -22
  38. package/src/pier-options.ts +100 -0
  39. package/src/pipe-channel.ts +65 -52
  40. package/src/platform-paths.ts +19 -52
  41. package/src/{core → plugins}/grid-shape.ts +12 -12
  42. package/src/plugins/heat-plan.ts +158 -0
  43. package/src/plugins/observation.ts +475 -0
  44. package/src/plugins/subagent.ts +728 -0
  45. package/src/{core → plugins}/terminal.ts +237 -123
  46. package/src/{core → plugins}/todo.ts +75 -100
  47. package/src/progress-core.ts +16 -53
  48. package/src/reconcile-core.ts +24 -33
  49. package/src/reducer-core.ts +322 -0
  50. package/src/reducer-invoker.ts +419 -0
  51. package/src/renderers.ts +187 -0
  52. package/src/role-loader.ts +35 -29
  53. package/src/role-manifest.ts +23 -32
  54. package/src/role-state.ts +119 -0
  55. package/src/routing-telemetry.ts +136 -0
  56. package/src/runtime-policy.ts +25 -43
  57. package/src/session-tail.ts +102 -23
  58. package/src/settle-wake-core.ts +55 -22
  59. package/src/slim-frame.ts +29 -124
  60. package/src/stale-core.ts +17 -24
  61. package/src/storage-layout.ts +24 -30
  62. package/src/subagent-core.ts +256 -119
  63. package/src/subagent-gc.ts +131 -69
  64. package/src/subagent-poller.ts +401 -12
  65. package/src/subagent-session.ts +516 -0
  66. package/src/subagent-spawn.ts +651 -28
  67. package/src/swallow.ts +65 -0
  68. package/src/terminal-core.ts +160 -69
  69. package/src/todo-core.ts +62 -113
  70. package/src/todo-read-hook.ts +23 -39
  71. package/src/todo-reminder-core.ts +30 -48
  72. package/src/todo-window.ts +11 -12
  73. package/src/todos-service.ts +27 -35
  74. package/src/tool-error.ts +19 -0
  75. package/src/tool-gate.ts +38 -21
  76. package/src/vocab.ts +55 -25
  77. package/src/core/subagent.ts +0 -839
  78. package/src/git-adapter.ts +0 -104
  79. package/src/notice-buffer.ts +0 -23
  80. package/src/subagent-git-io.ts +0 -80
  81. package/src/subagent-launch.ts +0 -110
  82. package/src/subagent-poll-loop.ts +0 -285
  83. package/src/subagent-port.ts +0 -27
  84. package/src/subagent-scope.ts +0 -39
  85. package/src/subagent-session-io.ts +0 -147
@@ -0,0 +1,516 @@
1
+ /**
2
+ * Child-session transcript I/O + pane-output observation for subagents. Path resolution, settlement
3
+ * text and liveness probes share one candidate order (reported path/id before recent-file fallback,
4
+ * excluding the master's own session and sessions claimed by other live panes), so settlement text
5
+ * can never cross sessions.
6
+ */
7
+ import { accessSync, constants, statSync } from 'node:fs';
8
+ import type { HerdrAgentState, HerdrClientLike } from './herdr-client.ts';
9
+ import {
10
+ bareSessionId,
11
+ deriveSubSessionState,
12
+ lastAssistantText,
13
+ listSessionFiles,
14
+ readSessionFile,
15
+ resolveSessionFileValue,
16
+ type SessionEntryLike,
17
+ type SubSessionState,
18
+ } from './session-tail.ts';
19
+ import { idParam, sleep, type AliveProbe, type SubEntry } from './subagent-core.ts';
20
+ import { toolError } from './tool-error.ts';
21
+
22
+ /* ── subagent output delta (pure) ───────────────────────────────── */
23
+
24
+ export const OUTPUT_DEFAULT_MAX_CHARS = 6000;
25
+ export const OUTPUT_HARD_CAP_CHARS = 16000;
26
+ const OUTPUT_MIN_OVERLAP = 16;
27
+ const OUTPUT_TAIL_CHARS = 500;
28
+
29
+ export type SubagentStatus = 'running' | 'idle' | 'blocked' | 'settled';
30
+
31
+ export interface SubagentOutputCursor {
32
+ tail: string;
33
+ fullLength: number;
34
+ /** Fingerprint of the last transcript report delivered to this pane; deduplicates the fallback across polls. */
35
+ reportTail?: string;
36
+ }
37
+
38
+ function boundText(text: string, maxChars: number): { text: string; truncated: boolean } {
39
+ if (text.length <= maxChars) return { text, truncated: false };
40
+ return { text: text.slice(text.length - maxChars), truncated: true };
41
+ }
42
+
43
+ function extractTail(text: string, tailChars = OUTPUT_TAIL_CHARS): string {
44
+ return text.length <= tailChars ? text : text.slice(text.length - tailChars);
45
+ }
46
+
47
+ /**
48
+ * Incremental delta between the previous cursor and the current full buffer: an initial read returns
49
+ * bounded full text, an append the text after the previous tail; ambiguous repeats and scrolled-off
50
+ * boundaries degrade to full text with `restart: true`.
51
+ */
52
+ export function computeSubagentOutputDelta(
53
+ prevCursor: SubagentOutputCursor | null | undefined,
54
+ currText: string,
55
+ options?: { maxChars?: number; minOverlap?: number; tailChars?: number },
56
+ ): { delta: string; restart: boolean; truncated: boolean; nextCursor: SubagentOutputCursor } {
57
+ const maxChars = Math.max(1, Math.min(options?.maxChars ?? OUTPUT_DEFAULT_MAX_CHARS, OUTPUT_HARD_CAP_CHARS));
58
+ const minOverlap = options?.minOverlap ?? OUTPUT_MIN_OVERLAP;
59
+ const tailChars = options?.tailChars ?? OUTPUT_TAIL_CHARS;
60
+ const nextCursor = (): SubagentOutputCursor => ({
61
+ tail: extractTail(currText, tailChars),
62
+ fullLength: currText.length,
63
+ });
64
+ const carry = (text: string, restart: boolean) => {
65
+ const bounded = boundText(text, maxChars);
66
+ return { delta: bounded.text, restart, truncated: bounded.truncated, nextCursor: nextCursor() };
67
+ };
68
+
69
+ if (!prevCursor || !prevCursor.tail) return carry(currText, false);
70
+
71
+ const prevTail = prevCursor.tail;
72
+ // Same length and suffix → nothing new; keep the cursor (and its report fingerprint) as-is.
73
+ if (currText.length === prevCursor.fullLength && currText.endsWith(prevTail)) {
74
+ return { delta: '', restart: false, truncated: false, nextCursor: prevCursor };
75
+ }
76
+
77
+ const firstIdx = currText.indexOf(prevTail);
78
+ if (firstIdx !== -1) {
79
+ // Repeats of the tail make the boundary ambiguous; fall back to the whole buffer.
80
+ if (firstIdx !== currText.lastIndexOf(prevTail)) return carry(currText, true);
81
+ const rawDelta = currText.slice(firstIdx + prevTail.length);
82
+ if (rawDelta.length === 0) return { delta: '', restart: false, truncated: false, nextCursor: prevCursor };
83
+ return carry(rawDelta, false);
84
+ }
85
+
86
+ // Tail scrolled off the top: the longest suffix of prevTail matching a prefix of currText is the boundary.
87
+ const maxPossibleOverlap = Math.min(prevTail.length - 1, currText.length);
88
+ const effectiveMinOverlap = Math.min(minOverlap, prevTail.length);
89
+ for (let k = maxPossibleOverlap; k >= effectiveMinOverlap; k--) {
90
+ if (currText.startsWith(prevTail.slice(prevTail.length - k))) return carry(currText.slice(k), false);
91
+ }
92
+ return carry(currText, true);
93
+ }
94
+
95
+ type StatusInput = { localStatus: SubEntry['status']; herdrStatus?: HerdrAgentState | null; hasAskFlag?: boolean };
96
+
97
+ /** First matching rule wins; a worker with no other signal is running. */
98
+ const STATUS_RULES: ReadonlyArray<{ status: SubagentStatus; match: (i: StatusInput) => boolean }> = [
99
+ { status: 'blocked', match: (i) => i.hasAskFlag === true || i.herdrStatus === 'blocked' },
100
+ { status: 'running', match: (i) => i.herdrStatus === 'working' },
101
+ { status: 'settled', match: (i) => i.localStatus === 'settled' || i.localStatus === 'closed' || i.herdrStatus === 'done' },
102
+ { status: 'idle', match: (i) => i.herdrStatus === 'idle' },
103
+ ];
104
+
105
+ export function resolveSubagentStatus(input: StatusInput): SubagentStatus {
106
+ return STATUS_RULES.find((rule) => rule.match(input))?.status ?? 'running';
107
+ }
108
+
109
+ export interface FormatSubagentOutputOpts {
110
+ paneId: string;
111
+ status: SubagentStatus;
112
+ revision: number;
113
+ bufferTruncated: boolean;
114
+ deltaResult: { delta: string; restart: boolean; truncated: boolean };
115
+ askQuestion?: string | null;
116
+ }
117
+
118
+ export function formatSubagentOutput(opts: FormatSubagentOutputOpts): string {
119
+ const parts: string[] = [];
120
+ const metaItems: string[] = [
121
+ `pane: ${opts.paneId}`,
122
+ `status: ${opts.status}${opts.status === 'blocked' && opts.askQuestion ? ` (question: "${opts.askQuestion}")` : ''}`,
123
+ `revision: ${opts.revision}`,
124
+ ];
125
+ if (opts.deltaResult.restart) {
126
+ metaItems.push('buffer scrolled or reset — full text returned (not a process restart)');
127
+ }
128
+ if (opts.bufferTruncated || opts.deltaResult.truncated) metaItems.push('truncated: true');
129
+ parts.push(`[Subagent Output | ${metaItems.join(' | ')}]`);
130
+ parts.push(opts.deltaResult.delta.length === 0 ? '(no new output since last read)' : opts.deltaResult.delta);
131
+ return parts.join('\n');
132
+ }
133
+
134
+ /* ── session file resolution + liveness probes ──────────────────── */
135
+
136
+ export interface SessionIoHost {
137
+ client: HerdrClientLike;
138
+ getSessionId: () => string;
139
+ sessionsDir: () => string;
140
+ }
141
+
142
+ export interface ResolveSessionFileOpts {
143
+ /** Reject candidates last written before this timestamp, so a request is never attributed to a
144
+ * session file that was not written after the request was injected (spawn-race mis-attribution). */
145
+ minMtimeMs?: number;
146
+ }
147
+
148
+ export interface SessionIo {
149
+ resolveSessionFile(paneId: string, cwd: string, preferred?: string | null, opts?: ResolveSessionFileOpts): Promise<string | null>;
150
+ collectFinalText(paneId: string, cwd: string, sinceTs: number, attempts?: number, preferred?: string | null): Promise<string | null>;
151
+ readAskFlag(paneId: string): Promise<string | null>;
152
+ probeAlive(paneId: string, cwd: string): Promise<AliveProbe>;
153
+ subSessionState(paneId: string, cwd: string, sinceTs: number, preferred?: string | null): Promise<SubSessionState>;
154
+ readSettleTail(paneId: string, cwd: string, preferred?: string | null, maxChars?: number): Promise<string | null>;
155
+ /** Re-attribute a registry sessionFile with no writes since `sinceTs`: keep `preferred` when fresh,
156
+ * else accept herdr's per-pane report when fresh. An existing value is only replaced by the
157
+ * authoritative report; null means "keep the old one" (never an mtime guess). */
158
+ reattributeStaleSessionFile(paneId: string, cwd: string, sinceTs: number, preferred: string | null): Promise<string | null>;
159
+ }
160
+
161
+ interface FileStamp {
162
+ size: number;
163
+ mtimeMs: number;
164
+ }
165
+
166
+ function stampOf(file: string): FileStamp | null {
167
+ try {
168
+ const s = statSync(file);
169
+ if (!s.isFile()) return null;
170
+ accessSync(file, constants.R_OK);
171
+ return { size: s.size, mtimeMs: s.mtimeMs };
172
+ } catch {
173
+ return null;
174
+ }
175
+ }
176
+
177
+ /** Memo of values derived from a session file, invalidated by (size, mtime, sinceTs): worker
178
+ * sessions reach tens of MB, and the poll loop re-derives the same state every tick. */
179
+ class DerivedCache<T> {
180
+ private readonly entries = new Map<string, { key: string; value: T }>();
181
+ private readonly limit: number;
182
+
183
+ constructor(limit = 32) {
184
+ this.limit = limit;
185
+ }
186
+
187
+ get(file: string, stamp: FileStamp, sinceTs: number): T | undefined {
188
+ const hit = this.entries.get(file);
189
+ return hit && hit.key === `${stamp.size}:${stamp.mtimeMs}:${sinceTs}` ? hit.value : undefined;
190
+ }
191
+
192
+ set(file: string, stamp: FileStamp, sinceTs: number, value: T): void {
193
+ this.entries.delete(file);
194
+ this.entries.set(file, { key: `${stamp.size}:${stamp.mtimeMs}:${sinceTs}`, value });
195
+ for (const oldest of this.entries.keys()) {
196
+ if (this.entries.size <= this.limit) break;
197
+ this.entries.delete(oldest);
198
+ }
199
+ }
200
+ }
201
+
202
+ export function createSessionIo(h: SessionIoHost): SessionIo {
203
+ const stateCache = new DerivedCache<SubSessionState>();
204
+ const finalTextCache = new DerivedCache<string | null>();
205
+
206
+ /** herdr's report for this pane plus the sessions claimed by other panes; one `agent.list`
207
+ * serves both, so the poll hot paths pay a single RPC. */
208
+ async function reportedAndTaken(paneId: string): Promise<{ reported: string | null; taken: Set<string> }> {
209
+ const taken = new Set<string>();
210
+ let reported: string | null = null;
211
+ try {
212
+ for (const a of await h.client.listAgents()) {
213
+ if (!a.session) continue;
214
+ if (a.paneId === paneId) reported = a.session;
215
+ else taken.add(bareSessionId(a.session));
216
+ }
217
+ // agent.list is authoritative, but a fresh pane can be absent during report lag while
218
+ // stub clients expose only the per-pane report.
219
+ if (!reported) reported = await h.client.getAgentSessionPath(paneId);
220
+ } catch {
221
+ try {
222
+ reported = await h.client.getAgentSessionPath(paneId);
223
+ } catch {
224
+ /* both unavailable → no report, own/taken exclusions still hold */
225
+ }
226
+ }
227
+ return { reported, taken };
228
+ }
229
+
230
+ /** Ordered candidates: pipe self-report (authoritative), herdr's report, then the newest session files. */
231
+ async function resolveSessionFileCandidates(paneId: string, cwd: string, preferred?: string | null): Promise<string[]> {
232
+ // A sub's sessionFile must never resolve to the master's own transcript (compare via
233
+ // bareSessionId: herdr reports ids and paths interchangeably).
234
+ const own = bareSessionId(h.getSessionId());
235
+ const { reported, taken } = await reportedAndTaken(paneId);
236
+ const out: string[] = [];
237
+ const push = (file: string | null): void => {
238
+ if (!file || out.includes(file)) return;
239
+ const id = bareSessionId(file);
240
+ if (own && id === own) return;
241
+ if (taken.has(id)) return;
242
+ out.push(file);
243
+ };
244
+ push(resolveSessionFileValue(cwd, h.sessionsDir(), preferred));
245
+ push(resolveSessionFileValue(cwd, h.sessionsDir(), reported));
246
+ for (const f of listSessionFiles(cwd, h.sessionsDir(), 4)) push(f);
247
+ return out;
248
+ }
249
+
250
+ async function* readableCandidates(paneId: string, cwd: string, preferred?: string | null): AsyncGenerator<{ file: string; stamp: FileStamp }> {
251
+ for (const file of await resolveSessionFileCandidates(paneId, cwd, preferred)) {
252
+ // herdr reports .jsonl paths before the file exists; vanished paths are skipped, never surfaced.
253
+ const stamp = stampOf(file);
254
+ if (stamp) yield { file, stamp };
255
+ }
256
+ }
257
+
258
+ async function resolveSessionFile(
259
+ paneId: string,
260
+ cwd: string,
261
+ preferred?: string | null,
262
+ opts?: ResolveSessionFileOpts,
263
+ ): Promise<string | null> {
264
+ for await (const { file, stamp } of readableCandidates(paneId, cwd, preferred)) {
265
+ if (opts?.minMtimeMs != null && stamp.mtimeMs < opts.minMtimeMs) continue;
266
+ return file;
267
+ }
268
+ return null;
269
+ }
270
+
271
+ async function reattributeStaleSessionFile(
272
+ paneId: string,
273
+ cwd: string,
274
+ sinceTs: number,
275
+ preferred: string | null,
276
+ ): Promise<string | null> {
277
+ // Tolerate fs timestamp granularity against the pre-injection injectTs capture.
278
+ const minMtimeMs = sinceTs - 2_000;
279
+ const own = bareSessionId(h.getSessionId());
280
+ const { reported, taken } = await reportedAndTaken(paneId);
281
+ // Own/taken exclusions apply to `preferred` too: a registry value pointing at the master's own
282
+ // transcript is always "fresh", so the gate cannot veto it and herdr's report takes over.
283
+ const preferredId = preferred ? bareSessionId(preferred) : null;
284
+ const preferredHeld = preferredId != null && ((own != null && preferredId === own) || taken.has(preferredId));
285
+ const preferredStamp = preferred ? stampOf(preferred) : null;
286
+ if (preferred && !preferredHeld && preferredStamp && preferredStamp.mtimeMs >= minMtimeMs) return preferred;
287
+ const file = resolveSessionFileValue(cwd, h.sessionsDir(), reported);
288
+ if (!file) return null;
289
+ const id = bareSessionId(file);
290
+ if (own && id === own) return null;
291
+ if (taken.has(id)) return null;
292
+ const stamp = stampOf(file);
293
+ return stamp && stamp.mtimeMs >= minMtimeMs ? file : null;
294
+ }
295
+
296
+ async function collectFinalText(
297
+ paneId: string,
298
+ cwd: string,
299
+ sinceTs: number,
300
+ attempts = 12,
301
+ preferred?: string | null,
302
+ ): Promise<string | null> {
303
+ for (let i = 0; i < attempts; i++) {
304
+ for await (const { file, stamp } of readableCandidates(paneId, cwd, preferred)) {
305
+ const cached = finalTextCache.get(file, stamp, sinceTs);
306
+ if (cached !== undefined) {
307
+ if (cached) return cached;
308
+ continue;
309
+ }
310
+ const entries: SessionEntryLike[] | null = readSessionFile(file);
311
+ if (!entries) continue;
312
+ const text = lastAssistantText(entries, { sinceTs })?.text ?? null;
313
+ finalTextCache.set(file, stamp, sinceTs, text);
314
+ if (text) return text;
315
+ }
316
+ await sleep(500);
317
+ }
318
+ return null;
319
+ }
320
+
321
+ async function readAskFlag(paneId: string): Promise<string | null> {
322
+ try {
323
+ const a = (await h.client.listAgents()).find((x) => x.paneId === paneId);
324
+ const v = a?.tokens?.['pi-ask'];
325
+ return typeof v === 'string' && v ? v : null;
326
+ } catch {
327
+ return null;
328
+ }
329
+ }
330
+
331
+ async function probeAlive(paneId: string, cwd: string): Promise<AliveProbe> {
332
+ const probe: AliveProbe = { paneExists: false, agentStatus: null, lastActivityMs: null };
333
+ let listed = false;
334
+ try {
335
+ const pane = (await h.client.listPanes()).find((p) => p.paneId === paneId);
336
+ listed = true;
337
+ probe.paneExists = pane != null;
338
+ probe.agentStatus = pane?.agentStatus ?? null;
339
+ if (pane?.foregroundCwd) probe.foregroundCwd = pane.foregroundCwd;
340
+ } catch {
341
+ /* pane.list unavailable — agent.list is a degraded fallback, not a death signal */
342
+ }
343
+ try {
344
+ const a = (await h.client.listAgents()).find((x) => x.paneId === paneId);
345
+ if (a) {
346
+ if (!listed) probe.paneExists = true;
347
+ probe.agentStatus = a.status ?? probe.agentStatus;
348
+ if (a.foregroundCwd) probe.foregroundCwd = a.foregroundCwd;
349
+ }
350
+ } catch {
351
+ }
352
+ for await (const { stamp } of readableCandidates(paneId, cwd)) {
353
+ if (probe.lastActivityMs == null || stamp.mtimeMs > probe.lastActivityMs) probe.lastActivityMs = stamp.mtimeMs;
354
+ }
355
+ return probe;
356
+ }
357
+
358
+ async function subSessionState(
359
+ paneId: string,
360
+ cwd: string,
361
+ sinceTs: number,
362
+ preferred?: string | null,
363
+ ): Promise<SubSessionState> {
364
+ for await (const { file, stamp } of readableCandidates(paneId, cwd, preferred)) {
365
+ const cached = stateCache.get(file, stamp, sinceTs);
366
+ if (cached) return cached;
367
+ const entries = readSessionFile(file);
368
+ if (!entries?.length) continue;
369
+ const state = deriveSubSessionState(entries, sinceTs);
370
+ stateCache.set(file, stamp, sinceTs, state);
371
+ return state;
372
+ }
373
+ return { text: null, pendingTool: false, activity: false, turnEnded: false, compacting: false };
374
+ }
375
+
376
+ /** Last assistant text of the best candidate regardless of stopReason: the settle attribution
377
+ * judgment asks what the tail we READ looks like, not whether it is final. */
378
+ async function readSettleTail(
379
+ paneId: string,
380
+ cwd: string,
381
+ preferred?: string | null,
382
+ maxChars = 1200,
383
+ ): Promise<string | null> {
384
+ for await (const { file } of readableCandidates(paneId, cwd, preferred)) {
385
+ const entries = readSessionFile(file);
386
+ if (!entries?.length) continue;
387
+ for (let i = entries.length - 1; i >= 0; i--) {
388
+ const m = entries[i]?.message as { role?: string; content?: Array<{ type?: string; text?: string }> } | undefined;
389
+ if (!m || m.role !== 'assistant' || !Array.isArray(m.content)) continue;
390
+ const text = m.content
391
+ .filter((c) => c && c.type === 'text' && c.text)
392
+ .map((c) => c.text as string)
393
+ .join('\n')
394
+ .trim();
395
+ if (text) return text.slice(-maxChars);
396
+ }
397
+ return null;
398
+ }
399
+ return null;
400
+ }
401
+
402
+ return {
403
+ resolveSessionFile,
404
+ collectFinalText,
405
+ readAskFlag,
406
+ probeAlive,
407
+ subSessionState,
408
+ readSettleTail,
409
+ reattributeStaleSessionFile,
410
+ };
411
+ }
412
+
413
+ /* ── output observation action ──────────────────────────────────── */
414
+
415
+ interface SubagentOutputRead {
416
+ text: string;
417
+ revision: number;
418
+ truncated: boolean;
419
+ }
420
+
421
+ /** Newer herdr exposes agent.read; older only pane.read. Prefer the semantic endpoint. */
422
+ async function readSubagentOutput(client: HerdrClientLike, paneId: string): Promise<SubagentOutputRead> {
423
+ if (typeof client.readAgent === 'function') {
424
+ try {
425
+ return await client.readAgent(paneId, { source: 'recent', stripAnsi: true });
426
+ } catch {
427
+ }
428
+ }
429
+ return await client.readPane(paneId, { source: 'recent', stripAnsi: true });
430
+ }
431
+
432
+ export async function executeSubagentOutput(
433
+ params: Record<string, unknown> | undefined,
434
+ toolCtx: unknown,
435
+ deps: {
436
+ client: HerdrClientLike;
437
+ resolveEntry: (rawId: string, cwd: string) => { entry: SubEntry } | { error: string };
438
+ outputCursors: Map<string, SubagentOutputCursor>;
439
+ getCwd: (toolCtx: unknown) => string;
440
+ /** Last finalized assistant text from the worker's transcript: the pane read alone cannot
441
+ * recover it when the fullscreen TUI shows only the status overlay. Optional for tests. */
442
+ readFinalReport?: (paneId: string, entryCwd: string) => Promise<string | null>;
443
+ },
444
+ ) {
445
+ const rawId = idParam(params, 'agentId', 'taskId');
446
+ 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.');
447
+
448
+ const cwd = deps.getCwd(toolCtx);
449
+ const resolved = deps.resolveEntry(rawId, cwd);
450
+ if ('error' in resolved) return toolError(resolved.error);
451
+
452
+ const entry = resolved.entry;
453
+ let agentState: HerdrAgentState | null = null;
454
+ let askFlag: string | null = null;
455
+ try {
456
+ const agent = (await deps.client.listAgents()).find((candidate) => candidate.paneId === entry.paneId);
457
+ agentState = agent?.status ?? null;
458
+ askFlag = agent?.tokens?.['pi-ask'] ?? null;
459
+ } catch {
460
+ }
461
+
462
+ const status = resolveSubagentStatus({ localStatus: entry.status, herdrStatus: agentState, hasAskFlag: Boolean(askFlag) });
463
+
464
+ let rawRead: SubagentOutputRead;
465
+ try {
466
+ rawRead = await readSubagentOutput(deps.client, entry.paneId);
467
+ } catch (err) {
468
+ return toolError(`Error: failed to read output for subagent ${entry.paneId}: ${(err as Error).message}`);
469
+ }
470
+
471
+ const prevCursor = deps.outputCursors.get(entry.paneId);
472
+ const maxChars = typeof params?.max_chars === 'number'
473
+ ? params.max_chars
474
+ : typeof params?.maxChars === 'number' ? params.maxChars : undefined;
475
+ const deltaResult = computeSubagentOutputDelta(prevCursor, rawRead.text, { maxChars });
476
+ deltaResult.nextCursor.reportTail ??= prevCursor?.reportTail;
477
+
478
+ // Transcript fallback: the terminal delta can be empty (or footer-only) while the finished
479
+ // report exists only in the session file. Surface it once per distinct report.
480
+ let reportSection: string | null = null;
481
+ if ((status === 'settled' || status === 'idle') && deps.readFinalReport) {
482
+ try {
483
+ const report = await deps.readFinalReport(entry.paneId, entry.cwd);
484
+ const fingerprint = report ? report.slice(-64) : '';
485
+ if (report && fingerprint && fingerprint !== deltaResult.nextCursor.reportTail) {
486
+ const bounded = boundText(report, Math.max(1, Math.min(maxChars ?? OUTPUT_DEFAULT_MAX_CHARS, OUTPUT_HARD_CAP_CHARS)));
487
+ reportSection = `[Subagent Report | latest finalized message from session transcript${bounded.truncated ? ' | truncated' : ''}]\n${bounded.text}`;
488
+ deltaResult.nextCursor.reportTail = fingerprint;
489
+ }
490
+ } catch {
491
+ }
492
+ }
493
+ deps.outputCursors.set(entry.paneId, deltaResult.nextCursor);
494
+
495
+ const formatted = formatSubagentOutput({
496
+ paneId: entry.paneId,
497
+ status,
498
+ revision: rawRead.revision,
499
+ bufferTruncated: rawRead.truncated,
500
+ deltaResult,
501
+ askQuestion: askFlag,
502
+ });
503
+
504
+ return {
505
+ content: [{ type: 'text' as const, text: reportSection ? `${formatted}\n${reportSection}` : formatted }],
506
+ details: {
507
+ paneId: entry.paneId,
508
+ status,
509
+ revision: rawRead.revision,
510
+ truncated: rawRead.truncated || deltaResult.truncated,
511
+ restart: deltaResult.restart,
512
+ deltaLength: deltaResult.delta.length,
513
+ reportDelivered: Boolean(reportSection),
514
+ },
515
+ };
516
+ }