@aeondave/pi-persona 1.17.0 → 1.17.3
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/AGENTS.md +79 -29
- package/CHANGELOG.md +264 -0
- package/README.md +27 -12
- package/agents/architect.md +21 -0
- package/agents/balthasar.md +17 -22
- package/agents/casper.md +17 -23
- package/agents/evidence-verifier.md +31 -16
- package/agents/melchior.md +17 -22
- package/agents/operator.md +15 -55
- package/agents/research.md +14 -48
- package/agents/reviewer.md +20 -39
- package/agents/scout.md +14 -15
- package/agents/verifier.md +15 -16
- package/docs/ARCHITECTURE.md +223 -57
- package/docs/EXPERIENCE.md +19 -9
- package/docs/MONITORS.md +62 -3
- package/docs/REFERENCE.md +189 -35
- package/docs/SPINE.md +2 -2
- package/docs/STRATEGIES.md +23 -8
- package/docs/TELEMETRY.md +1 -0
- package/flows/deep-review.flow.json +1 -1
- package/package.json +9 -8
- package/personas/audit.md +28 -17
- package/personas/dev.md +30 -32
- package/personas/elite.md +223 -221
- package/personas/judge.md +25 -19
- package/personas/magi.md +26 -29
- package/personas/planner.md +31 -24
- package/personas/researcher.md +25 -26
- package/personas/swarm.md +35 -25
- package/personas/verify.md +30 -19
- package/src/agents/agent.ts +20 -0
- package/src/bridge.ts +3 -2
- package/src/core/brief.ts +429 -356
- package/src/core/config.ts +16 -11
- package/src/core/model-catalog.ts +171 -0
- package/src/core/model-tier.ts +89 -0
- package/src/core/models.ts +13 -4
- package/src/core/monitor-output.ts +85 -0
- package/src/core/monitor.ts +34 -2
- package/src/core/nudge.ts +176 -23
- package/src/core/seed.ts +99 -6
- package/src/core/session-runs.ts +87 -0
- package/src/core/settings.ts +251 -0
- package/src/core/timer.ts +34 -9
- package/src/engine/adapter.ts +12 -8
- package/src/engine/async.ts +195 -136
- package/src/engine/child.ts +1 -1
- package/src/engine/host-module.ts +12 -0
- package/src/engine/inproc.ts +11 -6
- package/src/engine/stream.ts +3 -0
- package/src/exocom/install.ts +2 -1
- package/src/extension/companion-settings.ts +29 -0
- package/src/extension/engine.ts +43 -16
- package/src/extension/hooks.ts +106 -36
- package/src/extension/persona-model-settings.ts +336 -0
- package/src/extension/persona-settings.ts +459 -0
- package/src/extension/shared.ts +15 -7
- package/src/extension.ts +642 -267
- package/src/monitor/archive.ts +235 -0
- package/src/monitor/session.ts +21 -1
- package/src/orchestration/flow-journal.ts +26 -5
- package/src/orchestration/flow-run.ts +38 -13
- package/src/orchestration/flow.ts +9 -2
- package/src/orchestration/reducers.ts +9 -5
- package/src/orchestration/render.ts +1 -1
- package/src/orchestration/sdk.ts +17 -3
- package/src/orchestration/strategies/compete.ts +5 -1
- package/src/orchestration/strategies/council-rounds.ts +2 -0
- package/src/orchestration/strategies/critic-loop.ts +3 -0
- package/src/orchestration/strategies/debate.ts +2 -0
- package/src/orchestration/strategies/fanout.ts +3 -2
- package/src/orchestration/strategies/judge.ts +5 -1
- package/src/orchestration/strategies/magi.ts +40 -23
- package/src/orchestration/strategies/map.ts +13 -3
- package/src/orchestration/strategies/pair.ts +2 -0
- package/src/orchestration/strategies/pipeline.ts +2 -0
- package/src/orchestration/strategies/synthesize.ts +27 -7
- package/src/orchestration/strategy.ts +31 -0
- package/src/persona/config-store.ts +6 -195
- package/src/persona/model-participants.ts +1 -1
- package/src/persona/persona.ts +7 -3
- package/src/persona/settings-store.ts +636 -0
- package/src/telemetry/contract.ts +1 -1
- package/src/tools/background-run.ts +151 -0
- package/src/tools/council.ts +77 -52
- package/src/tools/delegate-tool.ts +315 -285
- package/src/tools/delegate.ts +22 -210
- package/src/tools/exocom-work.ts +18 -53
- package/src/tools/exocom.ts +15 -19
- package/src/tools/flow.ts +85 -28
- package/src/tools/intercom-tool.ts +108 -118
- package/src/tools/intercom.ts +1 -1
- package/src/tools/models.ts +19 -9
- package/src/tools/monitor.ts +39 -6
- package/src/tools/timer.ts +11 -13
- package/src/ui/agent-overlay.ts +55 -14
- package/src/ui/agent-tree.ts +84 -18
- package/src/ui/compact-widget.ts +25 -0
- package/src/ui/failure-summary.ts +55 -0
- package/src/ui/literal-tool-result.ts +24 -0
- package/src/ui/model-picker.ts +158 -81
- package/src/ui/persona-settings.ts +328 -0
- package/src/ui/presentation.ts +24 -0
- package/src/ui/report-body.ts +63 -0
- package/src/ui/session-cards.ts +189 -0
- package/src/ui/wake-rows.ts +262 -0
- package/teams.yaml +5 -1
- package/src/persona/state.ts +0 -28
package/src/engine/async.ts
CHANGED
|
@@ -6,20 +6,21 @@
|
|
|
6
6
|
*
|
|
7
7
|
* NOTE: live *steering* of a running child-process agent (resume/redirect) needs
|
|
8
8
|
* the cross-process broker (v0.5) or the InProcessEngine (v0.4). This module
|
|
9
|
-
* covers launch + peek + completion-notify
|
|
9
|
+
* covers launch + peek + completion-notify.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import type { ProgressSnapshot } from "./stream.ts";
|
|
12
|
+
import type { ProgressSnapshot, ToolEvent } from "./stream.ts";
|
|
13
13
|
import type { AgentResult } from "../orchestration/types.ts";
|
|
14
|
-
import {
|
|
14
|
+
import { DEFAULT_ASYNC_RETAIN } from "../core/config.ts";
|
|
15
|
+
import { MAX_DISPLAY_LABEL_CHARS, sanitizeDisplayLabel, sanitizeStatusLabel } from "../core/display-label.ts";
|
|
15
16
|
import { compactTokens } from "../core/format.ts";
|
|
16
17
|
import { formatDuration } from "../core/time.ts";
|
|
17
18
|
|
|
18
19
|
export { MAX_DISPLAY_LABEL_CHARS } from "../core/display-label.ts";
|
|
19
20
|
|
|
20
|
-
/** "possibly stuck" — the soft stall signal shared by the
|
|
21
|
-
* deliberately patient: a long scan, a big generation, or a blocking command shows no
|
|
22
|
-
* progress yet is perfectly healthy, so
|
|
21
|
+
/** "possibly stuck" — the soft stall signal shared by the operator card, the peek digest and the UI
|
|
22
|
+
* badge. It is deliberately patient: a long scan, a big generation, or a blocking command shows no
|
|
23
|
+
* visible progress yet is perfectly healthy, so every surface flags a leg only after a genuinely long quiet
|
|
23
24
|
* spell. Purely advisory (no auto-abort); the idle watchdog + token budget are the enforcing backstops. */
|
|
24
25
|
export const STALL_FLAG_MS = 90_000;
|
|
25
26
|
|
|
@@ -40,16 +41,19 @@ export interface AsyncRun {
|
|
|
40
41
|
/** The already-shortened model (e.g. "sonnet") this run was launched with — kept pre-shortened
|
|
41
42
|
* so this module needs no import from delegate.ts just to render it. */
|
|
42
43
|
model?: string;
|
|
43
|
-
/** Clock (ms) this run
|
|
44
|
-
* "that leg took 2m 5s" reading is computed from. Both
|
|
44
|
+
/** Clock (ms) this run started executing (launch, or admission for a queued one), and the clock it
|
|
45
|
+
* settled at — the pair the supervisor's "that leg took 2m 5s" reading is computed from. Both
|
|
46
|
+
* come from the tracker's INJECTED clock,
|
|
45
47
|
* so a test can settle a two-hour run without waiting. Optional because runs also reach the
|
|
46
48
|
* renderers from call sites that never stamped them; absent ⇒ no duration is claimed. */
|
|
47
49
|
startedAt?: number;
|
|
48
50
|
settledAt?: number;
|
|
49
|
-
/** Clock (ms) of the last time
|
|
50
|
-
*
|
|
51
|
-
*
|
|
51
|
+
/** Clock (ms) of the last time the run made {@link madeHeadway} — NOT merely the last event. A worker
|
|
52
|
+
* looping without headway, or a tool streaming output forever, keeps emitting but never bumps this,
|
|
53
|
+
* so `now - lastAdvanceAt` is what tells "working" from "stuck". */
|
|
52
54
|
lastAdvanceAt?: number;
|
|
55
|
+
/** Waiting for semaphore admission; queued time is not agent execution time. */
|
|
56
|
+
queued?: boolean;
|
|
53
57
|
/** Internal bookkeeping: true once the completion listeners have fired for this entry. Guards a
|
|
54
58
|
* force-settle racing the run thunk's natural resolution so listeners fire exactly once. Lives on
|
|
55
59
|
* the entry (not a side Set) so it survives pruning and the `launch` closure still sees it. */
|
|
@@ -63,6 +67,36 @@ export interface AsyncRun {
|
|
|
63
67
|
* run INSIDE {@link AsyncRunTracker.launch}, before the caller holds the id, so a registration
|
|
64
68
|
* performed after launch() returns would re-insert an already-dead run and leak it. */
|
|
65
69
|
mutates?: boolean;
|
|
70
|
+
/** The id of the one `tasks` call this leg was launched by. Its members are delivered together, once the
|
|
71
|
+
* last of them settles (see {@link AsyncRunTracker.batchSettled}); a single-leg launch has none. */
|
|
72
|
+
batch?: string;
|
|
73
|
+
/** What a running run is blocked on, when it is the OPERATOR (a flow checkpoint modal): shown in the
|
|
74
|
+
* digest instead of a stall verdict, because silence while a human decides is not a wedge. */
|
|
75
|
+
awaiting?: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** What {@link madeHeadway} reads of one progress reading: a tracker snapshot, or a leg's `AgentProgress`
|
|
79
|
+
* (which carries no turns). */
|
|
80
|
+
export interface ProgressReading {
|
|
81
|
+
output?: string | undefined;
|
|
82
|
+
tokens?: number | undefined;
|
|
83
|
+
turns?: number | undefined;
|
|
84
|
+
toolEvent?: ToolEvent | undefined;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The ONE rule for "this run advanced": the assistant produced more (output grew, tokens or turns rose) or a
|
|
89
|
+
* tool call just COMPLETED. Raw tool output is deliberately not headway - a straggler that streams text
|
|
90
|
+
* forever (`npm run dev`, `--watch`, `tail -f`) must still read as stalled, so the operator-only card can
|
|
91
|
+
* flag it. Nothing is killed and the model is not woken; a legitimately noisy tool earns an advisory only.
|
|
92
|
+
* A completion counts once: the transient `toolEvent` rides the one snapshot it was emitted on, so
|
|
93
|
+
* replaying that snapshot credits nothing. Pure.
|
|
94
|
+
*/
|
|
95
|
+
export function madeHeadway(prev: ProgressReading | undefined, next: ProgressReading): boolean {
|
|
96
|
+
return (next.output?.length ?? 0) > (prev?.output?.length ?? 0)
|
|
97
|
+
|| (next.tokens ?? 0) > (prev?.tokens ?? 0)
|
|
98
|
+
|| (next.turns ?? 0) > (prev?.turns ?? 0)
|
|
99
|
+
|| (next.toolEvent?.phase === "end" && next.toolEvent !== prev?.toolEvent);
|
|
66
100
|
}
|
|
67
101
|
|
|
68
102
|
/** The run's id is passed in so the launcher can wire a steer handle keyed by it. */
|
|
@@ -86,14 +120,25 @@ export class AsyncRunTracker {
|
|
|
86
120
|
private readonly now: () => number;
|
|
87
121
|
|
|
88
122
|
/** `now` is injected so the stall clock is deterministic under test (real time by default).
|
|
89
|
-
* `maxRetained` (default
|
|
90
|
-
* knob's landing spot: how many settled-but-uncollected runs survive before FIFO-eviction. */
|
|
123
|
+
* `maxRetained` (default {@link DEFAULT_ASYNC_RETAIN}; the extension passes `config.asyncRetain`) is the config-driven
|
|
124
|
+
* PI_PERSONA_ASYNC_RETAIN knob's landing spot: how many settled-but-uncollected runs survive before FIFO-eviction. */
|
|
91
125
|
constructor(opts?: { now?: () => number; maxRetained?: number }) {
|
|
92
126
|
this.now = opts?.now ?? ((): number => Date.now());
|
|
93
|
-
this.maxRetained = opts?.maxRetained ??
|
|
127
|
+
this.maxRetained = opts?.maxRetained ?? DEFAULT_ASYNC_RETAIN;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** How many run ids this lineage has issued: what a restored instance must continue from. */
|
|
131
|
+
get issued(): number {
|
|
132
|
+
return this.seq;
|
|
94
133
|
}
|
|
95
134
|
|
|
96
|
-
|
|
135
|
+
/** Continue the id counter from a previous instance of the same session so ids never repeat within
|
|
136
|
+
* one history. Monotonic: anything that would rewind it, or is not a whole number, is ignored. */
|
|
137
|
+
continueFrom(issued: number): void {
|
|
138
|
+
if (Number.isSafeInteger(issued) && issued > this.seq) this.seq = issued;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
launch(meta: { agent: string; task: string; label?: string; model?: string; mutates?: boolean; queued?: boolean; batch?: AsyncRun["batch"] }, run: RunThunk): string {
|
|
97
142
|
this.seq += 1;
|
|
98
143
|
const id = `run-${this.seq.toString(36)}`;
|
|
99
144
|
const startedAt = this.now(); // one clock read, so the launch and stall stamps cannot disagree
|
|
@@ -104,9 +149,10 @@ export class AsyncRunTracker {
|
|
|
104
149
|
status: "running",
|
|
105
150
|
progress: { output: "", turns: 0, tokens: 0 },
|
|
106
151
|
startedAt,
|
|
107
|
-
lastAdvanceAt: startedAt,
|
|
152
|
+
...(meta.queued ? { queued: true } : { lastAdvanceAt: startedAt }),
|
|
108
153
|
};
|
|
109
154
|
if (meta.mutates) entry.mutates = true;
|
|
155
|
+
if (meta.batch) entry.batch = meta.batch;
|
|
110
156
|
// Labels/models are rendered as trusted status metadata on several compact surfaces. Normalize
|
|
111
157
|
// once at the tracker boundary so a caller cannot smuggle multiline instructions into any of
|
|
112
158
|
// those views; renderers still sanitize defensively when they compose a label.
|
|
@@ -122,12 +168,8 @@ export class AsyncRunTracker {
|
|
|
122
168
|
try {
|
|
123
169
|
Promise.resolve(
|
|
124
170
|
run((s) => {
|
|
125
|
-
//
|
|
126
|
-
|
|
127
|
-
const prev = entry.progress;
|
|
128
|
-
if (s.output.length > prev.output.length || s.tokens > prev.tokens || s.turns > prev.turns) {
|
|
129
|
-
entry.lastAdvanceAt = this.now();
|
|
130
|
-
}
|
|
171
|
+
// The injected clock stamps the advance, never an event's own timestamp.
|
|
172
|
+
if (!entry.queued && madeHeadway(entry.progress, s)) entry.lastAdvanceAt = this.now();
|
|
131
173
|
entry.progress = s;
|
|
132
174
|
}, id),
|
|
133
175
|
)
|
|
@@ -163,8 +205,8 @@ export class AsyncRunTracker {
|
|
|
163
205
|
entry.settledAt = this.now();
|
|
164
206
|
this.settledOrder.push(entry.id);
|
|
165
207
|
this.prune();
|
|
166
|
-
// Iterate a SNAPSHOT: a
|
|
167
|
-
// splicing the live array under the cursor would shift the next
|
|
208
|
+
// Iterate a SNAPSHOT: a listener may unsubscribe from inside its own callback, and
|
|
209
|
+
// splicing the live array under the cursor would shift the next listener past it.
|
|
168
210
|
// A listener's throw must likewise not skip the ones after it — and on the natural-settle
|
|
169
211
|
// path this runs inside an unobserved promise chain, where an escaping throw becomes a
|
|
170
212
|
// process-killing unhandled rejection.
|
|
@@ -179,6 +221,36 @@ export class AsyncRunTracker {
|
|
|
179
221
|
}
|
|
180
222
|
}
|
|
181
223
|
|
|
224
|
+
/** A queued run acquired its concurrency slot: execution, and its stall window, start now. */
|
|
225
|
+
markAdmitted(id: string): boolean {
|
|
226
|
+
const entry = this.runs.get(id);
|
|
227
|
+
if (!entry || entry.status !== "running" || entry.settled || entry.queued !== true) return false;
|
|
228
|
+
delete entry.queued;
|
|
229
|
+
entry.startedAt = entry.lastAdvanceAt = this.now();
|
|
230
|
+
return true;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Mark a running run as waiting on the operator (`label`), or release it (`undefined`). The clock
|
|
234
|
+
* restarts on release, so the human's think time is never counted as the run's own silence. */
|
|
235
|
+
setAwaiting(id: string, label: string | undefined): boolean {
|
|
236
|
+
const entry = this.runs.get(id);
|
|
237
|
+
if (!entry || entry.status !== "running" || entry.settled) return false;
|
|
238
|
+
if (label === undefined) {
|
|
239
|
+
delete entry.awaiting;
|
|
240
|
+
this.advance(id);
|
|
241
|
+
} else {
|
|
242
|
+
entry.awaiting = sanitizeStatusLabel(label, "waiting for operator", 120);
|
|
243
|
+
}
|
|
244
|
+
return true;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Credit headway to a running run whose own snapshot cannot show it: a council or flow aggregates its
|
|
248
|
+
* legs' progress, so one leg's advance is invisible in the sum. Queued, settled and unknown runs are left alone. */
|
|
249
|
+
advance(id: string): void {
|
|
250
|
+
const entry = this.runs.get(id);
|
|
251
|
+
if (entry && entry.status === "running" && !entry.settled && !entry.queued) entry.lastAdvanceAt = this.now();
|
|
252
|
+
}
|
|
253
|
+
|
|
182
254
|
/**
|
|
183
255
|
* Force a still-"running" run to settle as stopped and fire its completion listeners. Escape hatch
|
|
184
256
|
* for a run whose hard-stop handle is gone (consumed by a prior abort, or never registered) while
|
|
@@ -267,6 +339,14 @@ export class AsyncRunTracker {
|
|
|
267
339
|
return this.list().filter((r) => r.status === "running");
|
|
268
340
|
}
|
|
269
341
|
|
|
342
|
+
/** True when `run` may be reported: it belongs to no batch, or no member of its batch is still
|
|
343
|
+
* running. Asked of the live tracker, never counted from a delivered set, so a member collected
|
|
344
|
+
* early (`intercom result`), stopped, or failed inside launch() can never keep its batch waiting. */
|
|
345
|
+
batchSettled(run: AsyncRun): boolean {
|
|
346
|
+
const batch = run.batch;
|
|
347
|
+
return batch === undefined || !this.running().some((r) => r.batch === batch);
|
|
348
|
+
}
|
|
349
|
+
|
|
270
350
|
onComplete(cb: (run: AsyncRun) => void): () => void {
|
|
271
351
|
this.completeListeners.push(cb);
|
|
272
352
|
return () => {
|
|
@@ -274,44 +354,9 @@ export class AsyncRunTracker {
|
|
|
274
354
|
if (i >= 0) this.completeListeners.splice(i, 1);
|
|
275
355
|
};
|
|
276
356
|
}
|
|
277
|
-
|
|
278
|
-
/**
|
|
279
|
-
* Wait (join) until every listed run settles, or `timeoutMs` elapses — never rejects:
|
|
280
|
-
* it resolves with the runs' CURRENT states, so the caller reports what settled and
|
|
281
|
-
* what is still going. Backs the `intercom wait` action (the supervisor holds its turn
|
|
282
|
-
* to collect async results it now needs before its next step).
|
|
283
|
-
*/
|
|
284
|
-
waitFor(ids: string[], timeoutMs: number, signal?: AbortSignal): Promise<AsyncRun[]> {
|
|
285
|
-
const targets = ids.map((id) => this.runs.get(id)).filter((r): r is AsyncRun => r !== undefined);
|
|
286
|
-
if (targets.every((r) => r.status !== "running")) return Promise.resolve(targets);
|
|
287
|
-
return new Promise((resolve) => {
|
|
288
|
-
let off = (): void => {};
|
|
289
|
-
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
290
|
-
const finish = (): void => {
|
|
291
|
-
off();
|
|
292
|
-
if (timer) clearTimeout(timer);
|
|
293
|
-
signal?.removeEventListener("abort", finish);
|
|
294
|
-
resolve(targets);
|
|
295
|
-
};
|
|
296
|
-
off = this.onComplete(() => {
|
|
297
|
-
if (targets.every((r) => r.status !== "running")) finish();
|
|
298
|
-
});
|
|
299
|
-
timer = setTimeout(finish, timeoutMs);
|
|
300
|
-
timer.unref?.();
|
|
301
|
-
if (signal) {
|
|
302
|
-
if (signal.aborted) finish();
|
|
303
|
-
else signal.addEventListener("abort", finish, { once: true });
|
|
304
|
-
}
|
|
305
|
-
});
|
|
306
|
-
}
|
|
307
357
|
}
|
|
308
358
|
|
|
309
|
-
/**
|
|
310
|
-
* A compact ProgressView digest of async runs — the full on-demand `/peek` view, and the body the
|
|
311
|
-
* routine check-in wraps ({@link buildCheckIn}). When `opts` carries the current clock + a stall
|
|
312
|
-
* window, a RUNNING run that hasn't advanced within that window is flagged "possibly stuck". The
|
|
313
|
-
* periodic FAST wakeup does NOT send this digest — it sends the focused {@link buildPeekAlert}.
|
|
314
|
-
*/
|
|
359
|
+
/** First occurrence of each run id, in order. */
|
|
315
360
|
export function dedupeRunsById(runs: AsyncRun[]): AsyncRun[] {
|
|
316
361
|
const byId = new Map<string, AsyncRun>();
|
|
317
362
|
for (const r of runs) if (!byId.has(r.id)) byId.set(r.id, r);
|
|
@@ -333,13 +378,14 @@ export function runDisplayName(run: Pick<AsyncRun, "agent" | "label" | "model">)
|
|
|
333
378
|
*
|
|
334
379
|
* Wall time is the supervisor's cheapest signal about a delegation — whether the leg it is reading
|
|
335
380
|
* came back in seconds or ground for two hours changes what to do next — and no completion surface
|
|
336
|
-
* used to say it. Undefined (rather than "?" or "0s") when the pair is missing or
|
|
381
|
+
* used to say it. Undefined (rather than "?" or "0s") when the pair is missing, inverted or not finite, so an
|
|
337
382
|
* unstamped run renders exactly as it did before instead of gaining a fabricated reading.
|
|
338
383
|
*/
|
|
339
384
|
export function runDurationLabel(run: AsyncRun): string | undefined {
|
|
340
385
|
const { startedAt, settledAt } = run;
|
|
341
|
-
if (startedAt === undefined || settledAt === undefined
|
|
342
|
-
|
|
386
|
+
if (startedAt === undefined || settledAt === undefined) return undefined;
|
|
387
|
+
const span = settledAt - startedAt;
|
|
388
|
+
return Number.isFinite(span) && span >= 0 ? formatDuration(span) : undefined;
|
|
343
389
|
}
|
|
344
390
|
|
|
345
391
|
/** The " in 2m 5s" / " after 2m 5s" fragment for a settled leg, or "" when it has no duration. */
|
|
@@ -391,20 +437,6 @@ export function boundCompletionSurface(
|
|
|
391
437
|
|
|
392
438
|
const resultActionHint = (id: string): string => `intercom { action:"result", to:"${id}" }`;
|
|
393
439
|
|
|
394
|
-
/** A timeout is a status update, not an invitation to poll. The passive completion notifier is
|
|
395
|
-
* the normal delivery path, so send the supervisor back to useful work and reserve peek/steer/stop
|
|
396
|
-
* for intervention. */
|
|
397
|
-
export function buildWaitTimeoutNote(ids: readonly string[], timeoutMs: number): string {
|
|
398
|
-
const visibleIds = ids.slice(0, MAX_ASYNC_STATUS_ROWS);
|
|
399
|
-
const omitted = ids.length - visibleIds.length;
|
|
400
|
-
const idSummary = `${visibleIds.join(", ")}${omitted > 0 ? `, … +${omitted} more` : ""}`;
|
|
401
|
-
return (
|
|
402
|
-
`⏳ still running after ${timeoutMs}ms: ${idSummary}. ` +
|
|
403
|
-
"Continue useful supervisor work; completion will notify you automatically. " +
|
|
404
|
-
"Use peek only when status is needed, steer to redirect, or stop a truly stalled run — do not immediately wait again."
|
|
405
|
-
);
|
|
406
|
-
}
|
|
407
|
-
|
|
408
440
|
/**
|
|
409
441
|
* The explicit overflow signal for the one case retention cannot cover: more runs settled than the
|
|
410
442
|
* tracker keeps, so some of the ids this very report names can no longer be fetched. Saying it here,
|
|
@@ -445,6 +477,18 @@ export function clipRunOutput(text: string, id: string, maxChars: number, label
|
|
|
445
477
|
return `${text.slice(0, headChars)}${marker}${tailChars > 0 ? text.slice(-tailChars) : ""}`;
|
|
446
478
|
}
|
|
447
479
|
|
|
480
|
+
export function isAsyncRunStalled(run: AsyncRun, now: number, stallMs: number): boolean {
|
|
481
|
+
return run.status === "running" && run.queued !== true && stallMs > 0
|
|
482
|
+
&& run.lastAdvanceAt !== undefined && Number.isFinite(run.lastAdvanceAt) && run.lastAdvanceAt >= 0
|
|
483
|
+
&& now - run.lastAdvanceAt >= stallMs;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* A compact ProgressView digest of async runs — the full on-demand `/peek` view, and the body the
|
|
488
|
+
* routine check-in wraps ({@link buildCheckIn}). When `opts` carries the current clock + a stall
|
|
489
|
+
* window, a RUNNING run that hasn't advanced within that window is flagged "possibly stuck". The
|
|
490
|
+
* periodic stall check never wakes the model with this digest; it files it as an operator-only card.
|
|
491
|
+
*/
|
|
448
492
|
export function buildPeekDigest(runs: AsyncRun[], opts?: { now?: number; stallMs?: number; waitingForSupervisor?: ReadonlySet<string> }): string {
|
|
449
493
|
if (runs.length === 0) return "No async runs.";
|
|
450
494
|
const running = runs.filter((r) => r.status === "running").length;
|
|
@@ -459,11 +503,17 @@ export function buildPeekDigest(runs: AsyncRun[], opts?: { now?: number; stallMs
|
|
|
459
503
|
const name = runDisplayName(r);
|
|
460
504
|
const head = `[${r.id}] ${name} — ${r.status}`;
|
|
461
505
|
if (r.status === "running") {
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
506
|
+
// A tracked council/flow reports tokens only (its legs carry no turn count): "0 turns" would be false.
|
|
507
|
+
let line = `${head} (${r.progress.turns > 0 ? `${r.progress.turns} turns, ` : ""}${compactTokens(r.progress.tokens)} cumulative input + output tokens)`;
|
|
508
|
+
// Queued time is not execution time, so a queued run claims no "running for".
|
|
509
|
+
const ran = r.queued ? undefined : runDurationLabel({ ...r, ...(now !== undefined ? { settledAt: now } : {}) });
|
|
510
|
+
if (ran) line += ` · running for ${ran}`;
|
|
511
|
+
if (r.queued) line += " · queued (waiting for concurrency slot)";
|
|
512
|
+
else if (r.awaiting) line += ` · ${r.awaiting}`;
|
|
513
|
+
else if (opts?.waitingForSupervisor?.has(r.id)) line += " · waiting for supervisor";
|
|
514
|
+
else if (now !== undefined && stallMs !== undefined && isAsyncRunStalled(r, now, stallMs)) {
|
|
515
|
+
const stalledFor = now - r.lastAdvanceAt!;
|
|
516
|
+
line += ` ⚠ possibly stuck (no progress for ${Math.round(stalledFor / 1000)}s)`;
|
|
467
517
|
}
|
|
468
518
|
return line;
|
|
469
519
|
}
|
|
@@ -484,13 +534,13 @@ export function buildPeekDigest(runs: AsyncRun[], opts?: { now?: number; stallMs
|
|
|
484
534
|
|
|
485
535
|
/**
|
|
486
536
|
* Turns the periodic peek from a poll into an EXCEPTION signal. Tracks which running legs have already
|
|
487
|
-
* been surfaced as stalled, so the
|
|
537
|
+
* been surfaced as stalled, so the operator is told ONCE when a leg crosses the stall window — never
|
|
488
538
|
* every tick while it stays stalled, and never at all while it is making progress. A leg that advances
|
|
489
539
|
* again (its `lastAdvanceAt` moves) re-arms, so a later stall re-alerts; legs that leave the list are
|
|
490
540
|
* forgotten. Pure w.r.t. the injected `now`, so it is unit-tested without a real clock.
|
|
491
541
|
*/
|
|
492
542
|
export class PeekWatcher {
|
|
493
|
-
/** runId → the `lastAdvanceAt` value at which
|
|
543
|
+
/** runId → the `lastAdvanceAt` value at which the operator episode was reported. */
|
|
494
544
|
private readonly reportedAt = new Map<string, number>();
|
|
495
545
|
|
|
496
546
|
/** The running legs that have NEWLY crossed the stall window since we last reported them. */
|
|
@@ -500,14 +550,18 @@ export class PeekWatcher {
|
|
|
500
550
|
for (const r of runs) {
|
|
501
551
|
if (r.status !== "running") continue;
|
|
502
552
|
live.add(r.id);
|
|
503
|
-
if (
|
|
504
|
-
this.reportedAt.delete(r.id);
|
|
553
|
+
if (r.queued) {
|
|
554
|
+
this.reportedAt.delete(r.id);
|
|
555
|
+
continue;
|
|
556
|
+
}
|
|
557
|
+
if (waitingForSupervisor.has(r.id) || r.awaiting) {
|
|
558
|
+
this.reportedAt.delete(r.id); // a live ask or an operator decision is not a stall; re-evaluate when it settles
|
|
505
559
|
continue;
|
|
506
560
|
}
|
|
507
561
|
if (r.lastAdvanceAt === undefined) continue;
|
|
508
562
|
const prev = this.reportedAt.get(r.id);
|
|
509
563
|
if (prev !== undefined && r.lastAdvanceAt > prev) this.reportedAt.delete(r.id); // advanced ⇒ re-arm
|
|
510
|
-
if (
|
|
564
|
+
if (isAsyncRunStalled(r, now, stallMs) && !this.reportedAt.has(r.id)) {
|
|
511
565
|
this.reportedAt.set(r.id, r.lastAdvanceAt);
|
|
512
566
|
newlyStuck.push(r);
|
|
513
567
|
}
|
|
@@ -522,34 +576,10 @@ export class PeekWatcher {
|
|
|
522
576
|
}
|
|
523
577
|
}
|
|
524
578
|
|
|
525
|
-
/**
|
|
526
|
-
* The periodic peek's wake message for a batch of NEWLY-stalled legs (from {@link PeekWatcher}), or ""
|
|
527
|
-
* when none. Unlike {@link buildPeekDigest} (the full, on-demand `/peek` view), this is an exception
|
|
528
|
-
* signal, not a status dump: a long operation looks identical to a stall from outside, so the framing is
|
|
529
|
-
* patience-first, and intervention means asking the leg — never reaching into its environment to check.
|
|
530
|
-
*/
|
|
531
|
-
export function buildPeekAlert(stuck: AsyncRun[], opts: { now: number }): string {
|
|
532
|
-
if (stuck.length === 0) return "";
|
|
533
|
-
const visibleStuck = stuck.slice(0, MAX_ASYNC_STATUS_ROWS);
|
|
534
|
-
const lines = visibleStuck.map((r) => {
|
|
535
|
-
const secs = Math.round((opts.now - (r.lastAdvanceAt ?? opts.now)) / 1000);
|
|
536
|
-
return `⚠ ${r.id} (${runDisplayName(r)}) — no visible progress for ${secs}s (${r.progress.turns} turns, ${compactTokens(r.progress.tokens)} cumulative input + output tokens)`;
|
|
537
|
-
});
|
|
538
|
-
const omitted = stuck.length - visibleStuck.length;
|
|
539
|
-
if (omitted > 0) lines.push(`… ${omitted} additional stalled-run alerts omitted from this bounded view.`);
|
|
540
|
-
lines.push(PROGRESS_TOKEN_NOTE);
|
|
541
|
-
return [
|
|
542
|
-
`${stuck.length} background ${stuck.length === 1 ? "leg" : "legs"} may be stalled:`,
|
|
543
|
-
...lines,
|
|
544
|
-
"A long operation (a slow scan, a big generation, a blocking command) looks the same as a stall from out here. " +
|
|
545
|
-
"If it's likely still working, leave it — the result returns to you as a follow-up. If you think it's genuinely " +
|
|
546
|
-
"wedged, ask the leg for a one-line status (a light `steer`) or `stop` it — don't run commands in its environment to check.",
|
|
547
|
-
].join("\n");
|
|
548
|
-
}
|
|
549
|
-
|
|
550
579
|
/** The operator-only periodic status card. It carries the progress digest without model instructions. */
|
|
551
|
-
export function buildCheckIn(runs: AsyncRun[], opts: { now: number; stallMs: number; waitingForSupervisor?: ReadonlySet<string
|
|
552
|
-
|
|
580
|
+
export function buildCheckIn(runs: AsyncRun[], opts: { now: number; stallMs: number; waitingForSupervisor?: ReadonlySet<string>; reason?: "routine" | "stall" }): string {
|
|
581
|
+
const reason = opts.reason ?? "routine";
|
|
582
|
+
return `Async activity · ${reason === "stall" ? "possible stall" : "routine check-in"}\n\n${buildPeekDigest(runs, opts)}`;
|
|
553
583
|
}
|
|
554
584
|
|
|
555
585
|
/**
|
|
@@ -557,20 +587,29 @@ export function buildCheckIn(runs: AsyncRun[], opts: { now: number; stallMs: num
|
|
|
557
587
|
* burst into a single message is what keeps completions from piling up as separate queued
|
|
558
588
|
* follow-ups (pi renders one sticky line per queued message); the explicit guidance keeps the
|
|
559
589
|
* supervisor from blindly re-issuing a delegation that just failed. `fence` wraps untrusted
|
|
560
|
-
* sub-agent text so the supervisor treats it as data, never as instructions.
|
|
590
|
+
* sub-agent text so the supervisor treats it as data, never as instructions. `bounded: false` lifts
|
|
591
|
+
* every cap, for the headless inline return where the caller is parked in the call and nothing is injected.
|
|
561
592
|
*/
|
|
562
|
-
export function buildCompletionReport(runs: AsyncRun[], fence: (text: string) => string): string {
|
|
593
|
+
export function buildCompletionReport(runs: AsyncRun[], fence: (text: string) => string, opts: { bounded?: boolean } = {}): string {
|
|
563
594
|
const done = runs.filter((r) => r.status === "done");
|
|
564
595
|
const failed = runs.filter((r) => r.status === "failed");
|
|
565
596
|
const stopped = runs.filter((r) => r.status === "stopped");
|
|
566
597
|
// Share the notification budget across every leg that can contribute payload. This keeps a
|
|
567
598
|
// large fan-out fair: the first result cannot consume the whole follow-up while later statuses
|
|
568
599
|
// are still waiting to be rendered. The final report cap remains a hard backstop for metadata.
|
|
600
|
+
const bounded = opts.bounded !== false;
|
|
601
|
+
const cap = (chars: number): number => (bounded ? chars : Number.POSITIVE_INFINITY);
|
|
569
602
|
const payloadRuns = runs.filter((r) => r.status === "done" || r.progress.output.trim());
|
|
570
|
-
const fairRunChars = Math.min(
|
|
603
|
+
const fairRunChars = cap(Math.min(
|
|
571
604
|
MAX_COMPLETION_RUN_CHARS,
|
|
572
605
|
Math.max(256, Math.floor(MAX_COMPLETION_REPORT_CHARS / Math.max(1, payloadRuns.length * 2))),
|
|
573
|
-
);
|
|
606
|
+
));
|
|
607
|
+
// Each failed leg is clipped on its own line, from a budget of its own: every id and cause stays
|
|
608
|
+
// listed however wide the fan-out, and the drill-down names a real run.
|
|
609
|
+
const failureChars = cap(Math.min(
|
|
610
|
+
MAX_COMPLETION_RUN_CHARS,
|
|
611
|
+
Math.max(200, Math.floor(MAX_COMPLETION_REPORT_CHARS / (4 * Math.max(1, failed.length)))),
|
|
612
|
+
));
|
|
574
613
|
// Failures are always reported — the supervisor must know so it can adjust or tell
|
|
575
614
|
// the user. Blind retry loops are prevented at RUNTIME by the DelegationLedger
|
|
576
615
|
// (an identical delegation that failed twice is vetoed before it spawns), not by
|
|
@@ -600,9 +639,9 @@ export function buildCompletionReport(runs: AsyncRun[], fence: (text: string) =>
|
|
|
600
639
|
}
|
|
601
640
|
}
|
|
602
641
|
if (failed.length > 0) {
|
|
603
|
-
const reasons = failed.map((r) => `• ${r.id} (${runDisplayName(r)})${durationSuffix(r, "after")}: ${r.error ?? "(no detail)"}`).join("\n");
|
|
642
|
+
const reasons = failed.map((r) => `• ${r.id} (${runDisplayName(r)})${durationSuffix(r, "after")}: ${clipRunOutput(r.error ?? "(no detail)", r.id, failureChars, "failure detail")}`).join("\n");
|
|
604
643
|
blocks.push(
|
|
605
|
-
`\n❌ ${failed.length} failed:\n${fence(
|
|
644
|
+
`\n❌ ${failed.length} failed:\n${fence(reasons)}\n\n` +
|
|
606
645
|
"Handle each failure deliberately: retry ONCE with a different model or approach, or report it to the user. " +
|
|
607
646
|
"If a failed/aborted leg left partial output below, salvage what's usable instead of re-running from scratch. " +
|
|
608
647
|
"Do not re-issue the same failing delegation repeatedly.",
|
|
@@ -616,17 +655,24 @@ export function buildCompletionReport(runs: AsyncRun[], fence: (text: string) =>
|
|
|
616
655
|
}
|
|
617
656
|
}
|
|
618
657
|
}
|
|
619
|
-
|
|
658
|
+
const report = blocks.join("\n");
|
|
659
|
+
if (!bounded) return report;
|
|
660
|
+
// The cap cuts whole legs from the middle of a wide fan-out. Name every run in the tail, which the
|
|
661
|
+
// cut keeps, so a cut leg is still fetchable by id instead of silently missing.
|
|
662
|
+
const named = report.length > MAX_COMPLETION_REPORT_CHARS
|
|
663
|
+
? `${report}\n\nRun ids in this report: ${runs.slice(0, MAX_ASYNC_STATUS_ROWS).map((r) => r.id).join(", ")}`
|
|
664
|
+
: report;
|
|
665
|
+
return boundCompletionSurface(named);
|
|
620
666
|
}
|
|
621
667
|
|
|
622
668
|
/**
|
|
623
669
|
* The completion report PLUS the premature-surrender counterweight. When a settled DONE leg's report
|
|
624
|
-
* carries a surrender marker, `scan` returns the note to append (else undefined).
|
|
625
|
-
* completion notifier
|
|
626
|
-
*
|
|
627
|
-
*
|
|
628
|
-
*
|
|
629
|
-
*
|
|
670
|
+
* carries a surrender marker, `scan` returns the note to append (else undefined). The background
|
|
671
|
+
* completion notifier renders through this, so a blocked leg gets the counterweight on the
|
|
672
|
+
* automatic follow-up — the sync `tool_result` hook never sees a background run. Only DONE legs
|
|
673
|
+
* are scanned: a FAILED leg is already surfaced as a failure by {@link buildCompletionReport},
|
|
674
|
+
* so the guard and the append agree (no done-vs-all mismatch). `scan` is injected
|
|
675
|
+
* (PersistenceNudge.scan) to keep this module decoupled from the nudge.
|
|
630
676
|
*/
|
|
631
677
|
export function renderCompletion(
|
|
632
678
|
runs: AsyncRun[],
|
|
@@ -680,6 +726,10 @@ export interface IdleNotifierDeps<T> {
|
|
|
680
726
|
* drain keeps pace with small items instead of being pinned to the worst case. Either form is
|
|
681
727
|
* clamped to at least one item: a queue must always drain. */
|
|
682
728
|
maxBatchItems?: number | ((pending: readonly T[]) => number);
|
|
729
|
+
/** Whether a buffered item may be delivered yet. A held item stays buffered (peekable, discardable)
|
|
730
|
+
* and is not polled: it ships on the first flush after the `notify()`/`kick()` that made it ready.
|
|
731
|
+
* Unset ⇒ everything is ready. */
|
|
732
|
+
isReady?: (item: T) => boolean;
|
|
683
733
|
/** Clock hook, injected for deterministic tests (defaults to `Date.now`). */
|
|
684
734
|
now?: () => number;
|
|
685
735
|
}
|
|
@@ -688,7 +738,8 @@ export interface IdleNotifierDeps<T> {
|
|
|
688
738
|
* Coalesces items into a single supervisor message, delivered only while the supervisor is idle.
|
|
689
739
|
* A stale host follow-up is NOT a blocker: an idle delivery starts the clean turn that can drain it.
|
|
690
740
|
* Self-healing: while busy it re-arms; sync throws and async rejections restore the whole batch.
|
|
691
|
-
* Used for async-run completions and child
|
|
741
|
+
* Used for async-run completions (a batch's members held until the last one settles) and child
|
|
742
|
+
* intercom asks.
|
|
692
743
|
*/
|
|
693
744
|
export class IdleCoalescingNotifier<T> {
|
|
694
745
|
private readonly deps: IdleNotifierDeps<T>;
|
|
@@ -744,15 +795,15 @@ export class IdleCoalescingNotifier<T> {
|
|
|
744
795
|
}
|
|
745
796
|
|
|
746
797
|
/** A snapshot of the buffered-but-not-yet-delivered items (e.g. runs that SETTLED but whose
|
|
747
|
-
* follow-up hasn't flushed yet). Lets `intercom
|
|
748
|
-
* settle→deliver gap instead of reporting nothing
|
|
749
|
-
* the pending flush won't re-deliver it. */
|
|
798
|
+
* follow-up hasn't flushed yet). Lets `intercom peek` surface results in the
|
|
799
|
+
* settle→deliver gap instead of reporting nothing. Peeking does not collect: only an explicit
|
|
800
|
+
* `result` `discard`s the item so the pending flush won't re-deliver it. */
|
|
750
801
|
peekPending(): T[] {
|
|
751
802
|
return [...this.pending];
|
|
752
803
|
}
|
|
753
804
|
|
|
754
805
|
/** Drop buffered items matching `pred` — e.g. results the supervisor already collected
|
|
755
|
-
*
|
|
806
|
+
* explicitly (intercom `result`), which must not be re-delivered as a follow-up. */
|
|
756
807
|
discard(pred: (item: T) => boolean): void {
|
|
757
808
|
for (let i = this.pending.length - 1; i >= 0; i--) {
|
|
758
809
|
if (pred(this.pending[i] as T)) this.pending.splice(i, 1);
|
|
@@ -776,7 +827,8 @@ export class IdleCoalescingNotifier<T> {
|
|
|
776
827
|
flushIfIdle(): Promise<void> {
|
|
777
828
|
if (this.flushing) return this.flushing;
|
|
778
829
|
this.clearArmedTimer();
|
|
779
|
-
|
|
830
|
+
const ready = this.readyItems();
|
|
831
|
+
if (ready.length === 0) return Promise.resolve();
|
|
780
832
|
|
|
781
833
|
const generation = this.generation;
|
|
782
834
|
let batch: T[] | undefined;
|
|
@@ -801,8 +853,9 @@ export class IdleCoalescingNotifier<T> {
|
|
|
801
853
|
}
|
|
802
854
|
}
|
|
803
855
|
|
|
804
|
-
const take = this.maxBatchItems === undefined ?
|
|
805
|
-
batch =
|
|
856
|
+
const take = this.maxBatchItems === undefined ? ready.length : Math.max(1, Math.floor(this.maxBatchItems(ready)));
|
|
857
|
+
batch = ready.slice(0, take);
|
|
858
|
+
for (const item of batch) this.pending.splice(this.pending.indexOf(item), 1);
|
|
806
859
|
const message = this.deps.render(batch);
|
|
807
860
|
if (!message) {
|
|
808
861
|
// Empty text from a non-empty batch is a renderer failure, not an acknowledgement: put the
|
|
@@ -877,8 +930,14 @@ export class IdleCoalescingNotifier<T> {
|
|
|
877
930
|
this.handle = undefined;
|
|
878
931
|
}
|
|
879
932
|
|
|
933
|
+
/** The buffered items that may be delivered now, in order. */
|
|
934
|
+
private readyItems(): T[] {
|
|
935
|
+
const { isReady } = this.deps;
|
|
936
|
+
return isReady ? this.pending.filter(isReady) : [...this.pending];
|
|
937
|
+
}
|
|
938
|
+
|
|
880
939
|
private arm(ms: number): void {
|
|
881
|
-
if (this.handle !== undefined || this.
|
|
940
|
+
if (this.handle !== undefined || this.readyItems().length === 0) return;
|
|
882
941
|
let scheduled!: unknown;
|
|
883
942
|
scheduled = this.deps.setTimer(() => {
|
|
884
943
|
if (this.handle !== scheduled) return; // cancelled/replaced timer racing its callback
|
package/src/engine/child.ts
CHANGED
|
@@ -509,7 +509,7 @@ export async function runChildAgent(
|
|
|
509
509
|
// start and was waiting on a slow first response.
|
|
510
510
|
result.errorMessage = `agent produced no progress within the ${opts.startupTimeoutMs}ms startup window — either it never started (a stalled init, e.g. an MCP adapter hanging) or its first provider response was slower than the window (a queued or rate-limited provider, a cold local model); raise or disable the window with PI_PERSONA_AGENT_STARTUP_MS (0 disables)${streamErr ? ` (last error: ${streamErr})` : ""}`;
|
|
511
511
|
} else if (timedOut) {
|
|
512
|
-
result.errorMessage = `agent timed out — no output for ${opts.timeoutMs}ms${streamErr ? ` (last error: ${streamErr})` : ""}`;
|
|
512
|
+
result.errorMessage = `agent timed out — no output for ${opts.timeoutMs}ms (raise this leg's timeoutMs if its tool is legitimately silent)${streamErr ? ` (last error: ${streamErr})` : ""}`;
|
|
513
513
|
} else if (aborted) {
|
|
514
514
|
result.errorMessage = `agent aborted${streamErr ? ` (last error: ${streamErr})` : ""}`;
|
|
515
515
|
} else if (oversizedLine) {
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** A missing hashed chunk in Pi's own bundle is a local host problem, not a provider outage.
|
|
2
|
+
* Pure and deliberately narrow: ordinary missing packages or a worker's own imports get no hint. */
|
|
3
|
+
export function piHostModuleHint(error: string): string | undefined {
|
|
4
|
+
const normalized = error.replaceAll("\\", "/");
|
|
5
|
+
const match = /Cannot find module (["'])([^"'\r\n]+\/@earendil-works\/pi-coding-agent\/dist\/bundle\/chunks\/[A-Za-z0-9_-]+-[A-Z0-9]{8}\.js)\1 imported from ([^\r\n]+)/.exec(normalized);
|
|
6
|
+
const missing = match?.[2];
|
|
7
|
+
const importer = match?.[3]?.trim();
|
|
8
|
+
if (!missing || !importer) return undefined;
|
|
9
|
+
const directory = missing.slice(0, missing.lastIndexOf("/") + 1);
|
|
10
|
+
if (!importer.startsWith(directory) || !/^[^/]+\.js$/.test(importer.slice(directory.length))) return undefined;
|
|
11
|
+
return "A local Pi host bundle module could not load; the host may have been updated in place while this session was running. Retry in a fresh Pi process with the same provider/model; only if the fresh process also fails, repair the Pi installation. Switching provider/model does not repair a missing local module.";
|
|
12
|
+
}
|