piher 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +60 -2
- package/package.json +58 -4
- package/schemas/role-manifest.schema.json +62 -0
- package/skills/piher-tools/SKILL.md +212 -0
- package/src/ansi-text.ts +143 -0
- package/src/bg-job-core.ts +412 -0
- package/src/bootstrap.ts +102 -0
- package/src/config-catalog-core.ts +200 -0
- package/src/config-command.ts +324 -0
- package/src/cordis-services.ts +38 -0
- package/src/core-client.ts +715 -0
- package/src/delegation-core.ts +94 -0
- package/src/gc-core.ts +150 -0
- package/src/guards.ts +6 -0
- package/src/hc-templates.ts +134 -0
- package/src/index-locks.ts +118 -0
- package/src/index-master.ts +92 -0
- package/src/index-notices.ts +79 -0
- package/src/index-pipe.ts +71 -0
- package/src/index-roles.ts +265 -0
- package/src/index-runtime.ts +23 -0
- package/src/index.ts +460 -0
- package/src/ledger.ts +61 -0
- package/src/lock-core.ts +200 -0
- package/src/manifest-compose.ts +103 -0
- package/src/options.ts +78 -0
- package/src/pi-surface.ts +140 -0
- package/src/pipe-channel.ts +291 -0
- package/src/platform-paths.ts +37 -0
- package/src/plugins/bg-job.ts +560 -0
- package/src/plugins/subagent.ts +1244 -0
- package/src/prompt-store.ts +50 -0
- package/src/renderers.ts +195 -0
- package/src/role-catalog.ts +99 -0
- package/src/role-loader.ts +196 -0
- package/src/role-manifest.ts +163 -0
- package/src/role-state.ts +119 -0
- package/src/roles/master.json +23 -0
- package/src/roles/worker-default.json +23 -0
- package/src/runtime-policy.ts +45 -0
- package/src/session-state.ts +19 -0
- package/src/session-tail.ts +276 -0
- package/src/settle-wake-core.ts +73 -0
- package/src/settlement-card.ts +96 -0
- package/src/storage-layout.ts +92 -0
- package/src/subagent-core.ts +543 -0
- package/src/subagent-gc.ts +312 -0
- package/src/subagent-poller.ts +484 -0
- package/src/subagent-session.ts +548 -0
- package/src/subagent-spawn.ts +695 -0
- package/src/swallow.ts +59 -0
- package/src/task-row.ts +257 -0
- package/src/task-store.ts +173 -0
- package/src/tool-error.ts +19 -0
- package/src/tool-gate.ts +99 -0
- package/src/tree-view.ts +58 -0
- package/src/vocab.ts +41 -0
- package/src/worker-marker.ts +65 -0
- package/src/worktree-io.ts +177 -0
- package/src/worktree.ts +280 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* piher extension entry point and composition root.
|
|
3
|
+
*
|
|
4
|
+
* Layout: index-runtime.ts picks the process mode (worker / bare pi / herdr master);
|
|
5
|
+
* index-roles.ts the role runtime, index-pipe.ts the pipe protocol handler, index-notices.ts the
|
|
6
|
+
* notice buffer, index-locks.ts write locks;
|
|
7
|
+
* index-master.ts is the dynamic master mount.
|
|
8
|
+
*
|
|
9
|
+
* pi contract: onUpdate must have AgentToolResult shape (a string crashes the TUI); tool-result
|
|
10
|
+
* details persist in session JSONL and getBranch() replay implements branch rollback.
|
|
11
|
+
*/
|
|
12
|
+
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
13
|
+
import {
|
|
14
|
+
closePipeServer, ownsPipe, pipeNameFor, pipePathFor, pipeRequest, releasePipe, startPipeServer, takeOverPipe,
|
|
15
|
+
} from './pipe-channel.ts';
|
|
16
|
+
import { installRenderers } from './renderers.ts';
|
|
17
|
+
import { SETTLEMENT_CUSTOM_TYPE, sendSettlementNotice } from './settlement-card.ts';
|
|
18
|
+
import { createCoreClient } from './core-client.ts';
|
|
19
|
+
import { bareSessionId, deriveSubSessionState, readSessionFile, resolveSessionFileValue } from './session-tail.ts';
|
|
20
|
+
import { fileURLToPath } from 'node:url';
|
|
21
|
+
import { join } from 'node:path';
|
|
22
|
+
import { existsSync } from 'node:fs';
|
|
23
|
+
import { platformPaths } from './platform-paths.ts';
|
|
24
|
+
import { WRITE_LOCK_ENV } from './lock-core.ts';
|
|
25
|
+
import { ABORT_STOP_REASON, planSettleWake, shouldPushSettleReply } from './settle-wake-core.ts';
|
|
26
|
+
import { parseRuntimeManifest } from './tool-gate.ts';
|
|
27
|
+
import { planIndexMode } from './index-runtime.ts';
|
|
28
|
+
import { emptySubagentPortBox, sleepWhileSubsRunningNote } from './subagent-core.ts';
|
|
29
|
+
import { writeWorkerMarker, readWorkerMarker } from './worker-marker.ts';
|
|
30
|
+
import {
|
|
31
|
+
DELEGATION_CUSTOM_TYPE,
|
|
32
|
+
buildDelegationSection,
|
|
33
|
+
foldDelegation,
|
|
34
|
+
parseDelegationContext,
|
|
35
|
+
type DelegationContext,
|
|
36
|
+
} from './delegation-core.ts';
|
|
37
|
+
import { widgetLines } from './tree-view.ts';
|
|
38
|
+
import { createNoticeBuffer } from './index-notices.ts';
|
|
39
|
+
import { handlePipeRequest, type MachineRequest } from './index-pipe.ts';
|
|
40
|
+
import { installWriteLocks } from './index-locks.ts';
|
|
41
|
+
import { installConfigCommand } from './config-command.ts';
|
|
42
|
+
import { composeMasterRuntime, createRoleRuntime } from './index-roles.ts';
|
|
43
|
+
|
|
44
|
+
/** How often a worker re-checks that its own socket path still exists (see watchPipePath). */
|
|
45
|
+
const PIPE_WATCHDOG_MS = 10_000;
|
|
46
|
+
|
|
47
|
+
function agentSessionsDir(): string {
|
|
48
|
+
return process.env.PI_CODING_AGENT_DIR ? join(process.env.PI_CODING_AGENT_DIR, 'sessions') : platformPaths.sessionsDir;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export default async function (pi: ExtensionAPI) {
|
|
52
|
+
const mode = planIndexMode();
|
|
53
|
+
const isSubagent = mode.isSubagent;
|
|
54
|
+
const roleBase = process.env.PIHER_ROLE_BASE || process.cwd();
|
|
55
|
+
|
|
56
|
+
let sessionId: string = process.env.PI_SESSION_FILE ?? process.env.PI_SESSION_ID ?? '';
|
|
57
|
+
/** The transcript path pi itself reports for this session, cached at session_start. The settle push
|
|
58
|
+
* must read it, and re-deriving it from the bare id failed in a live worker (`hasFile=false`), which
|
|
59
|
+
* silently disabled the instant reply and left every settlement to the master's poller. */
|
|
60
|
+
let ownSessionFile: string | null = null;
|
|
61
|
+
const { client, env } = createCoreClient();
|
|
62
|
+
/** RFC rfc-delegation-identity §2.1: resolved env → marker → branch fold; worker panes only. */
|
|
63
|
+
let delegation: DelegationContext | null = parseDelegationContext(process.env.PIHER_DELEGATION);
|
|
64
|
+
/** The transcript path core reports, cached at session start: piher's per-session state root
|
|
65
|
+
* (`piher/<id>/`) hangs off its directory, and re-deriving it by scanning the session dir fails
|
|
66
|
+
* before the file exists — which is exactly when `session_start` fires. */
|
|
67
|
+
let ownSessionPath: string | null = null;
|
|
68
|
+
const initialManifest =
|
|
69
|
+
parseRuntimeManifest(process.env.PIHER_ROLE_MANIFEST) ??
|
|
70
|
+
(mode.composeMaster ? composeMasterRuntime() : null);
|
|
71
|
+
|
|
72
|
+
const roles = createRoleRuntime({ pi, roleBase, initialManifest, isSubagent });
|
|
73
|
+
|
|
74
|
+
const rendererTypes = installRenderers(pi);
|
|
75
|
+
/** A card is only worth sending when this host could register its renderer. */
|
|
76
|
+
const cardsRendered = rendererTypes.includes(SETTLEMENT_CUSTOM_TYPE);
|
|
77
|
+
|
|
78
|
+
let agentActive = false;
|
|
79
|
+
let lastStopReason: string | null = null;
|
|
80
|
+
let latestCtx: { abort?: () => void } | null = null;
|
|
81
|
+
const subagentPort = emptySubagentPortBox();
|
|
82
|
+
let d96NoticeKey: string | null = null;
|
|
83
|
+
let d96NoticeAt = 0;
|
|
84
|
+
|
|
85
|
+
/** Session identity: sessionManager is authoritative (env may be unset in print/RPC mode). */
|
|
86
|
+
function reportedSessionFile(ctx: unknown): string | null {
|
|
87
|
+
try {
|
|
88
|
+
const sm = (ctx as { sessionManager?: { getSessionFile?: () => string | undefined } }).sessionManager;
|
|
89
|
+
return sm?.getSessionFile?.() ?? null;
|
|
90
|
+
} catch {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function resolveSessionId(ctx: unknown): string {
|
|
96
|
+
try {
|
|
97
|
+
const sm = (ctx as {
|
|
98
|
+
sessionManager?: { getSessionFile?: () => string | undefined; getSessionId?: () => string };
|
|
99
|
+
}).sessionManager;
|
|
100
|
+
return bareSessionId(
|
|
101
|
+
sm?.getSessionFile?.() ?? sm?.getSessionId?.() ?? process.env.PI_SESSION_FILE ?? process.env.PI_SESSION_ID ?? '',
|
|
102
|
+
);
|
|
103
|
+
} catch {
|
|
104
|
+
return bareSessionId(process.env.PI_SESSION_FILE ?? '');
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The resident widget carries one line per running subagent, and is removed when none runs. */
|
|
109
|
+
function renderWorkerWidget(ctx: unknown): void {
|
|
110
|
+
if (!mode.composeMaster) return;
|
|
111
|
+
const ui = (ctx as { ui?: { setWidget?: (id: string, lines: string[] | undefined) => void } } | null | undefined)?.ui;
|
|
112
|
+
const lines = widgetLines(subagentPort.current?.listRunningSubs() ?? [], Date.now());
|
|
113
|
+
try {
|
|
114
|
+
// pi keeps an empty array as an empty widget (a stray blank line above the editor); undefined
|
|
115
|
+
// is the documented way to remove one.
|
|
116
|
+
ui?.setWidget?.('piher-agents', lines.length > 0 ? lines : undefined);
|
|
117
|
+
} catch {
|
|
118
|
+
/* Older pi builds may omit widget support. */
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** The widget is a view of the tasks, so a turn boundary or a settlement repaints it. */
|
|
123
|
+
function repaintAgentViews(ctx: unknown): void {
|
|
124
|
+
renderWorkerWidget(ctx);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Tool names by call id: the end event may omit the name the tools ledger records. */
|
|
128
|
+
const runningTools = new Map<string, string>();
|
|
129
|
+
pi.on('tool_execution_start', async (event: { toolCallId?: string; toolName?: string }) => {
|
|
130
|
+
if (typeof event?.toolCallId === 'string' && typeof event?.toolName === 'string') runningTools.set(event.toolCallId, event.toolName);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
/* ── Masters sleep-polled background workers (sleep 120…840) instead of trusting the settlement notice. ── */
|
|
134
|
+
if (!isSubagent) {
|
|
135
|
+
pi.on('tool_result', async (event: unknown) => {
|
|
136
|
+
const rec = (event ?? {}) as { toolName?: unknown; input?: { command?: unknown }; content?: unknown };
|
|
137
|
+
if (rec.toolName !== 'bash' || typeof rec.input?.command !== 'string') return;
|
|
138
|
+
const note = sleepWhileSubsRunningNote(rec.input.command, subagentPort.current?.listRunningSubs() ?? []);
|
|
139
|
+
if (!note) return;
|
|
140
|
+
const content = Array.isArray(rec.content) ? rec.content as Array<{ type: 'text'; text: string }> : [];
|
|
141
|
+
return { content: [...content, { type: 'text' as const, text: note }] };
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (env) installWriteLocks(pi, { client, env, hard: process.env[WRITE_LOCK_ENV] === '1' });
|
|
146
|
+
|
|
147
|
+
/** pi's sendUserMessage is fire-and-forget (void); callers of this wrapper still get a promise. */
|
|
148
|
+
const sendUserMessageAs = (content: string, mode: 'steer' | 'followUp'): Promise<void> => {
|
|
149
|
+
(pi as unknown as { sendUserMessage?: (content: string, opts?: { deliverAs?: string; triggerTurn?: boolean }) => void })
|
|
150
|
+
.sendUserMessage?.(content, { deliverAs: mode, triggerTurn: true });
|
|
151
|
+
return Promise.resolve();
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const notices = createNoticeBuffer({
|
|
155
|
+
// pi core rejects prompts while the agent is mid-turn (and drops fire-and-forget sends), so a
|
|
156
|
+
// settlement landing in that window must stay buffered until the run settles — otherwise the
|
|
157
|
+
// result is lost with nobody left to retry it.
|
|
158
|
+
isBusy: () => agentActive || lastStopReason === ABORT_STOP_REASON,
|
|
159
|
+
send: (content, mode, card) => sendSettlementNotice(pi, {
|
|
160
|
+
content,
|
|
161
|
+
mode,
|
|
162
|
+
...(card ? { card } : {}),
|
|
163
|
+
cardsRendered,
|
|
164
|
+
}),
|
|
165
|
+
});
|
|
166
|
+
const deliverNotice = notices.deliverNotice;
|
|
167
|
+
|
|
168
|
+
/* ── pipe channel: this pane's NDJSON server + the settle fast-path push (D48/D49/D50) ── */
|
|
169
|
+
const pipeServerBox: { current: ReturnType<typeof startPipeServer> | null } = { current: null };
|
|
170
|
+
let pipeWatchdog: ReturnType<typeof setInterval> | null = null;
|
|
171
|
+
/** This instance's ownership generation for the pane's channel (see takeOverPipe). */
|
|
172
|
+
let pipeGeneration = 0;
|
|
173
|
+
let pendingMachineRequest: MachineRequest | null = null;
|
|
174
|
+
/** Dedup latch for settle notices: one per paneId+request id per round. */
|
|
175
|
+
const settleNoticeLatch = new Set<string>();
|
|
176
|
+
function claimSettleNotice(key: string): boolean {
|
|
177
|
+
if (settleNoticeLatch.has(key)) return false;
|
|
178
|
+
settleNoticeLatch.add(key);
|
|
179
|
+
return true;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function startPipeServerFor(cwd: string): Promise<void> {
|
|
183
|
+
const paneId = env?.terminalId ?? '';
|
|
184
|
+
if (!paneId) {
|
|
185
|
+
// No address means no channel: every follow-up to this pane is undeliverable, which is worth
|
|
186
|
+
// saying out loud rather than discovering as a 90 s timeout on the other side.
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
const name = pipeNameFor(cwd, paneId);
|
|
190
|
+
const path = pipePathFor(name);
|
|
191
|
+
// Exactly one server per pane, whoever else is in this process. Taking ownership closes the
|
|
192
|
+
// previous holder first — our own server on a rebind, or a second copy of this extension loaded in
|
|
193
|
+
// the same pane (an installed package plus the spawner's `-e`). Binding before that close would let
|
|
194
|
+
// libuv delete the new entry; leaving the previous holder alive would let a stale ctx answer.
|
|
195
|
+
const generation = await takeOverPipe(paneId, async () => {
|
|
196
|
+
const mine = pipeServerBox.current;
|
|
197
|
+
pipeServerBox.current = null;
|
|
198
|
+
if (pipeWatchdog) clearInterval(pipeWatchdog);
|
|
199
|
+
pipeWatchdog = null;
|
|
200
|
+
if (mine) await closePipeServer(mine);
|
|
201
|
+
});
|
|
202
|
+
pipeGeneration = generation;
|
|
203
|
+
/** A bind failure is silent by construction (net.Server reports it on 'error'), so it is the one
|
|
204
|
+
* failure that used to leave a live worker unreachable with nothing in any log. */
|
|
205
|
+
const bindFailed = (err: Error): void => {
|
|
206
|
+
const code = (err as NodeJS.ErrnoException)?.code ?? null;
|
|
207
|
+
void deliverNotice(
|
|
208
|
+
`piher: this pane cannot receive follow-up messages — its pipe ${path} failed to bind`
|
|
209
|
+
+ `${code ? ` (${code})` : ''}. A master that sends to it will report the channel as dead.`,
|
|
210
|
+
paneId,
|
|
211
|
+
null,
|
|
212
|
+
);
|
|
213
|
+
};
|
|
214
|
+
try {
|
|
215
|
+
const server = startPipeServer(name, async (req) => handlePipeRequest(req, {
|
|
216
|
+
paneId,
|
|
217
|
+
port: subagentPort,
|
|
218
|
+
claimSettleNotice,
|
|
219
|
+
deliverNotice,
|
|
220
|
+
sendUserMessageIn: (content) => sendUserMessageAs(content, 'followUp'),
|
|
221
|
+
sendUserMessageAs,
|
|
222
|
+
abort: () => { latestCtx?.abort?.(); },
|
|
223
|
+
setPendingMachineRequest: (next) => { pendingMachineRequest = next; },
|
|
224
|
+
}), bindFailed);
|
|
225
|
+
pipeServerBox.current = server;
|
|
226
|
+
server.once('listening', () => {
|
|
227
|
+
watchPipePath(cwd, path, paneId, generation);
|
|
228
|
+
});
|
|
229
|
+
} catch (err) {
|
|
230
|
+
// A synchronous throw (path too long, permission) is the same outcome as a failed bind.
|
|
231
|
+
bindFailed(err as Error);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** The path is a filesystem entry that a live listener does not own: anything that unlinks it (a
|
|
236
|
+
* stale pre-bind unlink from another instance, a tmp cleaner) leaves the socket open while every
|
|
237
|
+
* new connect() gets ENOENT — a worker that keeps working and cannot be reached. Nothing in the
|
|
238
|
+
* process notices, so this re-checks and rebinds. */
|
|
239
|
+
function watchPipePath(cwd: string, path: string, paneId: string, generation: number): void {
|
|
240
|
+
if (pipeWatchdog) clearInterval(pipeWatchdog);
|
|
241
|
+
pipeWatchdog = setInterval(() => {
|
|
242
|
+
// A channel someone else took over is not ours to heal, and healing it would start a tug of war.
|
|
243
|
+
if (!ownsPipe(paneId, generation)) return;
|
|
244
|
+
if (existsSync(path)) return;
|
|
245
|
+
void startPipeServerFor(cwd);
|
|
246
|
+
}, PIPE_WATCHDOG_MS);
|
|
247
|
+
pipeWatchdog.unref?.();
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Returns whether a settlement reply actually went out: a worker whose report never reached the
|
|
251
|
+
* master must not exit as if it had (S54 — the push comes first, the shutdown after). */
|
|
252
|
+
async function pushSettleReply(aborted = false): Promise<boolean> {
|
|
253
|
+
const req = pendingMachineRequest;
|
|
254
|
+
if (!req || !req.from) {
|
|
255
|
+
return false;
|
|
256
|
+
}
|
|
257
|
+
// An aborted settle is not a settlement: the closing message is still coming, and answering now
|
|
258
|
+
// would burn the one-shot request + the parent's claim latch on a null-text reply ("left no
|
|
259
|
+
// closing message") — the real closing message then never gets delivered. Keep the request armed.
|
|
260
|
+
if (!shouldPushSettleReply({ aborted, lastStopReason })) {
|
|
261
|
+
return false;
|
|
262
|
+
}
|
|
263
|
+
pendingMachineRequest = null;
|
|
264
|
+
try {
|
|
265
|
+
const ownFile = ownSessionFile
|
|
266
|
+
?? (sessionId ? resolveSessionFileValue(process.cwd(), agentSessionsDir(), sessionId) : null);
|
|
267
|
+
let text: string | null = null;
|
|
268
|
+
if (ownFile) {
|
|
269
|
+
const entries = readSessionFile(ownFile);
|
|
270
|
+
// Same derivation the master's poller uses, so the two paths can never disagree about whether
|
|
271
|
+
// the worker said anything: a turn that ended by token cap or abort still has words.
|
|
272
|
+
if (entries) {
|
|
273
|
+
const state = deriveSubSessionState(entries, req.sinceTs);
|
|
274
|
+
// A turn that ended with nothing to say but a reason — a refused tool, an error — must say
|
|
275
|
+
// it: staying silent left the master waiting the whole vacuum budget for a report that was
|
|
276
|
+
// never coming. A worker still thinking has no reason, and its push stays withheld.
|
|
277
|
+
text = state.text ?? state.why ?? null;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
if (text === null) {
|
|
281
|
+
return false;
|
|
282
|
+
}
|
|
283
|
+
await pipeRequest(req.from, {
|
|
284
|
+
type: 'reply',
|
|
285
|
+
id: req.id,
|
|
286
|
+
paneId: env?.terminalId ?? '',
|
|
287
|
+
text,
|
|
288
|
+
// Push the resolved PATH when readable; fall back to the bare id for the master-side mapping.
|
|
289
|
+
sessionFile: ownFile ?? (sessionId || null),
|
|
290
|
+
}, 5000);
|
|
291
|
+
} catch (err) {
|
|
292
|
+
/* The requester's pollLoop is the fallback; surface the dead letter for triage. */
|
|
293
|
+
console.error(`piher: settle reply push to ${req.from} failed: ${(err as Error)?.message ?? err}`);
|
|
294
|
+
return false;
|
|
295
|
+
}
|
|
296
|
+
return true;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
pi.on('session_start', async (event, ctx) => {
|
|
300
|
+
sessionId = resolveSessionId(ctx);
|
|
301
|
+
ownSessionPath = reportedSessionFile(ctx);
|
|
302
|
+
latestCtx = ctx as { abort?: () => void } | null;
|
|
303
|
+
// On resume pi restores widget state from the session; calling setWidget again
|
|
304
|
+
// breaks the TUI '/' command-panel route (v1.3 M9).
|
|
305
|
+
if ((event as { reason?: string } | undefined)?.reason !== 'resume') renderWorkerWidget(ctx);
|
|
306
|
+
roles.syncFromBranch(ctx);
|
|
307
|
+
if (isSubagent) {
|
|
308
|
+
// The worker marker keeps the delegation identity next to the session file, so a /resume of
|
|
309
|
+
// this session (which carries no spawn env) still knows who delegated it.
|
|
310
|
+
const sm = typeof ctx === 'object' && ctx !== null && 'sessionManager' in ctx ? ctx.sessionManager : undefined;
|
|
311
|
+
const sessionFile = (typeof sm?.getSessionFile === 'function' ? sm.getSessionFile() : undefined)
|
|
312
|
+
?? process.env.PI_SESSION_FILE ?? null;
|
|
313
|
+
if (sessionFile) ownSessionFile = resolveSessionFileValue(process.cwd(), agentSessionsDir(), sessionFile);
|
|
314
|
+
// RFC rfc-delegation-identity §2.1: env (spawn) > marker > branch fold (/resume).
|
|
315
|
+
if (!delegation) delegation = readWorkerMarker(sessionFile)?.delegation ?? null;
|
|
316
|
+
const branchEntries = (ctx as { sessionManager?: { getBranch?: () => readonly unknown[] } })
|
|
317
|
+
?.sessionManager?.getBranch?.() ?? [];
|
|
318
|
+
const folded = foldDelegation(branchEntries as Parameters<typeof foldDelegation>[0]);
|
|
319
|
+
if (!delegation && folded) delegation = folded;
|
|
320
|
+
if (delegation && !folded) {
|
|
321
|
+
// First-frame card: write once — the entry doubles as the /resume replay source.
|
|
322
|
+
try {
|
|
323
|
+
pi.appendEntry(DELEGATION_CUSTOM_TYPE, { version: 1, ...delegation, ts: Date.now() });
|
|
324
|
+
} catch {
|
|
325
|
+
/* Best effort; the marker already carries the identity. */
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
// The master reads the worker's session file from this label.
|
|
329
|
+
if (sessionFile) void client.setLabels({ 'piher.session': sessionFile });
|
|
330
|
+
writeWorkerMarker(sessionFile, roles.state.manifest?.role, delegation ?? undefined);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
await startPipeServerFor((ctx as { cwd?: string }).cwd ?? process.cwd());
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
/* RFC rfc-delegation-identity §2.2: worker identity + delivery contract ride the prompt
|
|
337
|
+
* sections each turn (same diff discipline as piher-role; removal cleans up). */
|
|
338
|
+
if (isSubagent) {
|
|
339
|
+
pi.on('before_agent_start', async (event) => {
|
|
340
|
+
const sections = event.systemPromptOptions?.sections;
|
|
341
|
+
if (!sections) return;
|
|
342
|
+
if (delegation) {
|
|
343
|
+
const text = buildDelegationSection(delegation, env?.terminalId ?? '');
|
|
344
|
+
if (sections['piher-delegation'] !== text) sections['piher-delegation'] = text;
|
|
345
|
+
} else {
|
|
346
|
+
delete sections['piher-delegation'];
|
|
347
|
+
}
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
pi.on('session_tree', async (_event, ctx) => {
|
|
352
|
+
sessionId = resolveSessionId(ctx);
|
|
353
|
+
ownSessionPath = reportedSessionFile(ctx) ?? ownSessionPath;
|
|
354
|
+
// pi reverts the transcript loadout at the navigated branch point; the role
|
|
355
|
+
// runtime follows the same point (same-origin, RFC §4.3).
|
|
356
|
+
roles.syncFromBranch(ctx);
|
|
357
|
+
repaintAgentViews(ctx);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
/* pi 1.2 counts the run as busy from `before_agent_start`, not `turn_start`: a settlement
|
|
361
|
+
* notice arriving in that window must enter the D92 buffer instead of being sent immediately. */
|
|
362
|
+
pi.on('before_agent_start', async () => {
|
|
363
|
+
agentActive = true;
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
pi.on('turn_start', async () => {
|
|
367
|
+
agentActive = true;
|
|
368
|
+
// A11: reset so a previous turn's abort cannot taint this one.
|
|
369
|
+
lastStopReason = null;
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
pi.on('turn_end', async (event: unknown, ctx) => {
|
|
373
|
+
// Master registered this twice: the lifecycle handler skipped malformed events, the notice
|
|
374
|
+
// flush ran regardless. The merged handler keeps both semantics.
|
|
375
|
+
const msg = event !== null && typeof event === 'object' && 'message' in event ? (event as { message: unknown }).message : null;
|
|
376
|
+
if (msg !== null && typeof msg === 'object') {
|
|
377
|
+
const { role, stopReason } = msg as { role?: unknown; stopReason?: unknown };
|
|
378
|
+
if (role === 'assistant' && typeof stopReason === 'string') {
|
|
379
|
+
lastStopReason = stopReason;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
repaintAgentViews(ctx);
|
|
383
|
+
// A steer sent into an aborted turn is dropped by pi, after the flush has already released the
|
|
384
|
+
// batch — the loss the buffer above exists to prevent. Keep the notices pending.
|
|
385
|
+
if (lastStopReason !== ABORT_STOP_REASON) {
|
|
386
|
+
void notices.flush('steer');
|
|
387
|
+
}
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
pi.on('agent_settled', async (event: unknown, ctx) => {
|
|
391
|
+
agentActive = false;
|
|
392
|
+
repaintAgentViews(ctx);
|
|
393
|
+
// pi ≥ 1.1 stamps cancelled runs on the event itself; stopReason tracking misses ESC mid-tool
|
|
394
|
+
// (no final assistant message), so the event field is authoritative when present.
|
|
395
|
+
const aborted = event !== null && typeof event === 'object' && 'aborted' in event && event.aborted === true;
|
|
396
|
+
const plan = planSettleWake({
|
|
397
|
+
lastStopReason,
|
|
398
|
+
aborted,
|
|
399
|
+
running: subagentPort.current?.listRunningSubs() ?? [],
|
|
400
|
+
lastNoticeKey: d96NoticeKey,
|
|
401
|
+
lastNoticeAt: d96NoticeAt,
|
|
402
|
+
now: Date.now(),
|
|
403
|
+
});
|
|
404
|
+
d96NoticeKey = plan.noticeKey;
|
|
405
|
+
d96NoticeAt = plan.noticeAt;
|
|
406
|
+
// D96: master settled while background subagents still run → remind (worker port is unbound).
|
|
407
|
+
if (plan.wake && plan.notice && !isSubagent) {
|
|
408
|
+
const running = subagentPort.current?.listRunningSubs() ?? [];
|
|
409
|
+
const brief = running.map((s) => `${s.paneId} (${s.description})`).join(', ');
|
|
410
|
+
const notice = `Note: ${running.length} background subagent(s) still running: ${brief}. If your task depends on them, wait for their settlement (subagent list to check status); if you will not wait, state why you are abandoning them.`;
|
|
411
|
+
void sendUserMessageAs(notice, 'followUp');
|
|
412
|
+
}
|
|
413
|
+
if (lastStopReason !== ABORT_STOP_REASON && !aborted) void notices.flush('followUp');
|
|
414
|
+
const pushed = pushSettleReply(aborted);
|
|
415
|
+
// S54: a role-declared `one-shot` worker exits once its report is in the master's hands. The
|
|
416
|
+
// ordering is the whole risk — shutting down first loses the report the parent waits for — so the
|
|
417
|
+
// exit hangs off the push, and never off an aborted turn (S52: an interrupted turn reports nothing).
|
|
418
|
+
if (isSubagent && !aborted && delegation?.lifecycle === 'one-shot') {
|
|
419
|
+
void pushed.then((sent) => { if (sent) ctx.shutdown(); });
|
|
420
|
+
} else {
|
|
421
|
+
void pushed;
|
|
422
|
+
}
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
installConfigCommand({ pi });
|
|
426
|
+
|
|
427
|
+
pi.on('session_shutdown', async () => {
|
|
428
|
+
// Stop the watchdog first: a rebind during shutdown would outlive the session it serves.
|
|
429
|
+
if (pipeWatchdog) clearInterval(pipeWatchdog);
|
|
430
|
+
pipeWatchdog = null;
|
|
431
|
+
if (pipeServerBox.current) {
|
|
432
|
+
const server = pipeServerBox.current;
|
|
433
|
+
pipeServerBox.current = null;
|
|
434
|
+
await closePipeServer(server);
|
|
435
|
+
}
|
|
436
|
+
if (env?.terminalId) releasePipe(env.terminalId, pipeGeneration);
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
/* ── master vs worker mount (dynamic: workers never load the cordis tree, C3) ── */
|
|
440
|
+
if (mode.composeMaster) {
|
|
441
|
+
const { mountMasterPlugins } = await import('./index-master.ts');
|
|
442
|
+
await mountMasterPlugins({
|
|
443
|
+
pi,
|
|
444
|
+
client,
|
|
445
|
+
env,
|
|
446
|
+
extPath: fileURLToPath(import.meta.url),
|
|
447
|
+
port: subagentPort,
|
|
448
|
+
pipeServerBox,
|
|
449
|
+
deliverNotice,
|
|
450
|
+
noticePending: notices.noticePending,
|
|
451
|
+
getSessionId: () => sessionId,
|
|
452
|
+
// The task view lives beside the transcript; the reported path is authoritative, and the scan by
|
|
453
|
+
// id is only the fallback for a host that reports an id instead of a file.
|
|
454
|
+
getSessionFile: () => ownSessionPath
|
|
455
|
+
?? (sessionId ? resolveSessionFileValue(process.cwd(), agentSessionsDir(), sessionId) : null),
|
|
456
|
+
claimSettleNotice,
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
}
|
|
460
|
+
}
|
package/src/ledger.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Disposal ledger shared by HMR and session shutdown (D80⑤ + D79).
|
|
3
|
+
*
|
|
4
|
+
* HMR partial reload can skip old fiber effect disposers, so process-level resources (intervals, pipe
|
|
5
|
+
* servers, watchers) would leak without compensation. Modules register those resources by module key;
|
|
6
|
+
* reload disposes matching keys, session_shutdown disposes everything. D79 reuses the same mechanism
|
|
7
|
+
* for pi-surface registration cleanup, where keys need not be file paths.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { fileURLToPath } from 'node:url';
|
|
11
|
+
|
|
12
|
+
function normalizeModuleKey(spec: string): string {
|
|
13
|
+
let s = String(spec);
|
|
14
|
+
try {
|
|
15
|
+
if (s.startsWith('file://')) s = fileURLToPath(s);
|
|
16
|
+
} catch {
|
|
17
|
+
// Malformed URL: keep the caller's key so disposal still matches.
|
|
18
|
+
}
|
|
19
|
+
return s.replace(/\\/g, '/');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class DisposeLedger {
|
|
23
|
+
/** LIFO order matches Cordis effect disposal semantics. */
|
|
24
|
+
private order: Array<{ key: string; dispose: () => void }> = [];
|
|
25
|
+
|
|
26
|
+
/** Register a resource; the returned cancellation makes self-disposal a no-op. */
|
|
27
|
+
add(spec: string, dispose: () => void): () => void {
|
|
28
|
+
const entry = { key: normalizeModuleKey(spec), dispose };
|
|
29
|
+
this.order.push(entry);
|
|
30
|
+
return () => {
|
|
31
|
+
const i = this.order.indexOf(entry);
|
|
32
|
+
if (i >= 0) this.order.splice(i, 1);
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
disposeKey(spec: string | string[]): number {
|
|
37
|
+
const keys = (Array.isArray(spec) ? spec : [spec]).map(normalizeModuleKey);
|
|
38
|
+
let n = 0;
|
|
39
|
+
for (let i = this.order.length - 1; i >= 0; i--) {
|
|
40
|
+
if (!keys.includes(this.order[i]!.key)) continue;
|
|
41
|
+
const { dispose } = this.order.splice(i, 1)[0]!;
|
|
42
|
+
try { dispose(); } catch { /* Compensation must never abort the reload. */ }
|
|
43
|
+
n++;
|
|
44
|
+
}
|
|
45
|
+
return n;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
disposeAll(): number {
|
|
49
|
+
let n = 0;
|
|
50
|
+
while (this.order.length > 0) {
|
|
51
|
+
const { dispose } = this.order.pop()!;
|
|
52
|
+
try { dispose(); } catch { /* Reload/shutdown must survive a broken disposer. */ }
|
|
53
|
+
n++;
|
|
54
|
+
}
|
|
55
|
+
return n;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
get size(): number {
|
|
59
|
+
return this.order.length;
|
|
60
|
+
}
|
|
61
|
+
}
|