pi-pier 0.3.0 → 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 +12 -11
- package/package.json +2 -2
- package/schemas/role-manifest.schema.json +7 -1
- package/skills/pi-herdr-workflow/SKILL.md +4 -5
- package/src/ansi-text.ts +62 -11
- package/src/ask-dialog.ts +308 -0
- package/src/ask-user.ts +113 -112
- package/src/bootstrap.ts +8 -12
- package/src/compact-coordinator.ts +278 -261
- package/src/compact-economics-core.ts +77 -145
- package/src/config-catalog-core.ts +77 -197
- package/src/config-command.ts +271 -64
- package/src/dashboard-command.ts +38 -19
- package/src/efficiency-config-core.ts +287 -484
- package/src/efficiency-store.ts +16 -29
- package/src/focus-poller.ts +14 -35
- package/src/gc-core.ts +10 -36
- package/src/herdr-client.ts +82 -208
- package/src/history-store.ts +36 -23
- package/src/index-gates.ts +124 -0
- package/src/index-locks.ts +2 -7
- package/src/index-master.ts +24 -18
- package/src/index-notices.ts +28 -15
- package/src/index-pipe.ts +17 -12
- package/src/index-roles.ts +306 -0
- package/src/index-runtime.ts +2 -6
- package/src/index-worker.ts +4 -8
- package/src/index.ts +305 -564
- package/src/jev-client.ts +12 -17
- package/src/jev-core.ts +137 -87
- package/src/ledger.ts +13 -21
- package/src/lock-core.ts +28 -56
- package/src/manifest-compose.ts +35 -20
- package/src/observation-core.ts +52 -100
- package/src/pane-title.ts +19 -38
- package/src/pi-surface.ts +34 -22
- package/src/pier-options.ts +54 -50
- package/src/pipe-channel.ts +27 -59
- package/src/platform-paths.ts +19 -52
- package/src/{core → plugins}/grid-shape.ts +8 -10
- package/src/{core → plugins}/heat-plan.ts +20 -22
- package/src/plugins/observation.ts +475 -0
- package/src/plugins/subagent.ts +728 -0
- package/src/{core → plugins}/terminal.ts +167 -127
- package/src/{core → plugins}/todo.ts +61 -100
- package/src/progress-core.ts +16 -53
- package/src/reconcile-core.ts +24 -33
- package/src/reducer-core.ts +24 -60
- package/src/reducer-invoker.ts +266 -239
- package/src/renderers.ts +76 -87
- package/src/role-loader.ts +33 -28
- package/src/role-manifest.ts +23 -28
- package/src/role-state.ts +119 -0
- package/src/routing-telemetry.ts +136 -0
- package/src/runtime-policy.ts +25 -40
- package/src/session-tail.ts +65 -41
- package/src/settle-wake-core.ts +52 -21
- package/src/slim-frame.ts +25 -122
- package/src/stale-core.ts +17 -24
- package/src/storage-layout.ts +14 -43
- package/src/subagent-core.ts +253 -118
- package/src/subagent-gc.ts +109 -67
- package/src/subagent-poller.ts +367 -24
- package/src/subagent-session.ts +516 -0
- package/src/subagent-spawn.ts +530 -52
- package/src/swallow.ts +7 -17
- package/src/terminal-core.ts +125 -106
- package/src/todo-core.ts +57 -109
- package/src/todo-read-hook.ts +23 -39
- package/src/todo-reminder-core.ts +26 -51
- package/src/todo-window.ts +11 -12
- package/src/todos-service.ts +15 -35
- package/src/tool-error.ts +6 -13
- package/src/tool-gate.ts +38 -21
- package/src/vocab.ts +52 -25
- package/src/ask-multi.ts +0 -260
- package/src/config-guide.ts +0 -361
- package/src/core/observation.ts +0 -555
- package/src/core/subagent.ts +0 -633
- package/src/efficiency-config-io.ts +0 -53
- package/src/git-adapter.ts +0 -113
- package/src/notice-buffer.ts +0 -23
- package/src/subagent-execute.ts +0 -328
- package/src/subagent-git-io.ts +0 -80
- package/src/subagent-launch.ts +0 -114
- package/src/subagent-list-action.ts +0 -69
- package/src/subagent-output-action.ts +0 -104
- package/src/subagent-output-adapter.ts +0 -27
- package/src/subagent-output-core.ts +0 -263
- package/src/subagent-poll-loop.ts +0 -305
- package/src/subagent-port.ts +0 -27
- package/src/subagent-ready.ts +0 -89
- package/src/subagent-registry.ts +0 -70
- package/src/subagent-resolution.ts +0 -41
- package/src/subagent-scope.ts +0 -41
- package/src/subagent-session-io.ts +0 -230
|
@@ -1,305 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Background settlement poller (takeover, blocked gate, observation, vacuum).
|
|
3
|
-
*
|
|
4
|
-
* Why: planners already live in subagent-poller.ts; this adapter owns the
|
|
5
|
-
* herdr/session I/O loop and pane-scope lifecycle so the plugin only starts it.
|
|
6
|
-
*/
|
|
7
|
-
import { appendFileSync } from 'node:fs';
|
|
8
|
-
import type { Context } from '@deepseek-ai/cordis';
|
|
9
|
-
import type { HerdrClientLike, HerdrAgentState } from './herdr-client.ts';
|
|
10
|
-
import { applyReportedSessionFile } from './history-store.ts';
|
|
11
|
-
import { runtimePolicy, type RuntimePolicy } from './runtime-policy.ts';
|
|
12
|
-
import { mountSubagentScope } from './subagent-scope.ts';
|
|
13
|
-
import {
|
|
14
|
-
OBSERVATION_TICK_MS,
|
|
15
|
-
TAKEOVER_RECHECK_MS,
|
|
16
|
-
buildSettlementNoticeText,
|
|
17
|
-
formatObservationTimeoutNotice,
|
|
18
|
-
formatPaneClosedNotice,
|
|
19
|
-
isSettlementCandidate,
|
|
20
|
-
planBlockedGate,
|
|
21
|
-
planObservationTick,
|
|
22
|
-
planTakeoverTick,
|
|
23
|
-
planVacuumTick,
|
|
24
|
-
} from './subagent-poller.ts';
|
|
25
|
-
import { buildBlockedGateNotice, type SubEntry } from './subagent-core.ts';
|
|
26
|
-
import type { SessionIo } from './subagent-session-io.ts';
|
|
27
|
-
import type { GitIo } from './subagent-git-io.ts';
|
|
28
|
-
|
|
29
|
-
export function sleep(ms: number): Promise<void> {
|
|
30
|
-
const wait = Promise.withResolvers<void>();
|
|
31
|
-
setTimeout(wait.resolve, ms);
|
|
32
|
-
return wait.promise;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export interface PollerHost {
|
|
36
|
-
client: HerdrClientLike;
|
|
37
|
-
sessionRoot: Context;
|
|
38
|
-
subs: Map<string, SubEntry>;
|
|
39
|
-
persistSubs(): void;
|
|
40
|
-
writeHistory(e: SubEntry, patch?: { outcome?: string | null; status?: SubEntry['status']; closedAt?: number }, via?: string): void;
|
|
41
|
-
blockedGateNotified: Set<string>;
|
|
42
|
-
lastMachineInjectAt: Map<string, number>;
|
|
43
|
-
session: SessionIo;
|
|
44
|
-
git: GitIo;
|
|
45
|
-
injectNotice(content: string): Promise<void>;
|
|
46
|
-
reconcileOnSettlement(description: string, outcome: 'settled' | 'failed'): string[];
|
|
47
|
-
withReconcileNotes(base: string, notes: readonly string[]): string;
|
|
48
|
-
claimSettleNotice(key: string): boolean;
|
|
49
|
-
/** Optional sleep seam for timing control without real delays */
|
|
50
|
-
sleep?: (ms: number) => Promise<void>;
|
|
51
|
-
/** Optional clock seam for deterministic virtual timestamps */
|
|
52
|
-
now?: () => number;
|
|
53
|
-
/** Optional runtime policy overrides for testing */
|
|
54
|
-
policy?: Partial<RuntimePolicy>;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
export interface Poller {
|
|
58
|
-
startPoller(paneId: string, cwd: string, spawnedAt: number, injectTs: number, description: string, requestId: string): Promise<void>;
|
|
59
|
-
readonly pollers: Set<string>;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
export function createPoller(h: PollerHost): Poller {
|
|
63
|
-
const pollers = new Set<string>();
|
|
64
|
-
const subScopes = new Map<string, { dispose: () => Promise<void> }>();
|
|
65
|
-
const doSleep = h.sleep ?? sleep;
|
|
66
|
-
const now = h.now ?? Date.now;
|
|
67
|
-
const observeWindowMs = h.policy?.observationWindowMs ?? runtimePolicy.observationWindowMs;
|
|
68
|
-
const machineInjectGraceMs = h.policy?.settlementWindowMs ?? runtimePolicy.settlementWindowMs;
|
|
69
|
-
const takeoverIdleMs = h.policy?.settlementWindowMs ?? runtimePolicy.settlementWindowMs;
|
|
70
|
-
const timeoutMs = h.policy?.subagentTimeoutMs ?? runtimePolicy.subagentTimeoutMs;
|
|
71
|
-
const pollIntervalMs = h.policy?.pollIntervalMs ?? runtimePolicy.pollIntervalMs;
|
|
72
|
-
|
|
73
|
-
async function pollLoop(
|
|
74
|
-
paneId: string,
|
|
75
|
-
cwd: string,
|
|
76
|
-
spawnedAt: number,
|
|
77
|
-
injectTs: number,
|
|
78
|
-
description: string,
|
|
79
|
-
requestId: string,
|
|
80
|
-
): Promise<void> {
|
|
81
|
-
void spawnedAt;
|
|
82
|
-
const startedAt = now();
|
|
83
|
-
let lastActivityAt = now();
|
|
84
|
-
const pollTrace = process.env.PI_HERDR_TRACE
|
|
85
|
-
? (msg: string) => { try { appendFileSync(process.env.PI_HERDR_TRACE!, `d98poll ${now()} ${paneId} ${msg}\n`); } catch { /* best-effort */ } }
|
|
86
|
-
: null;
|
|
87
|
-
try {
|
|
88
|
-
while (true) {
|
|
89
|
-
const entry = h.subs.get(paneId);
|
|
90
|
-
if (!entry || entry.status === 'settled') return;
|
|
91
|
-
|
|
92
|
-
if (entry.userTakeover) {
|
|
93
|
-
try {
|
|
94
|
-
const agents = await h.client.listAgents();
|
|
95
|
-
const agent = agents.find((a) => a.paneId === paneId);
|
|
96
|
-
const tick = planTakeoverTick({
|
|
97
|
-
currentStatus: agent?.status ?? null,
|
|
98
|
-
previousStatus: entry.lastAgentStatus,
|
|
99
|
-
idleStartedAt: entry.observationStartedAt,
|
|
100
|
-
now: now(),
|
|
101
|
-
idleMs: takeoverIdleMs,
|
|
102
|
-
});
|
|
103
|
-
if (tick.kind === 'start-idle') {
|
|
104
|
-
entry.observationStartedAt = now();
|
|
105
|
-
entry.lastAgentStatus = 'idle';
|
|
106
|
-
h.persistSubs();
|
|
107
|
-
} else if (tick.kind === 'return-control') {
|
|
108
|
-
entry.userTakeover = false;
|
|
109
|
-
entry.observationStartedAt = null;
|
|
110
|
-
entry.lastAgentStatus = null;
|
|
111
|
-
h.persistSubs();
|
|
112
|
-
} else if (tick.kind === 'hold' && tick.clearIdleTimer) {
|
|
113
|
-
entry.lastAgentStatus = tick.lastAgentStatus;
|
|
114
|
-
entry.observationStartedAt = null;
|
|
115
|
-
h.persistSubs();
|
|
116
|
-
}
|
|
117
|
-
} catch {
|
|
118
|
-
/* status probe failure must not stop the poller */
|
|
119
|
-
}
|
|
120
|
-
if (entry.userTakeover) {
|
|
121
|
-
await doSleep(TAKEOVER_RECHECK_MS);
|
|
122
|
-
continue;
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
let state: HerdrAgentState | null;
|
|
127
|
-
try {
|
|
128
|
-
state = await h.client.waitAgent(paneId, ['idle', 'done', 'blocked'], pollIntervalMs);
|
|
129
|
-
} catch {
|
|
130
|
-
state = null;
|
|
131
|
-
}
|
|
132
|
-
const gate = planBlockedGate(state, h.blockedGateNotified.has(paneId));
|
|
133
|
-
if (gate.kind === 'stay-blocked') {
|
|
134
|
-
if (gate.notify) {
|
|
135
|
-
h.blockedGateNotified.add(paneId);
|
|
136
|
-
const question = await h.session.readAskFlag(paneId);
|
|
137
|
-
try {
|
|
138
|
-
await h.injectNotice(buildBlockedGateNotice({ paneId, description, question }));
|
|
139
|
-
} catch {
|
|
140
|
-
/* list_agents can recover a missed notice */
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
continue;
|
|
144
|
-
}
|
|
145
|
-
if (gate.kind === 'clear-gate') h.blockedGateNotified.delete(paneId);
|
|
146
|
-
if (state === 'idle' || state === 'done') {
|
|
147
|
-
const s = await h.session.subSessionState(paneId, cwd, injectTs);
|
|
148
|
-
pollTrace?.(`state=${state} text=${s.text ? s.text.length : 'null'} pend=${s.pendingTool} act=${s.activity} obs=${String(entry.observationStartedAt ?? null)} takeover=${String(Boolean(entry.userTakeover))}`);
|
|
149
|
-
if (isSettlementCandidate(s)) {
|
|
150
|
-
const closing = s.text;
|
|
151
|
-
const obs = planObservationTick({
|
|
152
|
-
observationStartedAt: entry.observationStartedAt,
|
|
153
|
-
now: now(),
|
|
154
|
-
windowMs: observeWindowMs,
|
|
155
|
-
agentStatus: null,
|
|
156
|
-
machineInjectAgoMs: now() - (h.lastMachineInjectAt.get(paneId) ?? 0),
|
|
157
|
-
machineInjectGraceMs,
|
|
158
|
-
});
|
|
159
|
-
if (obs.kind === 'start-observation') {
|
|
160
|
-
entry.observationStartedAt = now();
|
|
161
|
-
entry.lastAgentStatus = 'idle';
|
|
162
|
-
h.persistSubs();
|
|
163
|
-
await doSleep(OBSERVATION_TICK_MS);
|
|
164
|
-
continue;
|
|
165
|
-
}
|
|
166
|
-
let agentStatus: string | null = null;
|
|
167
|
-
try {
|
|
168
|
-
const agents = await h.client.listAgents();
|
|
169
|
-
agentStatus = agents.find((a) => a.paneId === paneId)?.status ?? null;
|
|
170
|
-
} catch {
|
|
171
|
-
/* observation continues */
|
|
172
|
-
}
|
|
173
|
-
const obs2 = planObservationTick({
|
|
174
|
-
observationStartedAt: entry.observationStartedAt,
|
|
175
|
-
now: now(),
|
|
176
|
-
windowMs: observeWindowMs,
|
|
177
|
-
agentStatus,
|
|
178
|
-
machineInjectAgoMs: now() - (h.lastMachineInjectAt.get(paneId) ?? 0),
|
|
179
|
-
machineInjectGraceMs,
|
|
180
|
-
});
|
|
181
|
-
if (obs2.kind === 'user-takeover') {
|
|
182
|
-
entry.userTakeover = true;
|
|
183
|
-
entry.observationStartedAt = now();
|
|
184
|
-
entry.lastAgentStatus = 'working';
|
|
185
|
-
h.persistSubs();
|
|
186
|
-
continue;
|
|
187
|
-
}
|
|
188
|
-
if (obs2.kind === 'machine-inject-reset') {
|
|
189
|
-
entry.observationStartedAt = now();
|
|
190
|
-
h.persistSubs();
|
|
191
|
-
await doSleep(OBSERVATION_TICK_MS);
|
|
192
|
-
continue;
|
|
193
|
-
}
|
|
194
|
-
if (obs2.kind === 'wait') {
|
|
195
|
-
await doSleep(OBSERVATION_TICK_MS);
|
|
196
|
-
continue;
|
|
197
|
-
}
|
|
198
|
-
entry.observationStartedAt = null;
|
|
199
|
-
if (!entry.sessionFile) {
|
|
200
|
-
entry.sessionFile = applyReportedSessionFile(
|
|
201
|
-
entry.sessionFile,
|
|
202
|
-
await h.session.resolveSessionFile(paneId, cwd),
|
|
203
|
-
);
|
|
204
|
-
}
|
|
205
|
-
entry.status = 'consumed';
|
|
206
|
-
entry.consumedAt = now();
|
|
207
|
-
// A3: this path consumed the sub too - persist, or the session snapshot keeps it
|
|
208
|
-
// "running" and a restart replays a ghost subagent into list/settle-wake/zombie sweep.
|
|
209
|
-
h.persistSubs();
|
|
210
|
-
h.writeHistory(entry, { outcome: closing }, 'poll-settle');
|
|
211
|
-
const notes = h.reconcileOnSettlement(description, 'settled');
|
|
212
|
-
const statLine = await h.git.worktreeStatLine(entry);
|
|
213
|
-
const notice = h.withReconcileNotes(
|
|
214
|
-
buildSettlementNoticeText(`${paneId} (${description})`, closing, statLine),
|
|
215
|
-
notes,
|
|
216
|
-
);
|
|
217
|
-
if (h.claimSettleNotice(`${paneId}:${requestId}`)) {
|
|
218
|
-
try {
|
|
219
|
-
await h.injectNotice(notice);
|
|
220
|
-
} catch {
|
|
221
|
-
/* list_agents can recover */
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
return;
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
let alive = false;
|
|
228
|
-
try {
|
|
229
|
-
alive = (await h.client.listPanes()).some((p) => p.paneId === paneId);
|
|
230
|
-
} catch {
|
|
231
|
-
alive = true;
|
|
232
|
-
}
|
|
233
|
-
const vacuum = planVacuumTick({
|
|
234
|
-
waitState: state,
|
|
235
|
-
paneAlive: alive,
|
|
236
|
-
now: now(),
|
|
237
|
-
lastActivityAt,
|
|
238
|
-
timeoutMs,
|
|
239
|
-
});
|
|
240
|
-
if (vacuum.refreshActivity) lastActivityAt = now();
|
|
241
|
-
if (vacuum.action === 'pane-closed') {
|
|
242
|
-
entry.status = 'consumed';
|
|
243
|
-
entry.consumedAt = now();
|
|
244
|
-
h.persistSubs();
|
|
245
|
-
h.writeHistory(entry, { outcome: 'pane closed before settling' }, 'poll-pane-closed');
|
|
246
|
-
const notes = h.reconcileOnSettlement(description, 'failed');
|
|
247
|
-
const notice = h.withReconcileNotes(
|
|
248
|
-
formatPaneClosedNotice(paneId, description),
|
|
249
|
-
notes,
|
|
250
|
-
);
|
|
251
|
-
try {
|
|
252
|
-
await h.injectNotice(notice);
|
|
253
|
-
} catch { /* non-fatal */ }
|
|
254
|
-
return;
|
|
255
|
-
}
|
|
256
|
-
if (vacuum.action === 'timeout') {
|
|
257
|
-
entry.status = 'consumed';
|
|
258
|
-
entry.consumedAt = now();
|
|
259
|
-
h.persistSubs();
|
|
260
|
-
h.writeHistory(entry, { outcome: 'observation timeout' }, 'poll-timeout');
|
|
261
|
-
const notes = h.reconcileOnSettlement(description, 'failed');
|
|
262
|
-
const notice = h.withReconcileNotes(
|
|
263
|
-
formatObservationTimeoutNotice({
|
|
264
|
-
paneId,
|
|
265
|
-
description,
|
|
266
|
-
idleSeconds: Math.round((now() - lastActivityAt) / 1000),
|
|
267
|
-
startedAtIso: new Date(startedAt).toISOString(),
|
|
268
|
-
}),
|
|
269
|
-
notes,
|
|
270
|
-
);
|
|
271
|
-
try {
|
|
272
|
-
await h.injectNotice(notice);
|
|
273
|
-
} catch { /* non-fatal */ }
|
|
274
|
-
return;
|
|
275
|
-
}
|
|
276
|
-
}
|
|
277
|
-
} finally {
|
|
278
|
-
pollers.delete(paneId);
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
function startPoller(paneId: string, cwd: string, spawnedAt: number, injectTs: number, description: string, requestId: string): Promise<void> {
|
|
283
|
-
if (pollers.has(paneId)) return Promise.resolve();
|
|
284
|
-
pollers.add(paneId);
|
|
285
|
-
return (async () => {
|
|
286
|
-
try {
|
|
287
|
-
if (!subScopes.has(paneId)) {
|
|
288
|
-
const fiber = await mountSubagentScope(h.sessionRoot, paneId, {
|
|
289
|
-
onDispose: () => { pollers.delete(paneId); },
|
|
290
|
-
});
|
|
291
|
-
subScopes.set(paneId, fiber);
|
|
292
|
-
}
|
|
293
|
-
await pollLoop(paneId, cwd, spawnedAt, injectTs, description, requestId);
|
|
294
|
-
const fiber = subScopes.get(paneId);
|
|
295
|
-
subScopes.delete(paneId);
|
|
296
|
-
try { await fiber?.dispose(); } catch { /* already gone */ }
|
|
297
|
-
} catch (err) {
|
|
298
|
-
pollers.delete(paneId);
|
|
299
|
-
console.error(`pier: subagent poller ${paneId} crashed: ${(err as Error)?.message ?? err}`);
|
|
300
|
-
}
|
|
301
|
-
})();
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
return { startPoller, pollers };
|
|
305
|
-
}
|
package/src/subagent-port.ts
DELETED
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Subagent outbound port (composition root → plugin).
|
|
3
|
-
*
|
|
4
|
-
* Why: index used to pass a bag of independently-nullable callbacks that the
|
|
5
|
-
* plugin mutated field-by-field. A missing bag crashed at mount
|
|
6
|
-
* (`Cannot set properties of undefined (setting 'applyReplySession')`).
|
|
7
|
-
* Binding one `current` object is atomic: all methods or none.
|
|
8
|
-
*/
|
|
9
|
-
export interface RunningSub {
|
|
10
|
-
paneId: string;
|
|
11
|
-
description: string;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export interface SubagentPort {
|
|
15
|
-
applyReplySession(paneId: string, sessionFile: string | null): void;
|
|
16
|
-
reconcileOnReply(paneId: string): string[];
|
|
17
|
-
listRunningSubs(): RunningSub[];
|
|
18
|
-
settleStatLine(paneId: string): Promise<string | null>;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export interface SubagentPortBox {
|
|
22
|
-
current: SubagentPort | null;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export function emptySubagentPortBox(): SubagentPortBox {
|
|
26
|
-
return { current: null };
|
|
27
|
-
}
|
package/src/subagent-ready.ts
DELETED
|
@@ -1,89 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* A14: spawn readiness had a single 30 s ping window and reported failures as one bare line
|
|
3
|
-
* ("subagent pane wD:pJ pipe not ready within 30000ms"), even when the child process had already
|
|
4
|
-
* died with a stack trace on its screen. With 3–4 concurrent isolated workers (each running heavy
|
|
5
|
-
* commands) that window was hit repeatedly, and the diagnosis cost far more than the retry would.
|
|
6
|
-
*
|
|
7
|
-
* This module is the pure half: how long to wait, how to back off, when to give up early
|
|
8
|
-
* (the pane is gone — no point in waiting for a pipe that cannot appear) and how to word the
|
|
9
|
-
* failure so the model and the human can act on it.
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
/** Ping cadence: quick first probes, then exponential backoff capped at `READY_MAX_INTERVAL_MS`. */
|
|
13
|
-
export const READY_BASE_INTERVAL_MS = 500;
|
|
14
|
-
export const READY_MAX_INTERVAL_MS = 4000;
|
|
15
|
-
|
|
16
|
-
/** Wait this long between two samples of the child pane's liveness (pane.list + pane read). */
|
|
17
|
-
export const READY_LIVENESS_SAMPLE_MS = 2500;
|
|
18
|
-
|
|
19
|
-
export interface ReadyProbe {
|
|
20
|
-
/** The pane still exists in pane.list (unknown shells count as alive). */
|
|
21
|
-
readonly paneAlive: boolean;
|
|
22
|
-
/** Tail of the pane's screen, used only to explain a failure. */
|
|
23
|
-
readonly tail?: string | null;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export type ReadyAttemptPlan =
|
|
27
|
-
| { readonly kind: 'ready' }
|
|
28
|
-
| { readonly kind: 'retry'; readonly delayMs: number }
|
|
29
|
-
| { readonly kind: 'give-up'; readonly reason: 'pane-gone' | 'timeout' };
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* Decide one readiness attempt.
|
|
33
|
-
* `alive` is tri-state on purpose: an unavailable liveness probe (`null`) must not be read as death,
|
|
34
|
-
* otherwise a slow herdr socket would fail spawns that are merely slow to boot.
|
|
35
|
-
*/
|
|
36
|
-
export function planReadyAttempt(opts: {
|
|
37
|
-
elapsedMs: number;
|
|
38
|
-
attempt: number;
|
|
39
|
-
timeoutMs: number;
|
|
40
|
-
alive: boolean | null;
|
|
41
|
-
ready: boolean;
|
|
42
|
-
}): ReadyAttemptPlan {
|
|
43
|
-
if (opts.ready) return { kind: 'ready' };
|
|
44
|
-
if (opts.alive === false) return { kind: 'give-up', reason: 'pane-gone' };
|
|
45
|
-
if (opts.elapsedMs >= opts.timeoutMs) return { kind: 'give-up', reason: 'timeout' };
|
|
46
|
-
return { kind: 'retry', delayMs: readyBackoffMs(opts.attempt) };
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/** Exponential backoff with a cap: 500ms, 1s, 2s, 4s, 4s, … */
|
|
50
|
-
export function readyBackoffMs(attempt: number, baseMs = READY_BASE_INTERVAL_MS, capMs = READY_MAX_INTERVAL_MS): number {
|
|
51
|
-
const n = Math.max(0, Math.floor(attempt));
|
|
52
|
-
return Math.min(capMs, baseMs * 2 ** n);
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/** How many characters of pane tail to attach to a failure (enough for a stack trace head). */
|
|
56
|
-
export const READY_TAIL_CHARS = 1200;
|
|
57
|
-
|
|
58
|
-
export interface ReadyFailure {
|
|
59
|
-
readonly paneId: string;
|
|
60
|
-
readonly reason: 'pane-gone' | 'timeout';
|
|
61
|
-
readonly elapsedMs: number;
|
|
62
|
-
readonly timeoutMs: number;
|
|
63
|
-
/** Some agent states the wait observed (working/idle/…), for the "agent never reported" case. */
|
|
64
|
-
readonly lastStatus?: string | null;
|
|
65
|
-
readonly tail?: string | null;
|
|
66
|
-
/** True when the child process exited (pane gone) rather than never starting to report. */
|
|
67
|
-
readonly hint?: string | null;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* Failure text for the model. The three cases it must distinguish:
|
|
72
|
-
* 1. the pane died (usually the child crashed — the tail carries the reason, cf. A13's stack trace);
|
|
73
|
-
* 2. the pane is alive but never opened its pipe (extension failed to load, wrong pane env);
|
|
74
|
-
* 3. the pane is alive and its agent is working, just slower than the timeout (retry is safe).
|
|
75
|
-
*/
|
|
76
|
-
export function readyFailureText(failure: ReadyFailure): string {
|
|
77
|
-
const seconds = Math.round(failure.elapsedMs / 1000);
|
|
78
|
-
const head = failure.reason === 'pane-gone'
|
|
79
|
-
? `subagent pane ${failure.paneId} exited before its pipe became ready (waited ${seconds}s)`
|
|
80
|
-
: `subagent pane ${failure.paneId} pipe not ready within ${seconds}s (limit ${Math.round(failure.timeoutMs / 1000)}s)`;
|
|
81
|
-
const why = failure.reason === 'pane-gone'
|
|
82
|
-
? 'The child process is gone — its last output below usually carries the reason.'
|
|
83
|
-
: failure.lastStatus === 'working'
|
|
84
|
-
? 'The pane is alive and working, so the prompt may simply be slow to boot; retrying the same call is safe.'
|
|
85
|
-
: 'The pane is alive but never registered its pipe — the child pi process may still be starting, or it failed to load the pier extension.';
|
|
86
|
-
const tail = failure.tail ? `\nlast output of ${failure.paneId}:\n${failure.tail}` : '';
|
|
87
|
-
const hint = failure.hint ? `\n${failure.hint}` : '';
|
|
88
|
-
return `${head}\n${why}${hint}${tail}`;
|
|
89
|
-
}
|
package/src/subagent-registry.ts
DELETED
|
@@ -1,70 +0,0 @@
|
|
|
1
|
-
import type { HerdrClientLike } from './herdr-client.ts';
|
|
2
|
-
import { foldSubsRegistry, makeRegistry, SUBS_CUSTOM_TYPE, type SubEntry } from './subagent-core.ts';
|
|
3
|
-
import { swallow } from './swallow.ts';
|
|
4
|
-
|
|
5
|
-
export interface SubagentRegistryHost {
|
|
6
|
-
pi: { appendEntry?: (customType: string, data: unknown) => void };
|
|
7
|
-
client: HerdrClientLike;
|
|
8
|
-
subs: Map<string, SubEntry>;
|
|
9
|
-
writeHistory: (entry: SubEntry, patch?: { status?: SubEntry['status']; closedAt?: number }, via?: string) => void;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
export interface SubagentRegistry {
|
|
13
|
-
/** Append the registry snapshot to the session branch; duplicate snapshots are skipped (O1). */
|
|
14
|
-
persist(): void;
|
|
15
|
-
/** Replay `pi-herdr.subs` entries from the session branch into the live map. */
|
|
16
|
-
rebuild(eventCtx: unknown): void;
|
|
17
|
-
/** B6: close running rows whose pane herdr no longer lists. */
|
|
18
|
-
sweepZombieRunning(): Promise<void>;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
/** Owns durable registry projection and startup recovery, separate from tool actions. */
|
|
22
|
-
export function createSubagentRegistry(host: SubagentRegistryHost): SubagentRegistry {
|
|
23
|
-
let lastSnapshot = '';
|
|
24
|
-
|
|
25
|
-
function persist(): void {
|
|
26
|
-
try {
|
|
27
|
-
const registry = makeRegistry([...host.subs.values()]);
|
|
28
|
-
const snapshot = JSON.stringify(registry);
|
|
29
|
-
if (snapshot === lastSnapshot) return;
|
|
30
|
-
lastSnapshot = snapshot;
|
|
31
|
-
host.pi.appendEntry?.(SUBS_CUSTOM_TYPE, registry);
|
|
32
|
-
} catch (err) {
|
|
33
|
-
// Best-effort: session logging must not break delegation, but a ghost running subagent is the
|
|
34
|
-
// symptom when this fails silently, so keep the reason queryable (B9).
|
|
35
|
-
swallow('subagent.persist-subs', err);
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function rebuild(eventCtx: unknown): void {
|
|
40
|
-
try {
|
|
41
|
-
const entries = (eventCtx as { sessionManager?: { getBranch?: () => readonly unknown[] } })
|
|
42
|
-
?.sessionManager?.getBranch?.() ?? [];
|
|
43
|
-
const registry = foldSubsRegistry(entries as Parameters<typeof foldSubsRegistry>[0]);
|
|
44
|
-
for (const entry of registry.subs) host.subs.set(entry.paneId, entry);
|
|
45
|
-
lastSnapshot = ''; // Branch replay replaced live state; force the next persist.
|
|
46
|
-
} catch {
|
|
47
|
-
/* Registry recovery failure must not block the live session. */
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
async function sweepZombieRunning(): Promise<void> {
|
|
52
|
-
if (!host.client.available || host.subs.size === 0) return;
|
|
53
|
-
let livePaneIds: ReadonlySet<string>;
|
|
54
|
-
try {
|
|
55
|
-
livePaneIds = new Set((await host.client.listPanes()).map((pane) => pane.paneId));
|
|
56
|
-
} catch {
|
|
57
|
-
return; // Do not close agents when the liveness lookup itself failed.
|
|
58
|
-
}
|
|
59
|
-
let changed = false;
|
|
60
|
-
for (const [paneId, entry] of host.subs) {
|
|
61
|
-
if (entry.status !== 'running' || livePaneIds.has(paneId)) continue;
|
|
62
|
-
entry.status = 'closed';
|
|
63
|
-
host.writeHistory(entry, { status: 'closed', closedAt: Date.now() }, 'zombie-sweep');
|
|
64
|
-
changed = true;
|
|
65
|
-
}
|
|
66
|
-
if (changed) persist();
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
return { persist, rebuild, sweepZombieRunning };
|
|
70
|
-
}
|
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Subagent task-id lookup rules.
|
|
3
|
-
*
|
|
4
|
-
* Kept separate from the Cordis plugin so prefix resolution remains usable without mounting the
|
|
5
|
-
* master tree and can be verified independently from pane lifecycle code.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
export type TaskIdResolutionResult =
|
|
9
|
-
| { kind: 'resolved'; taskId: string }
|
|
10
|
-
| { kind: 'ambiguous'; query: string; candidates: string[] }
|
|
11
|
-
| { kind: 'too_short'; query: string }
|
|
12
|
-
| { kind: 'not_found'; query: string };
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* Resolve a full or short task ID against known candidates.
|
|
16
|
-
*
|
|
17
|
-
* Exact matches are accepted regardless of length. Prefix matching requires four characters and
|
|
18
|
-
* rejects ambiguity with a sorted candidate list.
|
|
19
|
-
*/
|
|
20
|
-
export function resolveTaskIdPrefix(
|
|
21
|
-
query: string,
|
|
22
|
-
candidates: Iterable<string>,
|
|
23
|
-
): TaskIdResolutionResult {
|
|
24
|
-
const trimmed = query.trim();
|
|
25
|
-
if (!trimmed) return { kind: 'not_found', query: trimmed };
|
|
26
|
-
|
|
27
|
-
const unique = Array.from(new Set(candidates));
|
|
28
|
-
const exact = unique.find((candidate) => candidate === trimmed);
|
|
29
|
-
if (exact) return { kind: 'resolved', taskId: exact };
|
|
30
|
-
|
|
31
|
-
if (trimmed.length < 4) return { kind: 'too_short', query: trimmed };
|
|
32
|
-
|
|
33
|
-
const lower = trimmed.toLowerCase();
|
|
34
|
-
const matches = unique.filter((candidate) => candidate.toLowerCase().startsWith(lower));
|
|
35
|
-
if (matches.length === 1) return { kind: 'resolved', taskId: matches[0]! };
|
|
36
|
-
if (matches.length > 1) {
|
|
37
|
-
matches.sort();
|
|
38
|
-
return { kind: 'ambiguous', query: trimmed, candidates: matches };
|
|
39
|
-
}
|
|
40
|
-
return { kind: 'not_found', query: trimmed };
|
|
41
|
-
}
|
package/src/subagent-scope.ts
DELETED
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* One Cordis fiber per subagent in the master process.
|
|
3
|
-
* pipe / poller / history cleanup uses ctx.effect (LIFO dispose).
|
|
4
|
-
* C3: workers must not import this module.
|
|
5
|
-
*/
|
|
6
|
-
import { Context } from '@deepseek-ai/cordis';
|
|
7
|
-
|
|
8
|
-
export interface ScopeHooks {
|
|
9
|
-
onDispose?: () => void;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
export function createSessionRoot(hooks: ScopeHooks = {}): Context {
|
|
13
|
-
const root = new Context();
|
|
14
|
-
if (hooks.onDispose) {
|
|
15
|
-
root.effect(() => () => { hooks.onDispose?.(); }, 'session-root');
|
|
16
|
-
}
|
|
17
|
-
return root;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export async function mountSubagentScope(
|
|
21
|
-
root: Context,
|
|
22
|
-
paneId: string,
|
|
23
|
-
hooks: ScopeHooks = {},
|
|
24
|
-
) {
|
|
25
|
-
// Why no second argument: the pinned cordis version takes only the plugin object here; the pane id
|
|
26
|
-
// already identifies the scope through the plugin name and the effect label.
|
|
27
|
-
return root.plugin({
|
|
28
|
-
name: `subagent:${paneId}`,
|
|
29
|
-
apply(ctx: Context) {
|
|
30
|
-
ctx.effect(() => () => { hooks.onDispose?.(); }, `subagent:${paneId}`);
|
|
31
|
-
},
|
|
32
|
-
});
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export async function disposeSessionRoot(root: Context): Promise<void> {
|
|
36
|
-
try {
|
|
37
|
-
await root.fiber.dispose();
|
|
38
|
-
} catch {
|
|
39
|
-
/* second dispose is a no-op */
|
|
40
|
-
}
|
|
41
|
-
}
|