pi-pier 0.2.6 → 0.3.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 +20 -7
- package/package.json +7 -2
- package/schemas/efficiency-config.schema.json +77 -0
- package/skills/pi-herdr-workflow/SKILL.md +13 -1
- package/src/ansi-text.ts +83 -0
- package/src/ask-multi.ts +260 -0
- package/src/ask-user.ts +458 -0
- package/src/bootstrap.ts +5 -2
- package/src/compact-coordinator.ts +454 -0
- package/src/compact-economics-core.ts +396 -0
- package/src/config-catalog-core.ts +452 -0
- package/src/config-command.ts +181 -0
- package/src/config-guide.ts +361 -0
- package/src/core/grid-shape.ts +4 -2
- package/src/core/heat-plan.ts +160 -0
- package/src/core/observation.ts +555 -0
- package/src/core/subagent.ts +187 -393
- package/src/core/terminal.ts +270 -47
- package/src/core/todo.ts +49 -14
- package/src/dashboard-command.ts +89 -0
- package/src/efficiency-config-core.ts +661 -0
- package/src/efficiency-config-io.ts +53 -0
- package/src/efficiency-store.ts +312 -0
- package/src/focus-poller.ts +244 -0
- package/src/gc-core.ts +85 -0
- package/src/git-adapter.ts +12 -3
- package/src/herdr-client.ts +292 -36
- package/src/index-locks.ts +27 -9
- package/src/index-master.ts +5 -0
- package/src/index-notices.ts +13 -2
- package/src/index-pipe.ts +5 -0
- package/src/index-worker.ts +5 -0
- package/src/index.ts +360 -60
- package/src/jev-client.ts +146 -0
- package/src/jev-core.ts +381 -0
- package/src/lock-core.ts +105 -16
- package/src/observation-core.ts +248 -0
- package/src/pier-options.ts +96 -0
- package/src/pipe-channel.ts +52 -7
- package/src/reducer-core.ts +358 -0
- package/src/reducer-invoker.ts +392 -0
- package/src/renderers.ts +198 -0
- package/src/role-loader.ts +2 -1
- package/src/role-manifest.ts +0 -4
- package/src/runtime-policy.ts +5 -8
- package/src/session-tail.ts +60 -5
- package/src/settle-wake-core.ts +3 -1
- package/src/slim-frame.ts +60 -3
- package/src/storage-layout.ts +23 -0
- package/src/subagent-core.ts +3 -1
- package/src/subagent-execute.ts +328 -0
- package/src/subagent-gc.ts +33 -13
- package/src/subagent-launch.ts +4 -0
- package/src/subagent-list-action.ts +69 -0
- package/src/subagent-output-action.ts +104 -0
- package/src/subagent-output-adapter.ts +27 -0
- package/src/subagent-output-core.ts +263 -0
- package/src/subagent-poll-loop.ts +59 -39
- package/src/subagent-poller.ts +46 -0
- package/src/subagent-ready.ts +89 -0
- package/src/subagent-registry.ts +70 -0
- package/src/subagent-resolution.ts +41 -0
- package/src/subagent-scope.ts +3 -1
- package/src/subagent-session-io.ts +115 -32
- package/src/subagent-spawn.ts +153 -8
- package/src/swallow.ts +75 -0
- package/src/terminal-core.ts +161 -10
- package/src/todo-core.ts +5 -4
- package/src/todo-reminder-core.ts +8 -1
- package/src/todos-service.ts +18 -6
- package/src/tool-error.ts +26 -0
- package/src/vocab.ts +7 -4
package/src/subagent-gc.ts
CHANGED
|
@@ -10,6 +10,9 @@ import type { HerdrClientLike } from './herdr-client.ts';
|
|
|
10
10
|
import { shouldClosePane, shouldCloseTaskTab } from './gc-core.ts';
|
|
11
11
|
import { runtimePolicy } from './runtime-policy.ts';
|
|
12
12
|
import { evaluateRelease, parseWorktreePorcelain, type SubEntry } from './subagent-core.ts';
|
|
13
|
+
import { planIsolateSweep, isPathInside } from './gc-core.ts';
|
|
14
|
+
|
|
15
|
+
interface Cand { branch: string; wtPath: string; entry: SubEntry | null }
|
|
13
16
|
import type { GitIo } from './subagent-git-io.ts';
|
|
14
17
|
import type { TerminalStateSlot } from './core/terminal.ts';
|
|
15
18
|
|
|
@@ -140,21 +143,35 @@ export function createGcController(h: GcHost): GcController {
|
|
|
140
143
|
const wtPorcelain = await h.git.runGit(masterCwd, ['worktree', 'list', '--porcelain']);
|
|
141
144
|
if (wtPorcelain === null) return;
|
|
142
145
|
const wtByBranch = parseWorktreePorcelain(wtPorcelain);
|
|
143
|
-
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
146
|
+
// Session-owned isolates: settled or consumed entries whose worktree was not released yet.
|
|
147
|
+
const sessionOwned = [...h.subs.values()]
|
|
148
|
+
.filter((e) => e.isolate && e.isolate.releasedAt == null && e.status !== 'running')
|
|
149
|
+
.map((e) => ({ branch: e.isolate!.branch, worktreePath: e.isolate!.worktreePath }));
|
|
150
|
+
// Untracked `pier/*` branches are only swept on explicit request: they may belong to
|
|
151
|
+
// another session (whose process may even be running inside them).
|
|
152
|
+
const sweepOrphans = process.env.PIER_ISOLATE_SWEEP_ORPHANS === '1';
|
|
153
|
+
const branches = sweepOrphans
|
|
154
|
+
? (await h.git.runGit(masterCwd, ['for-each-ref', '--format=%(refname:short)', 'refs/heads/pier/']))
|
|
155
|
+
?.split('\n').map((l) => l.trim()).filter(Boolean) ?? []
|
|
156
|
+
: [];
|
|
157
|
+
const plan = planIsolateSweep({
|
|
158
|
+
branches,
|
|
159
|
+
worktreesByBranch: wtByBranch,
|
|
160
|
+
registeredBranches,
|
|
161
|
+
pendingBranches: h.pendingIsolateBranches,
|
|
162
|
+
sessionOwned,
|
|
163
|
+
cwd: masterCwd,
|
|
164
|
+
sweepOrphans,
|
|
165
|
+
});
|
|
166
|
+
const entryByBranch = new Map<string, SubEntry>();
|
|
152
167
|
for (const e of h.subs.values()) {
|
|
153
|
-
if (
|
|
154
|
-
|
|
155
|
-
|
|
168
|
+
if (e.isolate && e.isolate.releasedAt == null && e.status !== 'running') entryByBranch.set(e.isolate.branch, e);
|
|
169
|
+
}
|
|
170
|
+
const byBranch = new Map<string, Cand>();
|
|
171
|
+
for (const cand of plan.candidates) {
|
|
172
|
+
byBranch.set(cand.branch, { branch: cand.branch, wtPath: cand.worktreePath, entry: entryByBranch.get(cand.branch) ?? null });
|
|
156
173
|
}
|
|
157
|
-
trace?.(`cands=${[...byBranch.keys()].join(',') || 'none'} subs=${[...h.subs.values()].map((s) => `${s.status}${s.isolate ? '/iso' : ''}`).join(',') || 'none'}`);
|
|
174
|
+
trace?.(`cands=${[...byBranch.keys()].join(',') || 'none'} skipped=${plan.skipped.map((s) => `${s.branch}:${s.reason}`).join(',') || 'none'} subs=${[...h.subs.values()].map((s) => `${s.status}${s.isolate ? '/iso' : ''}`).join(',') || 'none'}`);
|
|
158
175
|
let persisted = false;
|
|
159
176
|
for (const cand of byBranch.values()) {
|
|
160
177
|
const { branch, wtPath, entry } = cand;
|
|
@@ -179,6 +196,9 @@ export function createGcController(h: GcHost): GcController {
|
|
|
179
196
|
const dirtyCount = dirtyOut === null ? null : dirtyOut.split('\n').filter((l) => l.trim() !== '').length;
|
|
180
197
|
const decision = evaluateRelease({ merged: mergedFinal, dirtyCount });
|
|
181
198
|
if (decision.action === 'release') {
|
|
199
|
+
// Defense in depth: planner already refuses the current worktree, but a removal here
|
|
200
|
+
// must never be able to delete the directory this process is running in.
|
|
201
|
+
if (isPathInside(masterCwd, wtPath)) continue;
|
|
182
202
|
const removed = await h.git.runGit(masterCwd, ['worktree', 'remove', wtPath]);
|
|
183
203
|
let ok = removed !== null;
|
|
184
204
|
if (!ok) {
|
package/src/subagent-launch.ts
CHANGED
|
@@ -19,6 +19,8 @@ export type LaunchParams = {
|
|
|
19
19
|
tab?: unknown;
|
|
20
20
|
};
|
|
21
21
|
|
|
22
|
+
import type { SubagentSpec } from './subagent-core.ts';
|
|
23
|
+
|
|
22
24
|
export type LaunchValidation =
|
|
23
25
|
| { kind: 'error'; text: string }
|
|
24
26
|
| {
|
|
@@ -30,6 +32,8 @@ export type LaunchValidation =
|
|
|
30
32
|
roleKind: string;
|
|
31
33
|
suggested: string[];
|
|
32
34
|
tab: string | null;
|
|
35
|
+
/** Role manifest supplied by the caller (drives tool gating); null when unset. */
|
|
36
|
+
manifestRole: string | null;
|
|
33
37
|
};
|
|
34
38
|
|
|
35
39
|
export function planLaunchValidation(
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { basename } from 'node:path';
|
|
2
|
+
import { agoText, type AliveProbe, type SubEntry } from './subagent-core.ts';
|
|
3
|
+
|
|
4
|
+
export interface ListActionResult {
|
|
5
|
+
content: Array<{ type: 'text'; text: string }>;
|
|
6
|
+
details: Record<string, unknown>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface ListActionDeps {
|
|
10
|
+
subs: () => Iterable<SubEntry>;
|
|
11
|
+
probeAlive: (paneId: string, cwd: string) => Promise<AliveProbe>;
|
|
12
|
+
readAskFlag: (paneId: string) => Promise<string | null>;
|
|
13
|
+
now?: () => number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Render the live background registry without coupling it to Cordis or the tool registration. */
|
|
17
|
+
export async function executeSubagentList(deps: ListActionDeps): Promise<ListActionResult> {
|
|
18
|
+
const listed = [...deps.subs()].filter((sub) => sub.background);
|
|
19
|
+
if (listed.length === 0) {
|
|
20
|
+
return {
|
|
21
|
+
content: [{ type: 'text', text: 'No background subagents started (from this session branch).' }],
|
|
22
|
+
details: {},
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const byTask = new Map<string, SubEntry>();
|
|
27
|
+
for (const sub of listed) {
|
|
28
|
+
const previous = byTask.get(sub.taskId);
|
|
29
|
+
if (!previous || sub.createdAt >= previous.createdAt) byTask.set(sub.taskId, sub);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const probes = new Map<string, AliveProbe>();
|
|
33
|
+
await Promise.all([...byTask.values()].map(async (sub) => {
|
|
34
|
+
if (sub.status === 'closed') return;
|
|
35
|
+
try {
|
|
36
|
+
probes.set(sub.paneId, await deps.probeAlive(sub.paneId, sub.cwd));
|
|
37
|
+
} catch {
|
|
38
|
+
/* Best effort for display. */
|
|
39
|
+
}
|
|
40
|
+
}));
|
|
41
|
+
|
|
42
|
+
const now = deps.now ?? Date.now;
|
|
43
|
+
const lines: string[] = [];
|
|
44
|
+
for (const sub of byTask.values()) {
|
|
45
|
+
const tabTag = sub.tabName ? ` [tab: ${sub.tabName}]` : '';
|
|
46
|
+
const wtTag = sub.isolate ? ` [wt: ${sub.isolate.branch}]` : '';
|
|
47
|
+
if (sub.status === 'closed') {
|
|
48
|
+
lines.push(`${sub.taskId.slice(0, 8)} [idle] (${sub.kind}, closed; action send revives)${tabTag}${wtTag} ${sub.description}`);
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const state = sub.status === 'settled' ? 'idle' : 'running';
|
|
53
|
+
const takeoverMark = sub.userTakeover ? ', user-controlled' : '';
|
|
54
|
+
const probe = probes.get(sub.paneId);
|
|
55
|
+
const statusTag = probe?.agentStatus ? ` ${probe.agentStatus}` : '';
|
|
56
|
+
const activityTag = probe?.lastActivityMs != null ? `, active ${agoText(probe.lastActivityMs, now())}` : '';
|
|
57
|
+
let gateTag = '';
|
|
58
|
+
if (probe?.agentStatus === 'blocked') {
|
|
59
|
+
const question = await deps.readAskFlag(sub.paneId);
|
|
60
|
+
gateTag = question ? ` — AWAITING HUMAN: "${question}"` : ' — AWAITING HUMAN decision';
|
|
61
|
+
}
|
|
62
|
+
const cwdTag = probe?.foregroundCwd && probe.foregroundCwd !== sub.cwd
|
|
63
|
+
? ` [cwd: ${basename(probe.foregroundCwd) || probe.foregroundCwd}]`
|
|
64
|
+
: '';
|
|
65
|
+
lines.push(`${sub.paneId} [${state}${takeoverMark}${statusTag}${activityTag}${gateTag}] (${sub.kind})${tabTag}${wtTag}${cwdTag} ${sub.description}`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return { content: [{ type: 'text', text: lines.join('\n') }], details: {} };
|
|
69
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import type { HerdrAgentState, HerdrClientLike } from './herdr-client.ts';
|
|
2
|
+
import type { SubEntry } from './subagent-core.ts';
|
|
3
|
+
import {
|
|
4
|
+
computeSubagentOutputDelta,
|
|
5
|
+
formatSubagentOutput,
|
|
6
|
+
resolveSubagentStatus,
|
|
7
|
+
type SubagentOutputCursor,
|
|
8
|
+
} from './subagent-output-core.ts';
|
|
9
|
+
import { readSubagentOutput } from './subagent-output-adapter.ts';
|
|
10
|
+
import { toolError } from './tool-error.ts';
|
|
11
|
+
|
|
12
|
+
export interface OutputActionParams {
|
|
13
|
+
agentId?: unknown;
|
|
14
|
+
taskId?: unknown;
|
|
15
|
+
max_chars?: unknown;
|
|
16
|
+
maxChars?: unknown;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface OutputActionResult {
|
|
20
|
+
content: Array<{ type: 'text'; text: string }>;
|
|
21
|
+
details: Record<string, unknown>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface OutputActionDeps {
|
|
25
|
+
client: HerdrClientLike;
|
|
26
|
+
resolveEntry: (rawId: string, cwd: string) => { entry: SubEntry } | { error: string };
|
|
27
|
+
readAskFlag: (paneId: string) => Promise<string | null>;
|
|
28
|
+
outputCursors: Map<string, SubagentOutputCursor>;
|
|
29
|
+
getCwd: (toolCtx: unknown) => string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function executeSubagentOutput(
|
|
33
|
+
params: OutputActionParams | undefined,
|
|
34
|
+
toolCtx: unknown,
|
|
35
|
+
deps: OutputActionDeps,
|
|
36
|
+
): Promise<OutputActionResult> {
|
|
37
|
+
const rawId = String(params?.agentId ?? params?.taskId ?? '').trim();
|
|
38
|
+
if (!rawId) return toolError('Error: missing agentId for output (see action list)') as OutputActionResult;
|
|
39
|
+
|
|
40
|
+
const cwd = deps.getCwd(toolCtx);
|
|
41
|
+
const resolved = deps.resolveEntry(rawId, cwd);
|
|
42
|
+
if ('error' in resolved) return toolError(resolved.error) as OutputActionResult;
|
|
43
|
+
|
|
44
|
+
const entry = resolved.entry;
|
|
45
|
+
let agentState: HerdrAgentState | null = null;
|
|
46
|
+
let askFlag: string | null = null;
|
|
47
|
+
try {
|
|
48
|
+
const agents = await deps.client.listAgents();
|
|
49
|
+
const agent = agents.find((candidate) => candidate.paneId === entry.paneId);
|
|
50
|
+
agentState = agent?.status ?? null;
|
|
51
|
+
askFlag = agent?.tokens?.['pi-ask'] ?? null;
|
|
52
|
+
} catch {
|
|
53
|
+
/* Best effort: output remains useful without live status. */
|
|
54
|
+
}
|
|
55
|
+
if (!askFlag && agentState === 'blocked') {
|
|
56
|
+
try {
|
|
57
|
+
askFlag = await deps.readAskFlag(entry.paneId);
|
|
58
|
+
} catch {
|
|
59
|
+
/* Best effort. */
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const status = resolveSubagentStatus({
|
|
64
|
+
localStatus: entry.status,
|
|
65
|
+
herdrStatus: agentState,
|
|
66
|
+
hasAskFlag: Boolean(askFlag),
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
let rawRead: { text: string; revision: number; truncated: boolean };
|
|
70
|
+
try {
|
|
71
|
+
rawRead = await readSubagentOutput(deps.client, entry.paneId);
|
|
72
|
+
} catch (err) {
|
|
73
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
74
|
+
return toolError(`Error: failed to read output for subagent ${entry.paneId}: ${message}`) as OutputActionResult;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const prevCursor = deps.outputCursors.get(entry.paneId);
|
|
78
|
+
const maxChars = typeof params?.max_chars === 'number'
|
|
79
|
+
? params.max_chars
|
|
80
|
+
: typeof params?.maxChars === 'number' ? params.maxChars : undefined;
|
|
81
|
+
const deltaResult = computeSubagentOutputDelta(prevCursor, rawRead.text, { maxChars });
|
|
82
|
+
deps.outputCursors.set(entry.paneId, deltaResult.nextCursor);
|
|
83
|
+
|
|
84
|
+
const formatted = formatSubagentOutput({
|
|
85
|
+
paneId: entry.paneId,
|
|
86
|
+
status,
|
|
87
|
+
revision: rawRead.revision,
|
|
88
|
+
bufferTruncated: rawRead.truncated,
|
|
89
|
+
deltaResult,
|
|
90
|
+
askQuestion: askFlag,
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
content: [{ type: 'text', text: formatted }],
|
|
95
|
+
details: {
|
|
96
|
+
paneId: entry.paneId,
|
|
97
|
+
status,
|
|
98
|
+
revision: rawRead.revision,
|
|
99
|
+
truncated: rawRead.truncated || deltaResult.truncated,
|
|
100
|
+
restart: deltaResult.restart,
|
|
101
|
+
deltaLength: deltaResult.delta.length,
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Herdr output-read adapter for subagents.
|
|
3
|
+
*
|
|
4
|
+
* Newer herdr exposes agent.read; older versions only expose pane.read. Prefer the semantic agent
|
|
5
|
+
* endpoint and fall back to the pane endpoint so output observation stays version-compatible.
|
|
6
|
+
*/
|
|
7
|
+
import type { HerdrClientLike } from './herdr-client.ts';
|
|
8
|
+
|
|
9
|
+
export interface SubagentOutputRead {
|
|
10
|
+
text: string;
|
|
11
|
+
revision: number;
|
|
12
|
+
truncated: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function readSubagentOutput(
|
|
16
|
+
client: HerdrClientLike,
|
|
17
|
+
paneId: string,
|
|
18
|
+
): Promise<SubagentOutputRead> {
|
|
19
|
+
if (typeof client.readAgent === 'function') {
|
|
20
|
+
try {
|
|
21
|
+
return await client.readAgent(paneId, { source: 'recent', stripAnsi: true });
|
|
22
|
+
} catch {
|
|
23
|
+
return await client.readPane(paneId, { source: 'recent', stripAnsi: true });
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return await client.readPane(paneId, { source: 'recent', stripAnsi: true });
|
|
27
|
+
}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* subagent-output-core.ts
|
|
3
|
+
*
|
|
4
|
+
* Pure functions for subagent incremental output extraction:
|
|
5
|
+
* - Cursor management (in-memory per-pane tail tracking)
|
|
6
|
+
* - Longest common suffix/prefix delta computation
|
|
7
|
+
* - Boundary handling for scrolling, clear-screen, repeated text (graceful degradation with restart: true)
|
|
8
|
+
* - Bounded character budgeting and metadata formatting
|
|
9
|
+
*/
|
|
10
|
+
import type { HerdrAgentState } from './herdr-client.ts';
|
|
11
|
+
import type { SubEntry } from './subagent-core.ts';
|
|
12
|
+
|
|
13
|
+
export const OUTPUT_DEFAULT_MAX_CHARS = 6000;
|
|
14
|
+
export const OUTPUT_HARD_CAP_CHARS = 16000;
|
|
15
|
+
export const OUTPUT_MIN_OVERLAP = 16;
|
|
16
|
+
export const OUTPUT_TAIL_CHARS = 500;
|
|
17
|
+
|
|
18
|
+
export type SubagentStatus = 'running' | 'idle' | 'blocked' | 'settled';
|
|
19
|
+
|
|
20
|
+
export interface SubagentOutputCursor {
|
|
21
|
+
/** Suffix of previous full text used for boundary matching */
|
|
22
|
+
tail: string;
|
|
23
|
+
/** Length of previous full text */
|
|
24
|
+
fullLength: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface SubagentOutputDeltaOptions {
|
|
28
|
+
/** Max characters of delta to return (default: 6000) */
|
|
29
|
+
maxChars?: number;
|
|
30
|
+
/** Minimum overlap length required for partial scroll matching (default: 16) */
|
|
31
|
+
minOverlap?: number;
|
|
32
|
+
/** Length of tail to retain in cursor (default: 500) */
|
|
33
|
+
tailChars?: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface SubagentOutputDeltaResult {
|
|
37
|
+
/** The new output text (or full bounded text on initial/restart) */
|
|
38
|
+
delta: string;
|
|
39
|
+
/** Whether the increment could not be determined reliably (scrolled away, cleared, ambiguous repeat) */
|
|
40
|
+
restart: boolean;
|
|
41
|
+
/** Whether delta was truncated to maxChars */
|
|
42
|
+
truncated: boolean;
|
|
43
|
+
/** New cursor to save for next call */
|
|
44
|
+
nextCursor: SubagentOutputCursor;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Extract tail characters from a text buffer for cursor matching.
|
|
49
|
+
*/
|
|
50
|
+
export function extractTail(text: string, tailChars = OUTPUT_TAIL_CHARS): string {
|
|
51
|
+
if (text.length <= tailChars) return text;
|
|
52
|
+
return text.slice(text.length - tailChars);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Bound text to a maximum character budget, retaining the tail (most recent output).
|
|
57
|
+
*/
|
|
58
|
+
export function boundText(text: string, maxChars: number): { text: string; truncated: boolean } {
|
|
59
|
+
if (text.length <= maxChars) {
|
|
60
|
+
return { text, truncated: false };
|
|
61
|
+
}
|
|
62
|
+
return {
|
|
63
|
+
text: text.slice(text.length - maxChars),
|
|
64
|
+
truncated: true,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Pure function: Given previous cursor (tail + fullLength) and current full buffer text,
|
|
70
|
+
* compute the incremental delta since last read.
|
|
71
|
+
*
|
|
72
|
+
* Boundary handling:
|
|
73
|
+
* 1. Initial read (prevCursor is null/empty): returns full bounded text, restart=false.
|
|
74
|
+
* 2. No change: fullLength and tail match, delta is empty, restart=false.
|
|
75
|
+
* 3. Normal append: prevTail appears uniquely in currText -> delta is text after prevTail.
|
|
76
|
+
* 4. Repeated text: prevTail appears multiple times -> ambiguous, returns full bounded text with restart=true.
|
|
77
|
+
* 5. Partial buffer scroll: prevTail partially scrolled off top -> longest suffix of prevTail
|
|
78
|
+
* matching prefix of currText (>= minOverlap) identifies boundary.
|
|
79
|
+
* 6. Full scroll / clear screen: no valid overlap found -> returns full bounded text with restart=true.
|
|
80
|
+
*/
|
|
81
|
+
export function computeSubagentOutputDelta(
|
|
82
|
+
prevCursor: SubagentOutputCursor | null | undefined,
|
|
83
|
+
currText: string,
|
|
84
|
+
options?: SubagentOutputDeltaOptions,
|
|
85
|
+
): SubagentOutputDeltaResult {
|
|
86
|
+
const rawMaxChars = options?.maxChars ?? OUTPUT_DEFAULT_MAX_CHARS;
|
|
87
|
+
const maxChars = Math.max(1, Math.min(rawMaxChars, OUTPUT_HARD_CAP_CHARS));
|
|
88
|
+
const minOverlap = options?.minOverlap ?? OUTPUT_MIN_OVERLAP;
|
|
89
|
+
const tailChars = options?.tailChars ?? OUTPUT_TAIL_CHARS;
|
|
90
|
+
|
|
91
|
+
// Case 1: First call (no previous cursor)
|
|
92
|
+
if (!prevCursor || !prevCursor.tail) {
|
|
93
|
+
const bounded = boundText(currText, maxChars);
|
|
94
|
+
return {
|
|
95
|
+
delta: bounded.text,
|
|
96
|
+
restart: false,
|
|
97
|
+
truncated: bounded.truncated,
|
|
98
|
+
nextCursor: {
|
|
99
|
+
tail: extractTail(currText, tailChars),
|
|
100
|
+
fullLength: currText.length,
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const prevTail = prevCursor.tail;
|
|
106
|
+
|
|
107
|
+
// Case 2: Exact same content (no new output)
|
|
108
|
+
if (currText.length === prevCursor.fullLength && currText.endsWith(prevTail)) {
|
|
109
|
+
return {
|
|
110
|
+
delta: '',
|
|
111
|
+
restart: false,
|
|
112
|
+
truncated: false,
|
|
113
|
+
nextCursor: prevCursor,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Case 3: prevTail appears in currText
|
|
118
|
+
const firstIdx = currText.indexOf(prevTail);
|
|
119
|
+
const lastIdx = currText.lastIndexOf(prevTail);
|
|
120
|
+
|
|
121
|
+
if (firstIdx !== -1) {
|
|
122
|
+
// 3a: Ambiguous duplicate occurrences in current text
|
|
123
|
+
if (firstIdx !== lastIdx) {
|
|
124
|
+
const bounded = boundText(currText, maxChars);
|
|
125
|
+
return {
|
|
126
|
+
delta: bounded.text,
|
|
127
|
+
restart: true,
|
|
128
|
+
truncated: bounded.truncated,
|
|
129
|
+
nextCursor: {
|
|
130
|
+
tail: extractTail(currText, tailChars),
|
|
131
|
+
fullLength: currText.length,
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// 3b: Unique match
|
|
137
|
+
const rawDelta = currText.slice(firstIdx + prevTail.length);
|
|
138
|
+
if (rawDelta.length === 0) {
|
|
139
|
+
return {
|
|
140
|
+
delta: '',
|
|
141
|
+
restart: false,
|
|
142
|
+
truncated: false,
|
|
143
|
+
nextCursor: prevCursor,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const bounded = boundText(rawDelta, maxChars);
|
|
148
|
+
return {
|
|
149
|
+
delta: bounded.text,
|
|
150
|
+
restart: false,
|
|
151
|
+
truncated: bounded.truncated,
|
|
152
|
+
nextCursor: {
|
|
153
|
+
tail: extractTail(currText, tailChars),
|
|
154
|
+
fullLength: currText.length,
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Case 4: prevTail is not found in full. Check if a suffix of prevTail matches a prefix of currText
|
|
160
|
+
// (terminal buffer scrolled lines off the top).
|
|
161
|
+
let longestOverlap = 0;
|
|
162
|
+
const maxPossibleOverlap = Math.min(prevTail.length - 1, currText.length);
|
|
163
|
+
const effectiveMinOverlap = Math.min(minOverlap, prevTail.length);
|
|
164
|
+
|
|
165
|
+
for (let k = maxPossibleOverlap; k >= effectiveMinOverlap; k--) {
|
|
166
|
+
const suffix = prevTail.slice(prevTail.length - k);
|
|
167
|
+
if (currText.startsWith(suffix)) {
|
|
168
|
+
longestOverlap = k;
|
|
169
|
+
break;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (longestOverlap >= effectiveMinOverlap) {
|
|
174
|
+
const rawDelta = currText.slice(longestOverlap);
|
|
175
|
+
const bounded = boundText(rawDelta, maxChars);
|
|
176
|
+
return {
|
|
177
|
+
delta: bounded.text,
|
|
178
|
+
restart: false,
|
|
179
|
+
truncated: bounded.truncated,
|
|
180
|
+
nextCursor: {
|
|
181
|
+
tail: extractTail(currText, tailChars),
|
|
182
|
+
fullLength: currText.length,
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Case 5: Clear screen or full scroll out -> degraded reset
|
|
188
|
+
const bounded = boundText(currText, maxChars);
|
|
189
|
+
return {
|
|
190
|
+
delta: bounded.text,
|
|
191
|
+
restart: true,
|
|
192
|
+
truncated: bounded.truncated,
|
|
193
|
+
nextCursor: {
|
|
194
|
+
tail: extractTail(currText, tailChars),
|
|
195
|
+
fullLength: currText.length,
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Resolve subagent semantic status from local subs registry and herdr agent state.
|
|
202
|
+
*/
|
|
203
|
+
export function resolveSubagentStatus(opts: {
|
|
204
|
+
localStatus: SubEntry['status'];
|
|
205
|
+
herdrStatus?: HerdrAgentState | null;
|
|
206
|
+
hasAskFlag?: boolean;
|
|
207
|
+
}): SubagentStatus {
|
|
208
|
+
if (opts.hasAskFlag || opts.herdrStatus === 'blocked') {
|
|
209
|
+
return 'blocked';
|
|
210
|
+
}
|
|
211
|
+
if (opts.herdrStatus === 'working') {
|
|
212
|
+
return 'running';
|
|
213
|
+
}
|
|
214
|
+
if (opts.localStatus === 'settled' || opts.localStatus === 'closed' || opts.herdrStatus === 'done') {
|
|
215
|
+
return 'settled';
|
|
216
|
+
}
|
|
217
|
+
if (opts.herdrStatus === 'idle') {
|
|
218
|
+
return 'idle';
|
|
219
|
+
}
|
|
220
|
+
if (opts.localStatus === 'running') {
|
|
221
|
+
return 'running';
|
|
222
|
+
}
|
|
223
|
+
return 'running';
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export interface FormatSubagentOutputOpts {
|
|
227
|
+
paneId: string;
|
|
228
|
+
status: SubagentStatus;
|
|
229
|
+
revision: number;
|
|
230
|
+
bufferTruncated: boolean;
|
|
231
|
+
deltaResult: SubagentOutputDeltaResult;
|
|
232
|
+
askQuestion?: string | null;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Format subagent output for display to the master model.
|
|
237
|
+
*/
|
|
238
|
+
export function formatSubagentOutput(opts: FormatSubagentOutputOpts): string {
|
|
239
|
+
const parts: string[] = [];
|
|
240
|
+
const metaItems: string[] = [
|
|
241
|
+
`pane: ${opts.paneId}`,
|
|
242
|
+
`status: ${opts.status}${opts.status === 'blocked' && opts.askQuestion ? ` (question: "${opts.askQuestion}")` : ''}`,
|
|
243
|
+
`revision: ${opts.revision}`,
|
|
244
|
+
];
|
|
245
|
+
|
|
246
|
+
if (opts.deltaResult.restart) {
|
|
247
|
+
metaItems.push('restart: true (buffer reset or scrolled)');
|
|
248
|
+
}
|
|
249
|
+
if (opts.bufferTruncated || opts.deltaResult.truncated) {
|
|
250
|
+
metaItems.push('truncated: true');
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const header = `[Subagent Output | ${metaItems.join(' | ')}]`;
|
|
254
|
+
parts.push(header);
|
|
255
|
+
|
|
256
|
+
if (opts.deltaResult.delta.length === 0) {
|
|
257
|
+
parts.push('(no new output since last read)');
|
|
258
|
+
} else {
|
|
259
|
+
parts.push(opts.deltaResult.delta);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return parts.join('\n');
|
|
263
|
+
}
|