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.
- package/README.md +26 -12
- package/package.json +8 -3
- package/schemas/efficiency-config.schema.json +77 -0
- package/schemas/role-manifest.schema.json +7 -1
- package/skills/pi-herdr-workflow/SKILL.md +11 -0
- package/src/ansi-text.ts +134 -0
- package/src/ask-dialog.ts +308 -0
- package/src/ask-user.ts +139 -88
- package/src/bootstrap.ts +13 -14
- package/src/compact-coordinator.ts +471 -0
- package/src/compact-economics-core.ts +328 -0
- package/src/config-catalog-core.ts +332 -0
- package/src/config-command.ts +388 -0
- package/src/dashboard-command.ts +108 -0
- package/src/efficiency-config-core.ts +464 -0
- package/src/efficiency-store.ts +299 -0
- package/src/focus-poller.ts +223 -0
- package/src/gc-core.ts +77 -18
- package/src/herdr-client.ts +284 -154
- package/src/history-store.ts +36 -23
- package/src/index-gates.ts +124 -0
- package/src/index-locks.ts +29 -16
- package/src/index-master.ts +29 -19
- package/src/index-notices.ts +35 -11
- package/src/index-pipe.ts +17 -7
- package/src/index-roles.ts +306 -0
- package/src/index-runtime.ts +2 -6
- package/src/index-worker.ts +7 -9
- package/src/index.ts +425 -388
- package/src/jev-client.ts +141 -0
- package/src/jev-core.ts +431 -0
- package/src/ledger.ts +13 -21
- package/src/lock-core.ts +98 -37
- package/src/manifest-compose.ts +35 -20
- package/src/observation-core.ts +200 -0
- package/src/pane-title.ts +19 -38
- package/src/pi-surface.ts +34 -22
- package/src/pier-options.ts +100 -0
- package/src/pipe-channel.ts +65 -52
- package/src/platform-paths.ts +19 -52
- package/src/{core → plugins}/grid-shape.ts +12 -12
- package/src/plugins/heat-plan.ts +158 -0
- package/src/plugins/observation.ts +475 -0
- package/src/plugins/subagent.ts +728 -0
- package/src/{core → plugins}/terminal.ts +237 -123
- package/src/{core → plugins}/todo.ts +75 -100
- package/src/progress-core.ts +16 -53
- package/src/reconcile-core.ts +24 -33
- package/src/reducer-core.ts +322 -0
- package/src/reducer-invoker.ts +419 -0
- package/src/renderers.ts +187 -0
- package/src/role-loader.ts +35 -29
- package/src/role-manifest.ts +23 -32
- package/src/role-state.ts +119 -0
- package/src/routing-telemetry.ts +136 -0
- package/src/runtime-policy.ts +25 -43
- package/src/session-tail.ts +102 -23
- package/src/settle-wake-core.ts +55 -22
- package/src/slim-frame.ts +29 -124
- package/src/stale-core.ts +17 -24
- package/src/storage-layout.ts +24 -30
- package/src/subagent-core.ts +256 -119
- package/src/subagent-gc.ts +131 -69
- package/src/subagent-poller.ts +401 -12
- package/src/subagent-session.ts +516 -0
- package/src/subagent-spawn.ts +651 -28
- package/src/swallow.ts +65 -0
- package/src/terminal-core.ts +160 -69
- package/src/todo-core.ts +62 -113
- package/src/todo-read-hook.ts +23 -39
- package/src/todo-reminder-core.ts +30 -48
- package/src/todo-window.ts +11 -12
- package/src/todos-service.ts +27 -35
- package/src/tool-error.ts +19 -0
- package/src/tool-gate.ts +38 -21
- package/src/vocab.ts +55 -25
- package/src/core/subagent.ts +0 -839
- package/src/git-adapter.ts +0 -104
- package/src/notice-buffer.ts +0 -23
- package/src/subagent-git-io.ts +0 -80
- package/src/subagent-launch.ts +0 -110
- package/src/subagent-poll-loop.ts +0 -285
- package/src/subagent-port.ts +0 -27
- package/src/subagent-scope.ts +0 -39
- package/src/subagent-session-io.ts +0 -147
package/src/core/subagent.ts
DELETED
|
@@ -1,839 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* core/subagent — master-only loader entry (tools, registry, poller, GC).
|
|
3
|
-
*
|
|
4
|
-
* Inbound deps via `pi-herdr.subagent-deps`. Outbound pipe/settle queries bind
|
|
5
|
-
* atomically onto `port.current` (see subagent-port.ts). Settlement reconcile
|
|
6
|
-
* stays in index session state; this plugin consumes it through the port.
|
|
7
|
-
*/
|
|
8
|
-
import { Context } from '@deepseek-ai/cordis';
|
|
9
|
-
import { Type } from 'typebox';
|
|
10
|
-
import { randomUUID } from 'node:crypto';
|
|
11
|
-
import { basename, join, dirname } from 'node:path';
|
|
12
|
-
import type { PiSurface } from '../pi-surface.ts';
|
|
13
|
-
import type { HerdrClientLike } from '../herdr-client.ts';
|
|
14
|
-
import { Semaphore, SUBS_CUSTOM_TYPE, agoText, buildAliveNotice, buildBlockedGateNotice, buildIsolatePreamble, classifyWorktreeZone, foldSubsRegistry, formatSubagentResult, isAlive, isPathUnder, makeProgressUpdate, makeRegistry, planIsolateWorktree, tabNameForTask, type AliveProbe, type SubEntry } from '../subagent-core.ts';
|
|
15
|
-
import { applyReportedSessionFile, appendHistory, preferredHistoryFile, inheritOutcome, latestGeneration, readHistory, type HistoryEntry } from '../history-store.ts';
|
|
16
|
-
import { runtimePolicy } from '../runtime-policy.ts';
|
|
17
|
-
import { platformPaths } from '../platform-paths.ts';
|
|
18
|
-
import type { SubagentPort, SubagentPortBox } from '../subagent-port.ts';
|
|
19
|
-
import {
|
|
20
|
-
FOREGROUND_POLL_MS,
|
|
21
|
-
planForegroundTick,
|
|
22
|
-
planIsolateRepoGuard,
|
|
23
|
-
planLaunchValidation,
|
|
24
|
-
planPatienceExpiry,
|
|
25
|
-
} from '../subagent-launch.ts';
|
|
26
|
-
import { mkdirSync, stat as statCb } from 'node:fs';
|
|
27
|
-
import { resolve as pathResolve } from 'node:path';
|
|
28
|
-
import { promisify } from 'node:util';
|
|
29
|
-
const statAsync = promisify(statCb);
|
|
30
|
-
import { pipeNameFor, pipeRequestTo } from '../pipe-channel.ts';
|
|
31
|
-
import { composeForRole } from '../manifest-compose.ts';
|
|
32
|
-
import type { TerminalStateSlot } from './terminal.ts';
|
|
33
|
-
import { createGitIo } from '../subagent-git-io.ts';
|
|
34
|
-
import { createSessionIo } from '../subagent-session-io.ts';
|
|
35
|
-
import { createSpawner } from '../subagent-spawn.ts';
|
|
36
|
-
import { createPoller } from '../subagent-poll-loop.ts';
|
|
37
|
-
import { createGcController } from '../subagent-gc.ts';
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
interface SubagentEnv {
|
|
41
|
-
paneId: string;
|
|
42
|
-
tabId: string;
|
|
43
|
-
workspaceId: string;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
interface SubagentDeps {
|
|
47
|
-
client: HerdrClientLike;
|
|
48
|
-
env: SubagentEnv | null;
|
|
49
|
-
/** Extension entry (index.ts) path — launchLine `-e`. */
|
|
50
|
-
extPath: string;
|
|
51
|
-
/** Master tree root (scope mount). */
|
|
52
|
-
sessionRoot: Context;
|
|
53
|
-
port: SubagentPortBox;
|
|
54
|
-
getSessionId: () => string;
|
|
55
|
-
reconcileOnSettlement: (description: string, outcome: 'settled' | 'failed') => string[];
|
|
56
|
-
withReconcileNotes: (base: string, notes: readonly string[]) => string;
|
|
57
|
-
claimSettleNotice: (key: string) => boolean;
|
|
58
|
-
/**
|
|
59
|
-
* Settlement notice injector from index: buffer while busy, flush at turn_end.
|
|
60
|
-
* Tests may omit it and fall back to pi.sendUserMessage(followUp).
|
|
61
|
-
*/
|
|
62
|
-
deliverNotice?: (content: string, paneId?: string) => Promise<void>;
|
|
63
|
-
/** Panes whose settlement notice is still buffered (GC exemption). */
|
|
64
|
-
noticePending?: () => ReadonlySet<string>;
|
|
65
|
-
/** Terminal-family GC exemption (live terminal panes are not collected). */
|
|
66
|
-
terminalState: TerminalStateSlot;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
const SUBAGENT_DESCRIPTION = [
|
|
70
|
-
'Delegate a self-contained subtask to an isolated subagent that runs in its own herdr pane as an interactive pi session (separate context window; it does NOT see this conversation). A human can also open that pane and talk to the subagent directly.',
|
|
71
|
-
'`action` (optional, default "spawn"): spawn | resume | list | send | interrupt.',
|
|
72
|
-
'[spawn] `description`: short display label for the pane; `prompt`: the COMPLETE task — include all needed context, since only the prompt reaches the subagent. The description also doubles as the todo-reconcile key: when delegating a todo entry, use the entry content WITHOUT its ` <sub>` marker as the description, and the entry is auto-completed when this subagent settles.',
|
|
73
|
-
'[spawn] The subagent shares this workspace and works independently; the result is its final text answer.',
|
|
74
|
-
'[spawn] Concurrent delegation is supported: several spawn calls in one message run in parallel (at most 4 at once). Use this for well-scoped, independent subtasks; do not delegate the current step itself.',
|
|
75
|
-
'[spawn] `run_in_background` (default false): when true, the call returns immediately with an agentId; the subagent keeps running in its pane. Use action list to see its state, action send to give it follow-up work, action interrupt to stop it. When it settles, you receive a notification message with its closing output.',
|
|
76
|
-
'[spawn] `tab` (optional): name of a task tab to place the subagent into (join if a tab with this name exists, otherwise create it). Overrides the default placement. Default placement groups by git worktree: a subagent working in your checkout shares your tab; one working in a separate worktree (pass its path via `cwd`; create worktrees with `git worktree add`) gets its own tab named after the worktree directory.',
|
|
77
|
-
'[spawn] `role` + `allowed_tools`: when role matches a profile (searched: workspace .pi-herdr/roles/ → user-global ~/.pi/agent/herdr-pi/roles/ → builtin), the worker toolset becomes the composed manifest — baseline ∪ allowed_tools minus role-deny tools (deny always wins). Custom roles: drop a JSON profile into .pi-herdr/roles/ (master/worker-default reserved). Unknown role names remain display labels only.',
|
|
78
|
-
'[spawn] `isolate` (default false): creates a FRESH git worktree for this subagent and runs it there (branch pier/<slug> from your HEAD under ~/.herdr/worktrees/<repo>/). Three-way choice: heavy independent writing in parallel with your own edits or other workers, or work needing its own clean reviewable diff → isolate; read-mostly or sequential helper work → omit (shared checkout, writes guarded by the write-lock); targeting an existing directory/worktree → cwd. In isolate mode the subagent\'s writes cannot conflict with your checkout; its panes group into a tab named after the worktree; its prompt is prefixed with commit discipline (commit to its own branch, NEVER push); when it settles you get a diff summary (commits since base, files changed, uncommitted count). Review with git log/diff HEAD..<branch>, merge with git merge --no-ff <branch>; once merged and clean the worktree auto-removes (branch kept). Mutually exclusive with cwd.',
|
|
79
|
-
'[resume] `taskId`: revive a finished (collected) subagent from the delegation ledger — opens its saved conversation in a new pane (pi --session), then use action send to give it new work. The ledger is an append-only JSONL file, one row per status change (same taskId rows = generations, latest row is current): fields taskId, description, status (running|settled|consumed|closed), outcome (closing text), paneId, sessionFile, launchCommand, createdAt. It is per-checkout at ~/.pi/agent/herdr-pi/history/<flattened-cwd>/history.jsonl. Use action list for live panes from this session; for earlier sessions or closed panes, grep the ledger for the taskId.',
|
|
80
|
-
'[list] no extra parameters: list background subagents with live state (running / idle), pane ids, last activity, role, and descriptions. Foreground one-shot panes are not listed.',
|
|
81
|
-
'[send] `agentId` + `message`: follow-up to a background subagent. If working, delivered at next tool-call gap (steer, seconds); if idle, wakes a new turn. After settle, send to wake it; do not spawn a duplicate.',
|
|
82
|
-
'[interrupt] `agentId`: abort the current turn (fire-and-return). The pane stays; you can send again.',
|
|
83
|
-
].join(' ');
|
|
84
|
-
|
|
85
|
-
/** Subagent concurrency limit (max parallel delegations) */
|
|
86
|
-
const SUBAGENT_CONCURRENCY = 4;
|
|
87
|
-
const SUBAGENT_TIMEOUT_MS = runtimePolicy.subagentTimeoutMs;
|
|
88
|
-
const SUB_READY_TIMEOUT_MS = runtimePolicy.readinessTimeoutMs;
|
|
89
|
-
|
|
90
|
-
function defaultAgentSessionsDir(): string {
|
|
91
|
-
const base = process.env.PI_CODING_AGENT_DIR || platformPaths.agentDataDir;
|
|
92
|
-
return join(base, 'sessions');
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
function agentRootDir(): string {
|
|
96
|
-
return dirname(defaultAgentSessionsDir());
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
export default function subagentPlugin(ctx: Context): void {
|
|
100
|
-
const surface = ctx.get('pi-herdr.surface') as PiSurface<object>;
|
|
101
|
-
const d = ctx.get('pi-herdr.subagent-deps') as SubagentDeps;
|
|
102
|
-
const { client, env, sessionRoot, port, terminalState } = d;
|
|
103
|
-
const pi = surface.raw as {
|
|
104
|
-
appendEntry?: (customType: string, data: unknown) => void;
|
|
105
|
-
sendUserMessage?: (content: string, opts?: { deliverAs?: string }) => Promise<void>;
|
|
106
|
-
};
|
|
107
|
-
const scoped = surface.forModule(import.meta.url);
|
|
108
|
-
|
|
109
|
-
const runtime = {
|
|
110
|
-
nodePath: process.execPath,
|
|
111
|
-
cliPath: process.argv[1] ?? '',
|
|
112
|
-
extPath: d.extPath,
|
|
113
|
-
};
|
|
114
|
-
const subSemaphore = new Semaphore(SUBAGENT_CONCURRENCY);
|
|
115
|
-
/** B4 timestamps machine injection so grace-period working events are not mistaken for takeover. */
|
|
116
|
-
const lastMachineInjectAt = new Map<string, number>();
|
|
117
|
-
/* Persist the subagent registry as custom branch state so parent restarts can rebuild it. */
|
|
118
|
-
const subs = new Map<string, SubEntry>();
|
|
119
|
-
/** D98 excludes branches during worktree-add-to-registry races from orphan collection. */
|
|
120
|
-
const pendingIsolateBranches = new Set<string>();
|
|
121
|
-
/** D50 tracks the latest machine request per pane for interrupt claims and poll deduplication. */
|
|
122
|
-
const lastRequestIdByPane = new Map<string, string>();
|
|
123
|
-
|
|
124
|
-
/** O1 suppresses duplicate snapshots because 01a03c0d found 48% of session rows were heartbeat repeats. */
|
|
125
|
-
let lastSubsSnapshot = '';
|
|
126
|
-
|
|
127
|
-
function persistSubs(): void {
|
|
128
|
-
try {
|
|
129
|
-
const reg = makeRegistry([...subs.values()]);
|
|
130
|
-
const snap = JSON.stringify(reg);
|
|
131
|
-
if (snap === lastSubsSnapshot) return; // Avoid duplicate writes from the 1s/5s observation loops.
|
|
132
|
-
lastSubsSnapshot = snap;
|
|
133
|
-
pi.appendEntry?.(SUBS_CUSTOM_TYPE, reg);
|
|
134
|
-
} catch {
|
|
135
|
-
/* Persistence is best-effort so session logging cannot break delegation. */
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
/** E2 deduplicates gate notices while blocked but permits a later, distinct human question. */
|
|
139
|
-
const blockedGateNotified = new Set<string>();
|
|
140
|
-
const git = createGitIo();
|
|
141
|
-
|
|
142
|
-
const boundPort: SubagentPort = {
|
|
143
|
-
applyReplySession(paneId, sessionFile) {
|
|
144
|
-
const entry = subs.get(paneId);
|
|
145
|
-
if (!entry) return;
|
|
146
|
-
const next = applyReportedSessionFile(entry.sessionFile, sessionFile);
|
|
147
|
-
if (next === entry.sessionFile) return;
|
|
148
|
-
entry.sessionFile = next;
|
|
149
|
-
persistSubs();
|
|
150
|
-
writeHistory(entry, undefined, 'session-report');
|
|
151
|
-
},
|
|
152
|
-
reconcileOnReply(paneId) {
|
|
153
|
-
const entry = subs.get(paneId);
|
|
154
|
-
return entry ? d.reconcileOnSettlement(entry.description, 'settled') : [];
|
|
155
|
-
},
|
|
156
|
-
listRunningSubs() {
|
|
157
|
-
return [...subs.values()]
|
|
158
|
-
.filter((s) => s.background && s.status === 'running')
|
|
159
|
-
.map((s) => ({ paneId: s.paneId, description: s.description }));
|
|
160
|
-
},
|
|
161
|
-
async settleStatLine(paneId) {
|
|
162
|
-
const entry = subs.get(paneId);
|
|
163
|
-
return entry ? await git.worktreeStatLine(entry) : null;
|
|
164
|
-
},
|
|
165
|
-
};
|
|
166
|
-
port.current = boundPort;
|
|
167
|
-
ctx.effect(() => () => {
|
|
168
|
-
if (port.current === boundPort) port.current = null;
|
|
169
|
-
}, 'subagent-port');
|
|
170
|
-
function rebuildSubs(eventCtx: unknown): void {
|
|
171
|
-
try {
|
|
172
|
-
const entries = (eventCtx as { sessionManager?: { getBranch?: () => readonly unknown[] } })
|
|
173
|
-
?.sessionManager?.getBranch?.() ?? [];
|
|
174
|
-
const reg = foldSubsRegistry(entries as Parameters<typeof foldSubsRegistry>[0]);
|
|
175
|
-
for (const sub of reg.subs) subs.set(sub.paneId, sub);
|
|
176
|
-
lastSubsSnapshot = ''; // Force persistence because branch replay replaced live state.
|
|
177
|
-
} catch {
|
|
178
|
-
/* Registry recovery failure must not block the live session. */
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
/** B6 closes crash-stale running rows only after herdr confirms their pane is gone; 01a03c0d
|
|
183
|
-
* showed p6/p7 otherwise remained permanently running. */
|
|
184
|
-
async function sweepZombieRunning(): Promise<void> {
|
|
185
|
-
if (!client.available || subs.size === 0) return;
|
|
186
|
-
let livePaneIds: ReadonlySet<string>;
|
|
187
|
-
try {
|
|
188
|
-
livePaneIds = new Set((await client.listPanes()).map((p) => p.paneId));
|
|
189
|
-
} catch {
|
|
190
|
-
return; // Do not close agents when liveness lookup itself failed.
|
|
191
|
-
}
|
|
192
|
-
let changed = false;
|
|
193
|
-
for (const [paneId, e] of subs) {
|
|
194
|
-
if (e.status !== 'running' || livePaneIds.has(paneId)) continue;
|
|
195
|
-
e.status = 'closed';
|
|
196
|
-
writeHistory(e, { status: 'closed', closedAt: Date.now() }, 'zombie-sweep');
|
|
197
|
-
changed = true;
|
|
198
|
-
}
|
|
199
|
-
if (changed) persistSubs();
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
scoped.on('session_start', async (_event: unknown, eventCtx: unknown) => {
|
|
203
|
-
rebuildSubs(eventCtx);
|
|
204
|
-
await sweepZombieRunning();
|
|
205
|
-
});
|
|
206
|
-
scoped.on('session_tree', async (_event: unknown, eventCtx: unknown) => {
|
|
207
|
-
rebuildSubs(eventCtx);
|
|
208
|
-
await sweepZombieRunning();
|
|
209
|
-
});
|
|
210
|
-
|
|
211
|
-
/* ── B1 liveness rewrite for subagent errors ─────────────────────
|
|
212
|
-
* A false no-output result caused the model to seize work from a healthy subagent. Probe
|
|
213
|
-
* agent.list and session mtime before the tool result enters model context; if alive, emit the
|
|
214
|
-
* same notice as A2 backgrounding. This hook enforces what prompting alone could not. */
|
|
215
|
-
scoped.on('tool_result', async (event: { toolName?: string; toolCallId?: string; isError?: boolean; content?: Array<{ type: string; text?: string }> }) => {
|
|
216
|
-
if (event?.toolName !== 'subagent' || !event.isError) return;
|
|
217
|
-
// Extract only pane ids carried by our own errors; leave unrelated errors untouched.
|
|
218
|
-
const errText = (event.content ?? []).map((c) => c.text ?? '').join(' ');
|
|
219
|
-
const paneId = [...subs.keys()].find((id) => errText.includes(id))
|
|
220
|
-
?? (errText.match(/\bw[A-Za-z0-9]+:p\d+\b/) ?? [])[0];
|
|
221
|
-
if (!paneId) return;
|
|
222
|
-
const entry = subs.get(paneId);
|
|
223
|
-
if (!entry || entry.status === 'settled' || entry.status === 'consumed') return;
|
|
224
|
-
const probe = await probeAlive(paneId, entry.cwd);
|
|
225
|
-
if (!isAlive(probe, Date.now())) return; // Preserve the original error only when the agent is truly dead.
|
|
226
|
-
// Move a live agent to the poller so its eventual result can replace the false error.
|
|
227
|
-
if (!pollers.has(paneId)) {
|
|
228
|
-
entry.background = true;
|
|
229
|
-
startPoller(paneId, entry.cwd, entry.createdAt, entry.createdAt, entry.description, lastRequestIdByPane.get(paneId) ?? `probe-${paneId}`);
|
|
230
|
-
persistSubs();
|
|
231
|
-
}
|
|
232
|
-
const notice = buildAliveNotice(
|
|
233
|
-
{ paneId, description: entry.description, scenario: 'error-alive', probe },
|
|
234
|
-
Date.now(),
|
|
235
|
-
);
|
|
236
|
-
return { content: [{ type: 'text', text: notice }] };
|
|
237
|
-
});
|
|
238
|
-
|
|
239
|
-
function histFile(cwd: string): string {
|
|
240
|
-
return preferredHistoryFile(agentRootDir(), cwd);
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
function toHistory(e: SubEntry, outcome: string | null): HistoryEntry {
|
|
244
|
-
return {
|
|
245
|
-
taskId: e.taskId,
|
|
246
|
-
kind: e.kind,
|
|
247
|
-
paneId: e.paneId,
|
|
248
|
-
tabId: e.tabId,
|
|
249
|
-
tabName: e.tabName,
|
|
250
|
-
workspaceId: env?.workspaceId ?? '',
|
|
251
|
-
cwd: e.cwd,
|
|
252
|
-
description: e.description,
|
|
253
|
-
sessionFile: e.sessionFile,
|
|
254
|
-
launchCommand: e.launchCommand,
|
|
255
|
-
status: e.status,
|
|
256
|
-
outcome,
|
|
257
|
-
createdAt: e.createdAt,
|
|
258
|
-
consumedAt: e.consumedAt ?? null,
|
|
259
|
-
closedAt: null,
|
|
260
|
-
revivedFrom: e.revivedFrom ?? null,
|
|
261
|
-
};
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
/** B5 retains the latest non-empty outcome because closed generations otherwise hid settled
|
|
265
|
-
* results behind outcome:null in latest-generation views. */
|
|
266
|
-
const lastOutcomeByTask = new Map<string, string>();
|
|
267
|
-
|
|
268
|
-
function writeHistory(e: SubEntry, patch?: Partial<HistoryEntry>, via?: string): void {
|
|
269
|
-
const outcome = inheritOutcome(lastOutcomeByTask.get(e.taskId), patch?.outcome);
|
|
270
|
-
if (typeof outcome === 'string' && outcome.length > 0) lastOutcomeByTask.set(e.taskId, outcome);
|
|
271
|
-
appendHistory(histFile(e.cwd), { ...toHistory(e, outcome), ...(patch ?? {}), ...(via ? { via } : {}) });
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
const injectNotice = (content: string): Promise<void> =>
|
|
275
|
-
d.deliverNotice ? d.deliverNotice(content)
|
|
276
|
-
: (pi.sendUserMessage?.(content, { deliverAs: 'followUp' }) ?? Promise.resolve());
|
|
277
|
-
|
|
278
|
-
const session = createSessionIo({
|
|
279
|
-
client,
|
|
280
|
-
getSessionId: d.getSessionId,
|
|
281
|
-
sessionsDir: defaultAgentSessionsDir,
|
|
282
|
-
});
|
|
283
|
-
const spawn = createSpawner({ client, env, runtime, git });
|
|
284
|
-
const poller = createPoller({
|
|
285
|
-
client,
|
|
286
|
-
sessionRoot,
|
|
287
|
-
subs,
|
|
288
|
-
persistSubs,
|
|
289
|
-
writeHistory,
|
|
290
|
-
blockedGateNotified,
|
|
291
|
-
lastMachineInjectAt,
|
|
292
|
-
session,
|
|
293
|
-
git,
|
|
294
|
-
injectNotice,
|
|
295
|
-
reconcileOnSettlement: d.reconcileOnSettlement,
|
|
296
|
-
withReconcileNotes: d.withReconcileNotes,
|
|
297
|
-
claimSettleNotice: d.claimSettleNotice,
|
|
298
|
-
});
|
|
299
|
-
const gc = createGcController({
|
|
300
|
-
client,
|
|
301
|
-
env,
|
|
302
|
-
subs,
|
|
303
|
-
persistSubs,
|
|
304
|
-
writeHistory,
|
|
305
|
-
terminalState,
|
|
306
|
-
noticePending: d.noticePending,
|
|
307
|
-
pendingIsolateBranches,
|
|
308
|
-
git,
|
|
309
|
-
injectNotice,
|
|
310
|
-
});
|
|
311
|
-
gc.startTicker(ctx);
|
|
312
|
-
scoped.on('turn_start', () => gc.onTurnStart());
|
|
313
|
-
|
|
314
|
-
const { resolveSessionFile, collectFinalText, readAskFlag, probeAlive, subSessionState } = session;
|
|
315
|
-
const { spawnPaneInTaskTab, launchLine, approveFor, waitSubReady, findExistingPane } = spawn;
|
|
316
|
-
const { startPoller, pollers } = poller;
|
|
317
|
-
const runGit = (...args: Parameters<typeof git.runGit>) => git.runGit(...args);
|
|
318
|
-
const listWorktrees = (...args: Parameters<typeof git.listWorktrees>) => git.listWorktrees(...args);
|
|
319
|
-
|
|
320
|
-
/** Revive a closed task; resume and automatic send_message revival share this path. */
|
|
321
|
-
async function reviveEntry(entry: SubEntry): Promise<SubEntry> {
|
|
322
|
-
// D98: Do not revive a released isolate worktree because its directory is gone and cwd is invalid.
|
|
323
|
-
if (entry.isolate?.releasedAt != null) {
|
|
324
|
-
throw new Error(`isolate worktree ${entry.isolate.branch} was released (merged) — delegate a new subagent instead`);
|
|
325
|
-
}
|
|
326
|
-
const latest = latestGeneration(readHistory(histFile(entry.cwd)), entry.taskId) ?? entry;
|
|
327
|
-
const resumeFile = latest.sessionFile && /\.jsonl$/.test(latest.sessionFile) ? latest.sessionFile : null;
|
|
328
|
-
// D86 trust matches spawn: pass -a only for the master's checkout/worktrees; revive has no tool context, so use process cwd.
|
|
329
|
-
const approve = await approveFor(entry.cwd, process.cwd());
|
|
330
|
-
const spawned = await spawnPaneInTaskTab(
|
|
331
|
-
{ desiredTab: entry.tabName || latest.tabName || null, description: entry.description },
|
|
332
|
-
entry.cwd,
|
|
333
|
-
{ PI_HERDR_SUBAGENT: '1' },
|
|
334
|
-
launchLine(resumeFile, null, approve),
|
|
335
|
-
);
|
|
336
|
-
const ready = await waitSubReady(entry.cwd, spawned.paneId);
|
|
337
|
-
if (!ready) throw new Error(`revived pane ${spawned.paneId} pipe not ready`);
|
|
338
|
-
entry.paneId = spawned.paneId;
|
|
339
|
-
entry.tabId = spawned.tabId;
|
|
340
|
-
entry.tabName = spawned.tabName;
|
|
341
|
-
entry.sessionFile = resumeFile;
|
|
342
|
-
entry.status = 'running';
|
|
343
|
-
entry.consumedAt = null;
|
|
344
|
-
entry.revivedFrom = latest.paneId;
|
|
345
|
-
entry.launchCommand = [launchLine(resumeFile, null, approve)];
|
|
346
|
-
entry.createdAt = Date.now();
|
|
347
|
-
writeHistory(entry, undefined, 'revive');
|
|
348
|
-
return entry;
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
async function executeSubagentResume(params, toolCtx) {
|
|
353
|
-
if (!client.available) {
|
|
354
|
-
return {
|
|
355
|
-
content: [{ type: 'text', text: 'Error: requires a herdr-managed pane.' }],
|
|
356
|
-
details: {},
|
|
357
|
-
};
|
|
358
|
-
}
|
|
359
|
-
const cwd = (toolCtx as { cwd?: string }).cwd ?? process.cwd();
|
|
360
|
-
const taskId = String(params?.taskId ?? '');
|
|
361
|
-
const latest = latestGeneration(readHistory(histFile(cwd)), taskId);
|
|
362
|
-
if (!latest) {
|
|
363
|
-
return {
|
|
364
|
-
content: [{ type: 'text', text: `Error: no history for task "${taskId}" in this workspace.` }],
|
|
365
|
-
details: {},
|
|
366
|
-
};
|
|
367
|
-
}
|
|
368
|
-
const release = await subSemaphore.acquire();
|
|
369
|
-
try {
|
|
370
|
-
// D94: Reuse an existing pane for the same session to avoid competing pi processes.
|
|
371
|
-
const existing = await findExistingPane(latest.sessionFile);
|
|
372
|
-
if (existing) {
|
|
373
|
-
const entry: SubEntry = {
|
|
374
|
-
taskId,
|
|
375
|
-
kind: latest.kind,
|
|
376
|
-
paneId: existing.paneId,
|
|
377
|
-
tabId: existing.tabId,
|
|
378
|
-
tabName: latest.tabName ?? tabNameForTask(latest.description),
|
|
379
|
-
cwd,
|
|
380
|
-
description: latest.description,
|
|
381
|
-
background: true,
|
|
382
|
-
status: 'running',
|
|
383
|
-
sessionFile: latest.sessionFile,
|
|
384
|
-
launchCommand: latest.launchCommand,
|
|
385
|
-
createdAt: Date.now(),
|
|
386
|
-
revivedFrom: latest.paneId,
|
|
387
|
-
consumedAt: null,
|
|
388
|
-
};
|
|
389
|
-
subs.set(entry.paneId, entry);
|
|
390
|
-
persistSubs();
|
|
391
|
-
writeHistory(entry, undefined, 'resume');
|
|
392
|
-
return {
|
|
393
|
-
content: [{
|
|
394
|
-
type: 'text',
|
|
395
|
-
text: `resumed subagent ${entry.paneId} from task ${taskId} (reused existing pane with same session; pi still running there).`,
|
|
396
|
-
}],
|
|
397
|
-
details: { paneId: entry.paneId, taskId },
|
|
398
|
-
};
|
|
399
|
-
}
|
|
400
|
-
// Create a new pane only when no existing one can be reused.
|
|
401
|
-
const entry: SubEntry = {
|
|
402
|
-
taskId,
|
|
403
|
-
kind: latest.kind,
|
|
404
|
-
paneId: '',
|
|
405
|
-
tabId: '',
|
|
406
|
-
tabName: latest.tabName ?? tabNameForTask(latest.description),
|
|
407
|
-
cwd,
|
|
408
|
-
description: latest.description,
|
|
409
|
-
background: true,
|
|
410
|
-
status: 'running',
|
|
411
|
-
sessionFile: latest.sessionFile,
|
|
412
|
-
launchCommand: latest.launchCommand,
|
|
413
|
-
createdAt: Date.now(),
|
|
414
|
-
revivedFrom: latest.paneId,
|
|
415
|
-
consumedAt: null,
|
|
416
|
-
};
|
|
417
|
-
await reviveEntry(entry);
|
|
418
|
-
subs.set(entry.paneId, entry);
|
|
419
|
-
persistSubs();
|
|
420
|
-
return {
|
|
421
|
-
content: [{
|
|
422
|
-
type: 'text',
|
|
423
|
-
text: entry.sessionFile
|
|
424
|
-
? `resumed subagent ${entry.paneId} from task ${taskId} (session restored).`
|
|
425
|
-
: `resumed subagent ${entry.paneId} from task ${taskId} (session file missing; fresh conversation).`,
|
|
426
|
-
}],
|
|
427
|
-
details: { paneId: entry.paneId, taskId },
|
|
428
|
-
};
|
|
429
|
-
} catch (err) {
|
|
430
|
-
return {
|
|
431
|
-
content: [{ type: 'text', text: `Error: failed to resume task "${taskId}": ${(err as Error).message}` }],
|
|
432
|
-
details: {},
|
|
433
|
-
};
|
|
434
|
-
} finally {
|
|
435
|
-
release();
|
|
436
|
-
}
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
async function executeSubagentList() {
|
|
440
|
-
const listed = [...subs.values()].filter((s) => s.background);
|
|
441
|
-
if (listed.length === 0) {
|
|
442
|
-
return { content: [{ type: 'text', text: 'No background subagents started (from this session branch).' }], details: {} };
|
|
443
|
-
}
|
|
444
|
-
// Deduplicate by taskId so only the newest generation appears after revival; old paneIds do not repeat.
|
|
445
|
-
const byTask = new Map<string, SubEntry>();
|
|
446
|
-
for (const sub of listed) {
|
|
447
|
-
const prev = byTask.get(sub.taskId);
|
|
448
|
-
if (!prev || sub.createdAt >= prev.createdAt) byTask.set(sub.taskId, sub);
|
|
449
|
-
}
|
|
450
|
-
// C1: Probe liveness in real time (pane status and session activity) so the master can distinguish work from a true hang.
|
|
451
|
-
const probes = new Map<string, AliveProbe>();
|
|
452
|
-
await Promise.all([...byTask.values()].map(async (sub) => {
|
|
453
|
-
if (sub.status === 'closed') return;
|
|
454
|
-
try { probes.set(sub.paneId, await probeAlive(sub.paneId, sub.cwd)); } catch { /* Best effort for display. */ }
|
|
455
|
-
}));
|
|
456
|
-
const lines: string[] = [];
|
|
457
|
-
for (const sub of byTask.values()) {
|
|
458
|
-
const tabTag = sub.tabName ? ` [tab: ${sub.tabName}]` : '';
|
|
459
|
-
// D98: Include the worktree branch on isolate entries, including closed ones; settlement stats already report merge state.
|
|
460
|
-
const wtTag = sub.isolate ? ` [wt: ${sub.isolate.branch}]` : '';
|
|
461
|
-
if (sub.status === 'closed') {
|
|
462
|
-
lines.push(`${sub.taskId.slice(0, 8)} [idle] (${sub.kind}, closed; action send revives)${tabTag}${wtTag} ${sub.description}`);
|
|
463
|
-
continue;
|
|
464
|
-
}
|
|
465
|
-
// The registry is authoritative: settled is idle, everything else is running, including blocked human gates (DSH mapping).
|
|
466
|
-
const state = sub.status === 'settled' ? 'idle' : 'running';
|
|
467
|
-
// D94: Mark when the user has taken over.
|
|
468
|
-
const takeoverMark = sub.userTakeover ? ', user-controlled' : '';
|
|
469
|
-
const probe = probes.get(sub.paneId);
|
|
470
|
-
const statusTag = probe?.agentStatus ? ` ${probe.agentStatus}` : '';
|
|
471
|
-
const activityTag = probe?.lastActivityMs != null ? `, active ${agoText(probe.lastActivityMs, Date.now())}` : '';
|
|
472
|
-
// E: For blocked agents, include the gate-question summary so the master sends the user to that pane instead of taking over.
|
|
473
|
-
let gateTag = '';
|
|
474
|
-
if (probe?.agentStatus === 'blocked') {
|
|
475
|
-
const q = await readAskFlag(sub.paneId);
|
|
476
|
-
gateTag = q ? ` — AWAITING HUMAN: "${q}"` : ' — AWAITING HUMAN decision';
|
|
477
|
-
}
|
|
478
|
-
lines.push(`${sub.paneId} [${state}${takeoverMark}${statusTag}${activityTag}${gateTag}] (${sub.kind})${tabTag}${wtTag} ${sub.description}`);
|
|
479
|
-
}
|
|
480
|
-
return { content: [{ type: 'text', text: lines.join('\n') }], details: {} };
|
|
481
|
-
}
|
|
482
|
-
|
|
483
|
-
async function executeSubagentSend(params) {
|
|
484
|
-
const entry = subs.get(String(params?.agentId ?? ''));
|
|
485
|
-
if (!entry) {
|
|
486
|
-
return { content: [{ type: 'text', text: `Error: unknown subagent id "${params?.agentId}" (see action list)` }], details: {} };
|
|
487
|
-
}
|
|
488
|
-
const spawnedAt = Date.now();
|
|
489
|
-
try {
|
|
490
|
-
// A closed task is revived automatically because the pane is only a temporary host.
|
|
491
|
-
if (entry.status === 'closed') {
|
|
492
|
-
await reviveEntry(entry);
|
|
493
|
-
subs.set(entry.paneId, entry);
|
|
494
|
-
persistSubs();
|
|
495
|
-
}
|
|
496
|
-
// M11 (D46): follow_up uses the extension pipe. B3 adds steering so supplemental instructions reach a long-running worker
|
|
497
|
-
// within seconds during a tool-call gap; the old followUp queue waited for the entire run and caused 20-minute rework (01a03c0d).
|
|
498
|
-
const ready = await waitSubReady(entry.cwd, entry.paneId);
|
|
499
|
-
if (!ready) throw new Error(`subagent pane ${entry.paneId} pipe not ready`);
|
|
500
|
-
const fuId = `fu-${Date.now()}`;
|
|
501
|
-
const res = await pipeRequestTo(entry.cwd, entry.paneId, {
|
|
502
|
-
type: 'follow_up',
|
|
503
|
-
id: fuId,
|
|
504
|
-
text: String(params?.message ?? ''),
|
|
505
|
-
from: pipeNameFor(entry.cwd, env?.paneId ?? ''),
|
|
506
|
-
push: true,
|
|
507
|
-
steer: true,
|
|
508
|
-
});
|
|
509
|
-
if (res.type !== 'ok') {
|
|
510
|
-
throw new Error(`pipe follow_up rejected: ${res.type === 'error' ? res.message : 'unknown response'}`);
|
|
511
|
-
}
|
|
512
|
-
lastMachineInjectAt.set(entry.paneId, Date.now()); // B4: attribute working state during the observation window.
|
|
513
|
-
// Regression hardening (D98 liveness evidence): an exception between status='running' and startPoller could leave a ghost
|
|
514
|
-
// running entry without a poller, never settling and skipped by GC; roll back to the previous status on failure.
|
|
515
|
-
const prevStatus = entry.status;
|
|
516
|
-
entry.status = 'running';
|
|
517
|
-
persistSubs();
|
|
518
|
-
try {
|
|
519
|
-
lastRequestIdByPane.set(entry.paneId, fuId);
|
|
520
|
-
startPoller(entry.paneId, entry.cwd, spawnedAt, Date.now(), entry.description, fuId);
|
|
521
|
-
} catch (inner) {
|
|
522
|
-
entry.status = prevStatus;
|
|
523
|
-
persistSubs();
|
|
524
|
-
throw inner;
|
|
525
|
-
}
|
|
526
|
-
return { content: [{ type: 'text', text: `Message sent to subagent ${entry.paneId}.` }], details: { paneId: entry.paneId } };
|
|
527
|
-
} catch (err) {
|
|
528
|
-
return { content: [{ type: 'text', text: `Error: failed to reach subagent ${entry.paneId}: ${(err as Error).message}` }], details: {} };
|
|
529
|
-
}
|
|
530
|
-
}
|
|
531
|
-
|
|
532
|
-
async function executeSubagentInterrupt(params) {
|
|
533
|
-
const entry = subs.get(String(params?.agentId ?? ''));
|
|
534
|
-
if (!entry) {
|
|
535
|
-
return { content: [{ type: 'text', text: `Error: unknown subagent id "${params?.agentId}" (see action list)` }], details: {} };
|
|
536
|
-
}
|
|
537
|
-
if (entry.status === 'closed') {
|
|
538
|
-
// DSH alignment: an idle or finished target is an idempotent no-op.
|
|
539
|
-
return { content: [{ type: 'text', text: `Interrupt accepted for subagent ${entry.paneId} (already idle/closed; no-op).` }], details: {} };
|
|
540
|
-
}
|
|
541
|
-
try {
|
|
542
|
-
const res = await pipeRequestTo(entry.cwd, entry.paneId, {
|
|
543
|
-
type: 'interrupt',
|
|
544
|
-
id: `int-${Date.now()}`,
|
|
545
|
-
});
|
|
546
|
-
if (res.type !== 'ok') {
|
|
547
|
-
throw new Error(`pipe interrupt rejected: ${res.type === 'error' ? res.message : 'unknown response'}`);
|
|
548
|
-
}
|
|
549
|
-
// Do not send a settlement notice for an interrupted turn because the requester already knows.
|
|
550
|
-
const lastId = lastRequestIdByPane.get(entry.paneId);
|
|
551
|
-
if (lastId) d.claimSettleNotice(`${entry.paneId}:${lastId}`);
|
|
552
|
-
return { content: [{ type: 'text', text: `Interrupt accepted for subagent ${entry.paneId} (fire-and-return).` }], details: { paneId: entry.paneId } };
|
|
553
|
-
} catch (err) {
|
|
554
|
-
return { content: [{ type: 'text', text: `Error: failed to reach subagent ${entry.paneId}: ${(err as Error).message}` }], details: {} };
|
|
555
|
-
}
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
scoped.registerTool({
|
|
559
|
-
name: 'subagent',
|
|
560
|
-
label: 'Subagent',
|
|
561
|
-
description: SUBAGENT_DESCRIPTION,
|
|
562
|
-
parameters: Type.Object({
|
|
563
|
-
action: Type.Optional(Type.Union([
|
|
564
|
-
Type.Literal('spawn'),
|
|
565
|
-
Type.Literal('resume'),
|
|
566
|
-
Type.Literal('list'),
|
|
567
|
-
Type.Literal('send'),
|
|
568
|
-
Type.Literal('interrupt'),
|
|
569
|
-
], { description: 'Operation to perform (default: spawn)' })),
|
|
570
|
-
description: Type.Optional(Type.String({ description: '[spawn] Short label for this subtask (pane title)' })),
|
|
571
|
-
prompt: Type.Optional(Type.String({ description: '[spawn] The complete self-contained task for the subagent' })),
|
|
572
|
-
run_in_background: Type.Optional(Type.Boolean({ description: '[spawn] Return immediately with an agentId; the subagent keeps running in its own pane (default false)' })),
|
|
573
|
-
cwd: Type.Optional(Type.String({ description: '[spawn] Working directory for the subagent (absolute, or relative to this workspace). Use it to delegate into a git worktree: panes group by worktree — same checkout as you share your tab; a separate worktree gets its own tab named after the worktree directory. Create worktrees yourself with git worktree add. Use isolate:true instead when you want a FRESH worktree created for this task rather than targeting an existing one' })),
|
|
574
|
-
isolate: Type.Optional(Type.Boolean({ description: '[spawn] Create a fresh git worktree and run the subagent there. Use when the task writes files heavily and independently — in parallel with your own edits or other workers\' — or needs its own clean, reviewable diff. For read-mostly or sequential helper work omit it (shared checkout, writes guarded by the write-lock); use `cwd` only to target an existing directory/worktree (e.g. a retained pier worktree). Mechanics: branch pier/<slug> from your HEAD under ~/.herdr/worktrees/<repo>/; its writes cannot conflict with your checkout; panes group into a tab named after the worktree; the prompt is prefixed with commit discipline (commit to its branch, never push); on settle you get a diff summary. Review with git log/diff HEAD..<branch>, merge with git merge --no-ff <branch>; once merged and clean the worktree auto-removes (branch kept). Mutually exclusive with cwd' })),
|
|
575
|
-
role: Type.Optional(Type.String({ description: '[spawn] Role label or profile name. When role matches a profile (searched: workspace .pi-herdr/roles/ → user-global ~/.pi/agent/herdr-pi/roles/ → builtin), the worker toolset becomes the composed manifest. Unknown role names remain display labels only.' })),
|
|
576
|
-
tab: Type.Optional(Type.String({ description: '[spawn] Name of a task tab to place the subagent into (join if exists, otherwise create). Default placement groups by git worktree.' })),
|
|
577
|
-
allowed_tools: Type.Optional(Type.Array(Type.String(), { description: '[spawn] Additional tools for role composition (union with role baseline)' })),
|
|
578
|
-
taskId: Type.Optional(Type.String({ description: '[resume] The task id to revive from the delegation ledger' })),
|
|
579
|
-
agentId: Type.Optional(Type.String({ description: '[send|interrupt] The subagent id (herdr pane id)' })),
|
|
580
|
-
message: Type.Optional(Type.String({ description: '[send] The follow-up message' })),
|
|
581
|
-
}),
|
|
582
|
-
async execute(toolCallId, params, signal, onUpdate, toolCtx) {
|
|
583
|
-
void toolCallId;
|
|
584
|
-
void signal;
|
|
585
|
-
const action = typeof params?.action === 'string' && params.action.trim() ? params.action.trim() : 'spawn';
|
|
586
|
-
if (action === 'resume') return executeSubagentResume(params, toolCtx);
|
|
587
|
-
if (action === 'list') return executeSubagentList();
|
|
588
|
-
if (action === 'send') return executeSubagentSend(params);
|
|
589
|
-
if (action === 'interrupt') return executeSubagentInterrupt(params);
|
|
590
|
-
if (action !== 'spawn') {
|
|
591
|
-
return { content: [{ type: 'text', text: `Error: unknown action "${action}" (valid: spawn, resume, list, send, interrupt)` }], details: {} };
|
|
592
|
-
}
|
|
593
|
-
const launch = planLaunchValidation(params, client.available);
|
|
594
|
-
if (launch.kind === 'error') {
|
|
595
|
-
return { content: [{ type: 'text', text: launch.text }], details: {} };
|
|
596
|
-
}
|
|
597
|
-
const { spec, background, isolate, cwdParam, roleKind: kind, suggested, manifestRole, tab } = launch;
|
|
598
|
-
const masterCwd = (toolCtx as { cwd?: string }).cwd ?? process.cwd();
|
|
599
|
-
// Move taskId generation above the original spawn section because isolate planning needs taskHex; this is behavior-neutral.
|
|
600
|
-
const taskId = randomUUID();
|
|
601
|
-
// D98 2c: Create the isolate through pier's execFile git path (decision 1), not herdr's
|
|
602
|
-
// socket worktree.create, which has bootstrap races, missing root-pane env, worker/master
|
|
603
|
-
// workspace separation, and linked_worktree_source rejection. The model makes the decision; do not infer it.
|
|
604
|
-
let isolateMeta: SubEntry['isolate'] | null = null;
|
|
605
|
-
let cwd = masterCwd;
|
|
606
|
-
if (isolate) {
|
|
607
|
-
const baseSha = await runGit(masterCwd, ['rev-parse', 'HEAD']);
|
|
608
|
-
const isoGuard = planIsolateRepoGuard(baseSha);
|
|
609
|
-
if (isoGuard.kind === 'error') {
|
|
610
|
-
return { content: [{ type: 'text', text: isoGuard.text }], details: {} };
|
|
611
|
-
}
|
|
612
|
-
const sha = isoGuard.sha.trim();
|
|
613
|
-
const pierBranches = new Set(
|
|
614
|
-
((await runGit(masterCwd, ['for-each-ref', '--format=%(refname:short)', 'refs/heads/pier/'])) ?? '')
|
|
615
|
-
.split('\n').filter(Boolean),
|
|
616
|
-
);
|
|
617
|
-
const worktrees = await listWorktrees(masterCwd);
|
|
618
|
-
const repoName = basename(worktrees[0] ?? masterCwd);
|
|
619
|
-
const plan = planIsolateWorktree({
|
|
620
|
-
description: spec.description,
|
|
621
|
-
taskHex: taskId.slice(0, 6),
|
|
622
|
-
existingPierBranches: pierBranches,
|
|
623
|
-
});
|
|
624
|
-
const wtPath = join(platformPaths.worktreeBaseDir, repoName, plan.worktreeDirName);
|
|
625
|
-
try {
|
|
626
|
-
mkdirSync(dirname(wtPath), { recursive: true }); // git worktree add does not create its parent directory, so a first run would fail.
|
|
627
|
-
} catch { /* Existing directory or permissions failure is handled by worktree add. */ }
|
|
628
|
-
// Keep the branch pending between worktree add and subs.set: the 2–3s readiness wait lets the ticker
|
|
629
|
-
// scan, and without this guard it could reclaim a new clean =HEAD-ancestor worktree as an orphan.
|
|
630
|
-
pendingIsolateBranches.add(plan.branch);
|
|
631
|
-
const added = await runGit(masterCwd, ['worktree', 'add', '-b', plan.branch, wtPath, sha]);
|
|
632
|
-
if (added === null) {
|
|
633
|
-
pendingIsolateBranches.delete(plan.branch);
|
|
634
|
-
return {
|
|
635
|
-
content: [{ type: 'text', text: `Error: failed to create worktree ${wtPath} (branch ${plan.branch}) — run \`git worktree prune\` and retry if it reports stale entries` }],
|
|
636
|
-
details: {},
|
|
637
|
-
};
|
|
638
|
-
}
|
|
639
|
-
git.invalidateWorktreesCache(); // Invalidate the 5s cache so the zone classifier sees the new worktree immediately.
|
|
640
|
-
cwd = wtPath;
|
|
641
|
-
isolateMeta = { worktreePath: wtPath, branch: plan.branch, baseSha: sha, releasedAt: null, retainNotified: false };
|
|
642
|
-
spec.prompt = `${buildIsolatePreamble({ worktreePath: wtPath, branch: plan.branch, baseShort: sha.slice(0, 7) })}\n\n${spec.prompt}`;
|
|
643
|
-
} else if (cwdParam) {
|
|
644
|
-
cwd = pathResolve(masterCwd, cwdParam);
|
|
645
|
-
try {
|
|
646
|
-
const st = await statAsync(cwd);
|
|
647
|
-
if (!st.isDirectory()) throw new Error('not a directory');
|
|
648
|
-
} catch {
|
|
649
|
-
return {
|
|
650
|
-
content: [{ type: 'text', text: `Error: \`cwd\` is not an existing directory: ${cwdParam}` }],
|
|
651
|
-
details: {},
|
|
652
|
-
};
|
|
653
|
-
}
|
|
654
|
-
}
|
|
655
|
-
// D86 R1: Group by the git worktree containing cwd (main checkout → main tab; other worktree → directory-named tab).
|
|
656
|
-
const zone = classifyWorktreeZone({
|
|
657
|
-
cwd,
|
|
658
|
-
masterCwd,
|
|
659
|
-
worktrees: await listWorktrees(masterCwd),
|
|
660
|
-
});
|
|
661
|
-
// D86 trust: pass -a only for the master's checkout/worktrees; external directories remain behind pi's Trust dialog.
|
|
662
|
-
const approve = isPathUnder(cwd, masterCwd) || zone.zone === 'worktree';
|
|
663
|
-
let roleManifestEnv: Record<string, string> = {};
|
|
664
|
-
let roleModel: string | null = null;
|
|
665
|
-
try {
|
|
666
|
-
const { role, manifest } = composeForRole(manifestRole, suggested, { loadRoleOpts: { baseDir: masterCwd } });
|
|
667
|
-
roleManifestEnv = {
|
|
668
|
-
PI_HERDR_ROLE_MANIFEST: JSON.stringify({
|
|
669
|
-
role: role.role,
|
|
670
|
-
version: role.version,
|
|
671
|
-
tools: manifest.tools,
|
|
672
|
-
permissions: manifest.permissions,
|
|
673
|
-
unknownTools: manifest.unknownTools,
|
|
674
|
-
services: role.services ?? {},
|
|
675
|
-
}),
|
|
676
|
-
};
|
|
677
|
-
// WS-D10: Route the model by role; omission intentionally uses the process default.
|
|
678
|
-
if (typeof role.model === 'string' && role.model.trim()) roleModel = role.model.trim();
|
|
679
|
-
} catch (err) {
|
|
680
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
681
|
-
return {
|
|
682
|
-
content: [{ type: 'text', text: `Error: role "${manifestRole}" manifest invalid — ${msg}` }],
|
|
683
|
-
details: {},
|
|
684
|
-
};
|
|
685
|
-
}
|
|
686
|
-
const release = await subSemaphore.acquire();
|
|
687
|
-
let paneId = '';
|
|
688
|
-
try {
|
|
689
|
-
const spawnedAt = Date.now();
|
|
690
|
-
const spawned = await spawnPaneInTaskTab(
|
|
691
|
-
{ desiredTab: tab, description: spec.description, zone },
|
|
692
|
-
cwd,
|
|
693
|
-
{ PI_HERDR_SUBAGENT: '1', ...roleManifestEnv },
|
|
694
|
-
launchLine(null, roleModel, approve),
|
|
695
|
-
);
|
|
696
|
-
paneId = spawned.paneId;
|
|
697
|
-
const entry: SubEntry = {
|
|
698
|
-
taskId,
|
|
699
|
-
kind,
|
|
700
|
-
paneId,
|
|
701
|
-
tabId: spawned.tabId,
|
|
702
|
-
tabName: spawned.tabName,
|
|
703
|
-
cwd,
|
|
704
|
-
description: spec.description,
|
|
705
|
-
background,
|
|
706
|
-
status: 'running',
|
|
707
|
-
sessionFile: null,
|
|
708
|
-
launchCommand: [launchLine(null, roleModel, approve)],
|
|
709
|
-
createdAt: Date.now(),
|
|
710
|
-
revivedFrom: null,
|
|
711
|
-
...(isolateMeta ? { isolate: isolateMeta } : {}),
|
|
712
|
-
};
|
|
713
|
-
const ready = await waitSubReady(cwd, paneId);
|
|
714
|
-
if (!ready) throw new Error(`subagent pane ${paneId} pipe not ready within ${SUB_READY_TIMEOUT_MS}ms`);
|
|
715
|
-
entry.sessionFile = await resolveSessionFile(paneId, cwd);
|
|
716
|
-
subs.set(paneId, entry);
|
|
717
|
-
persistSubs();
|
|
718
|
-
onUpdate?.(makeProgressUpdate(`subagent ready in pane ${paneId}; injecting prompt via pipe…`));
|
|
719
|
-
// M11 (D45/D46): Inject through the extension pipe to the child extension's sendUserMessage(followUp);
|
|
720
|
-
// reserve the PTY keyboard channel entirely for the person, with no soft-lock window or mixed input.
|
|
721
|
-
// Regression evidence (session 01a03bf0): ecc0bc4's foreground-wait rewrite once removed this block,
|
|
722
|
-
// leaving the prompt uninjected and injectTs undefined (ReferenceError → spawn-failed, with a ghost
|
|
723
|
-
// running ledger entry). Keep injection and injectTs together in future refactors.
|
|
724
|
-
const injectTs = Date.now();
|
|
725
|
-
const injected = await pipeRequestTo(cwd, paneId, {
|
|
726
|
-
type: 'prompt',
|
|
727
|
-
id: `prompt-${taskId}`,
|
|
728
|
-
text: spec.prompt,
|
|
729
|
-
from: pipeNameFor(cwd, env?.paneId ?? ''),
|
|
730
|
-
push: background,
|
|
731
|
-
});
|
|
732
|
-
if (injected.type !== 'ok') {
|
|
733
|
-
throw new Error(`pipe prompt rejected: ${injected.type === 'error' ? injected.message : 'unknown response'}`);
|
|
734
|
-
}
|
|
735
|
-
lastMachineInjectAt.set(paneId, injectTs); // B4: attribute working state during the observation window.
|
|
736
|
-
// A1+A2 (user-verified fix): foreground waiting uses a content gate plus a patience threshold before backgrounding.
|
|
737
|
-
// Treating idle as settled with a hard 90s window misclassified real 4–6 minute working periods as no-output;
|
|
738
|
-
// three healthy subagents were observed becoming consumed at 101s while producing results four minutes later.
|
|
739
|
-
const PATIENCE_MS = runtimePolicy.foregroundPatienceMs;
|
|
740
|
-
const patienceDeadline = Date.now() + PATIENCE_MS;
|
|
741
|
-
let text: string | null = null;
|
|
742
|
-
let settledKind: 'settled' | 'timeout' = 'timeout';
|
|
743
|
-
while (Date.now() < Math.min(patienceDeadline, spawnedAt + SUBAGENT_TIMEOUT_MS)) {
|
|
744
|
-
const state = await client.waitAgent(paneId, ['idle', 'done', 'blocked'], 30_000);
|
|
745
|
-
const session = (state === 'idle' || state === 'done')
|
|
746
|
-
? await subSessionState(paneId, cwd, injectTs)
|
|
747
|
-
: { text: null, pendingTool: false, activity: false };
|
|
748
|
-
const tick = planForegroundTick({ state, session });
|
|
749
|
-
if (tick.kind === 'blocked') {
|
|
750
|
-
const question = await readAskFlag(paneId);
|
|
751
|
-
entry.background = true;
|
|
752
|
-
lastRequestIdByPane.set(paneId, `prompt-${taskId}`);
|
|
753
|
-
startPoller(paneId, cwd, spawnedAt, injectTs, spec.description, `prompt-${taskId}`);
|
|
754
|
-
persistSubs();
|
|
755
|
-
writeHistory(entry, undefined, 'to-background');
|
|
756
|
-
return {
|
|
757
|
-
content: [{ type: 'text', text: buildBlockedGateNotice({ paneId, description: spec.description, question }) }],
|
|
758
|
-
details: { paneId, taskId, background: true, blocked: true, role: kind },
|
|
759
|
-
};
|
|
760
|
-
}
|
|
761
|
-
if (tick.kind === 'settled') {
|
|
762
|
-
text = tick.text;
|
|
763
|
-
settledKind = 'settled';
|
|
764
|
-
break;
|
|
765
|
-
}
|
|
766
|
-
if (tick.kind === 'wait') {
|
|
767
|
-
await new Promise((r) => setTimeout(r, tick.delayMs));
|
|
768
|
-
continue;
|
|
769
|
-
}
|
|
770
|
-
if (tick.kind === 'collect-final') {
|
|
771
|
-
text = await collectFinalText(paneId, cwd, injectTs, 6);
|
|
772
|
-
if (text) { settledKind = 'settled'; break; }
|
|
773
|
-
await new Promise((r) => setTimeout(r, FOREGROUND_POLL_MS));
|
|
774
|
-
continue;
|
|
775
|
-
}
|
|
776
|
-
}
|
|
777
|
-
if (!text && settledKind !== 'blocked') {
|
|
778
|
-
const probe = await probeAlive(paneId, cwd);
|
|
779
|
-
if (planPatienceExpiry(isAlive(probe, Date.now())) === 'move-to-background') {
|
|
780
|
-
entry.background = true;
|
|
781
|
-
lastRequestIdByPane.set(paneId, `prompt-${taskId}`);
|
|
782
|
-
startPoller(paneId, cwd, spawnedAt, injectTs, spec.description, `prompt-${taskId}`);
|
|
783
|
-
persistSubs();
|
|
784
|
-
const notice = buildAliveNotice(
|
|
785
|
-
{ paneId, description: spec.description, scenario: 'moved-to-bg', probe },
|
|
786
|
-
Date.now(),
|
|
787
|
-
);
|
|
788
|
-
return {
|
|
789
|
-
content: [{ type: 'text', text: notice }],
|
|
790
|
-
details: { paneId, taskId, background: true, movedToBackground: true, role: kind },
|
|
791
|
-
};
|
|
792
|
-
}
|
|
793
|
-
settledKind = 'timeout';
|
|
794
|
-
}
|
|
795
|
-
const outcome =
|
|
796
|
-
settledKind === 'timeout' ? { kind: 'timeout' as const, text: text ?? '' }
|
|
797
|
-
: text ? { kind: 'completed' as const, text }
|
|
798
|
-
: { kind: 'no-output' as const, text: '' };
|
|
799
|
-
entry.status = 'consumed';
|
|
800
|
-
entry.consumedAt = Date.now();
|
|
801
|
-
persistSubs();
|
|
802
|
-
writeHistory(entry, { outcome: outcome.kind === 'completed' ? text : null }, 'fg-settle');
|
|
803
|
-
return {
|
|
804
|
-
content: [{ type: 'text', text: formatSubagentResult(outcome, spec.description) }],
|
|
805
|
-
details: { paneId, taskId, background, role: kind },
|
|
806
|
-
};
|
|
807
|
-
} catch (err) {
|
|
808
|
-
// Regression fix (session 01a03bf0): a mid-spawn failure must remove the ledger entry and close its pane,
|
|
809
|
-
// otherwise a ghost running entry never settles, causing D96 reminder storms and send_message calls
|
|
810
|
-
// to land in an empty session with no task context. Pane closure is best effort; GC covers board exceptions.
|
|
811
|
-
if (paneId) {
|
|
812
|
-
subs.delete(paneId);
|
|
813
|
-
persistSubs();
|
|
814
|
-
void client.closePane(paneId).catch(() => { /* Best effort only. */ });
|
|
815
|
-
}
|
|
816
|
-
// D98 2d: If isolate startup never becomes ready, best-effort remove the new worktree and branch (no work
|
|
817
|
-
// exists to preserve); failure stays silent because the branch is harmless and remains visible to git worktree list.
|
|
818
|
-
if (isolateMeta) {
|
|
819
|
-
void runGit(masterCwd, ['worktree', 'remove', '--force', isolateMeta.worktreePath])
|
|
820
|
-
.then(() => runGit(masterCwd, ['branch', '-D', isolateMeta!.branch]))
|
|
821
|
-
.catch(() => { /* Best effort only. */ });
|
|
822
|
-
}
|
|
823
|
-
return {
|
|
824
|
-
content: [{
|
|
825
|
-
type: 'text',
|
|
826
|
-
text: formatSubagentResult(
|
|
827
|
-
{ kind: 'spawn-failed', text: String((err as Error)?.message ?? err) },
|
|
828
|
-
spec.description,
|
|
829
|
-
),
|
|
830
|
-
}],
|
|
831
|
-
details: {},
|
|
832
|
-
};
|
|
833
|
-
} finally {
|
|
834
|
-
release();
|
|
835
|
-
if (isolateMeta) pendingIsolateBranches.delete(isolateMeta.branch); // D98: release the creation-window guard on every success/failure path.
|
|
836
|
-
}
|
|
837
|
-
},
|
|
838
|
-
});
|
|
839
|
-
}
|