@bermudi/pi-delegate 0.1.9 → 0.1.11
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 +22 -7
- package/agents.ts +140 -14
- package/delegate.ts +3 -2
- package/dispatch.ts +26 -27
- package/extension.ts +2 -4
- package/format.ts +2 -2
- package/host-cache.ts +70 -0
- package/host.ts +164 -811
- package/lifecycle.ts +653 -508
- package/manual.ts +40 -10
- package/package.json +1 -1
- package/pi-package-source.ts +293 -0
- package/provider-extensions.ts +528 -0
- package/quiescence.ts +262 -0
- package/runner.ts +32 -140
- package/schema.ts +77 -153
- package/task-resolution.ts +60 -16
- package/ticket-format.ts +323 -0
- package/tickets.ts +79 -248
- package/tools.ts +12 -0
- package/trusted-paths.ts +71 -0
- package/types.ts +13 -17
package/README.md
CHANGED
|
@@ -47,9 +47,17 @@ The other built-ins are:
|
|
|
47
47
|
default. Set `workspace: "shared"` when a reviewer needs a persistent
|
|
48
48
|
`sessionId`.
|
|
49
49
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
50
|
+
A same-named Markdown file can override any built-in (first definition wins
|
|
51
|
+
across `.pi/agents/`, `~/.pi/agent/agents/`, `~/.agents/`, `.claude/agents/`,
|
|
52
|
+
`~/.claude/agents/`). A prompt-only override keeps the built-in's tools and
|
|
53
|
+
workspace — `scout` stays read-only and `reviewer` stays scratch unless the
|
|
54
|
+
file explicitly sets `tools` or `workspace`. Fresh built-ins inherit the
|
|
55
|
+
parent's exact model object and thinking level; an explicit `model`/`thinking`
|
|
56
|
+
in the Markdown file replaces that inheritance. Task fields always win, and for
|
|
57
|
+
`scout`/`coder`/`reviewer` settings overrides (`settings.json`
|
|
58
|
+
`delegate.agentOverrides` / `delegate.agentOverridesByParentModel`) win over the
|
|
59
|
+
Markdown file, while `default` ignores settings and uses only an explicit
|
|
60
|
+
Markdown `model`/`thinking` when present.
|
|
53
61
|
|
|
54
62
|
### Disposable scratch workspace
|
|
55
63
|
|
|
@@ -142,15 +150,22 @@ over an installed extension.
|
|
|
142
150
|
model by default.
|
|
143
151
|
- **Default subagent** — The reserved built-in `agent: "default"` profile. It
|
|
144
152
|
mirrors the live parent's model, thinking level, delegatable native tools, and
|
|
145
|
-
base system prompt while preserving delegate's extension/context isolation
|
|
153
|
+
base system prompt while preserving delegate's extension/context isolation;
|
|
154
|
+
a `default.md` Markdown file can override its prompt/tools/model/thinking
|
|
155
|
+
(first definition wins — a prompt-only file keeps the parent-mirrored tools
|
|
156
|
+
and thinking/model inheritance).
|
|
146
157
|
- **Custom agent** — A subagent profile defined by the parent, either inline in
|
|
147
158
|
a delegate task (`systemPrompt`, `tools`, and `thinking`) or persisted as a
|
|
148
159
|
Markdown file. The subagent inherits the parent model by default; `model` is a
|
|
149
160
|
rare override. Markdown agents are examples of custom agents.
|
|
150
161
|
- **Named agent** / **Markdown agent** — A reusable custom agent persisted as a
|
|
151
|
-
Markdown file in `.pi/agents/*.md
|
|
152
|
-
defines its name, description,
|
|
153
|
-
Markdown body is its system prompt.
|
|
162
|
+
Markdown file in `.pi/agents/*.md`, `~/.pi/agent/agents/*.md`, `~/.agents/*.md`,
|
|
163
|
+
`.claude/agents/*.md`, or `~/.claude/agents/*.md` (first definition wins). The frontmatter defines its name, description,
|
|
164
|
+
model, tools, and thinking level; the Markdown body is its system prompt. A
|
|
165
|
+
same-named file for a built-in (`default`/`scout`/`coder`/`reviewer`)
|
|
166
|
+
overrides that built-in; a prompt-only override keeps the built-in's tools
|
|
167
|
+
and workspace, and an explicit `model`/`thinking` replaces parent inheritance
|
|
168
|
+
(for `default` settings are ignored, for others settings win over the file).
|
|
154
169
|
- **Ad-hoc subagent** — A subagent created from inline task fields instead of a
|
|
155
170
|
named Markdown agent profile. In current output this is labeled `ad-hoc`.
|
|
156
171
|
- **Inline task** — The task object itself when its configuration is supplied
|
package/agents.ts
CHANGED
|
@@ -115,7 +115,7 @@ export function parseFrontmatter(
|
|
|
115
115
|
|
|
116
116
|
// ── Agent Discovery ───────────────────────────────────────────────────────
|
|
117
117
|
|
|
118
|
-
/** Built-in profiles are always available
|
|
118
|
+
/** Built-in profiles are always available but can be overridden by a same-named Markdown file. */
|
|
119
119
|
export const BUILTIN_AGENT_CONFIGS: Readonly<Record<string, AgentConfig>> = {
|
|
120
120
|
[DEFAULT_AGENT_NAME]: {
|
|
121
121
|
name: DEFAULT_AGENT_NAME,
|
|
@@ -253,12 +253,14 @@ export function loadAgentFile(filePath: string): AgentConfig | null {
|
|
|
253
253
|
}
|
|
254
254
|
const { data, body } = parseFrontmatter(content, filePath);
|
|
255
255
|
if (!data.name || !data.description) return null;
|
|
256
|
+
const model = data.model?.trim() || undefined;
|
|
257
|
+
const thinking = data.thinking?.trim();
|
|
256
258
|
return {
|
|
257
259
|
name: data.name,
|
|
258
260
|
description: data.description,
|
|
259
|
-
model
|
|
260
|
-
thinking: VALID_THINKING.has(
|
|
261
|
-
? (
|
|
261
|
+
model,
|
|
262
|
+
thinking: VALID_THINKING.has(thinking ?? "")
|
|
263
|
+
? (thinking as ThinkingLevel)
|
|
262
264
|
: "off",
|
|
263
265
|
// Omitted/blank `tools:` → inherit the full agent set (`*`), matching
|
|
264
266
|
// CC/OpenCode/Devin. A previous version rejected empty tools; that was
|
|
@@ -295,6 +297,8 @@ export function loadClaudeAgentFile(filePath: string): AgentConfig | null {
|
|
|
295
297
|
}
|
|
296
298
|
const { data, body } = parseFrontmatter(content, filePath);
|
|
297
299
|
if (!data.name || !data.description) return null;
|
|
300
|
+
const model = data.model?.trim();
|
|
301
|
+
const thinking = data.thinking?.trim();
|
|
298
302
|
|
|
299
303
|
// Track whether the user wrote an explicit `tools:` allowlist. Omitted or
|
|
300
304
|
// blank means "inherit the full default set" (`*`), and only in that case
|
|
@@ -327,13 +331,13 @@ export function loadClaudeAgentFile(filePath: string): AgentConfig | null {
|
|
|
327
331
|
name: data.name,
|
|
328
332
|
description: data.description,
|
|
329
333
|
// `inherit` is Claude's "use parent" default — drop it so we fall through
|
|
330
|
-
// to parent-model inheritance.
|
|
334
|
+
// to parent-model inheritance. Other values are stored trimmed.
|
|
331
335
|
model:
|
|
332
|
-
|
|
336
|
+
model && model.toLowerCase() === "inherit"
|
|
333
337
|
? undefined
|
|
334
|
-
:
|
|
335
|
-
thinking: VALID_THINKING.has(
|
|
336
|
-
? (
|
|
338
|
+
: model || undefined,
|
|
339
|
+
thinking: VALID_THINKING.has(thinking ?? "")
|
|
340
|
+
? (thinking as ThinkingLevel)
|
|
337
341
|
: "off",
|
|
338
342
|
tools,
|
|
339
343
|
systemPrompt: body,
|
|
@@ -382,6 +386,7 @@ export function discoverAgents(cwd: string): Map<string, AgentConfig> {
|
|
|
382
386
|
const agents = new Map<string, AgentConfig>(
|
|
383
387
|
Object.entries(BUILTIN_AGENT_CONFIGS),
|
|
384
388
|
);
|
|
389
|
+
const overriddenBuiltins = new Set<string>();
|
|
385
390
|
const loadDir = (
|
|
386
391
|
{ dir, scope }: { dir: string; scope: AgentConfig["scope"] },
|
|
387
392
|
loader: (fp: string) => AgentConfig | null,
|
|
@@ -396,13 +401,134 @@ export function discoverAgents(cwd: string): Map<string, AgentConfig> {
|
|
|
396
401
|
if (!e.name.endsWith(".md") || e.name.endsWith(".chain.md")) continue;
|
|
397
402
|
const filePath = path.join(dir, e.name);
|
|
398
403
|
const cfg = loader(filePath);
|
|
399
|
-
if (cfg
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
)
|
|
404
|
+
if (!cfg) continue;
|
|
405
|
+
if (isBuiltinAgentName(cfg.name)) {
|
|
406
|
+
const existing = agents.get(cfg.name);
|
|
407
|
+
if (existing?.builtin && !overriddenBuiltins.has(cfg.name)) {
|
|
408
|
+
// Markdown can override a built-in: first definition wins, replacing
|
|
409
|
+
// the default config. This lets users customize systemPrompt, tools,
|
|
410
|
+
// etc. via a Markdown file. Preserve builtin workspace when the file
|
|
411
|
+
// does not specify one (Markdown profiles have no workspace
|
|
412
|
+
// frontmatter today), and preserve builtin tools when the file omits
|
|
413
|
+
// the tools key so a prompt-only override does not silently escalate
|
|
414
|
+
// privileges (e.g. scout staying read-only).
|
|
415
|
+
// `disallowedTools` is only meaningful for Claude profiles (which
|
|
416
|
+
// actually implement the denylist); for native Pi loaders it is
|
|
417
|
+
// ignored, so it must not be treated as an explicit tools change.
|
|
418
|
+
let rawTools: string | undefined;
|
|
419
|
+
let rawWorkspace: string | undefined;
|
|
420
|
+
let rawDisallowedTools: string | undefined;
|
|
421
|
+
let rawModel: string | undefined;
|
|
422
|
+
let rawThinking: string | undefined;
|
|
423
|
+
try {
|
|
424
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
425
|
+
const { data } = parseFrontmatter(content, filePath);
|
|
426
|
+
rawTools = data.tools;
|
|
427
|
+
rawWorkspace = (data as Record<string, string>).workspace;
|
|
428
|
+
rawDisallowedTools = (data as Record<string, string>)
|
|
429
|
+
.disallowedTools;
|
|
430
|
+
rawModel = data.model;
|
|
431
|
+
rawThinking = data.thinking;
|
|
432
|
+
} catch {
|
|
433
|
+
// ignore, keep parsed tools/workspace
|
|
434
|
+
}
|
|
435
|
+
const hasExplicitAllowlist =
|
|
436
|
+
rawTools !== undefined && rawTools.trim() !== "";
|
|
437
|
+
const hasDenylist =
|
|
438
|
+
scope === "claude" &&
|
|
439
|
+
rawDisallowedTools !== undefined &&
|
|
440
|
+
rawDisallowedTools.trim() !== "";
|
|
441
|
+
const hasExplicitTools = hasExplicitAllowlist || hasDenylist;
|
|
442
|
+
const hasExplicitWorkspace =
|
|
443
|
+
rawWorkspace === "shared" || rawWorkspace === "scratch";
|
|
444
|
+
const hasInvalidWorkspace =
|
|
445
|
+
rawWorkspace !== undefined &&
|
|
446
|
+
rawWorkspace.trim() !== "" &&
|
|
447
|
+
!hasExplicitWorkspace;
|
|
448
|
+
const hasExplicitModel =
|
|
449
|
+
rawModel !== undefined &&
|
|
450
|
+
rawModel.trim() !== "" &&
|
|
451
|
+
rawModel.trim().toLowerCase() !== "inherit";
|
|
452
|
+
const hasExplicitThinking =
|
|
453
|
+
rawThinking !== undefined &&
|
|
454
|
+
rawThinking.trim() !== "" &&
|
|
455
|
+
VALID_THINKING.has(rawThinking.trim());
|
|
456
|
+
if (
|
|
457
|
+
cfg.name === DEFAULT_AGENT_NAME &&
|
|
458
|
+
!hasExplicitAllowlist &&
|
|
459
|
+
hasDenylist
|
|
460
|
+
) {
|
|
461
|
+
// For `default`, a deny-only override must be applied to the
|
|
462
|
+
// parent's actual tools at resolution, not to the static
|
|
463
|
+
// DEFAULT_TOOLS at discovery. Materializing against DEFAULT_TOOLS
|
|
464
|
+
// would grant write/edit when the parent is read-only.
|
|
465
|
+
const denied = new Set(
|
|
466
|
+
(rawDisallowedTools ?? "")
|
|
467
|
+
.split(",")
|
|
468
|
+
.map((s) => s.trim())
|
|
469
|
+
.filter(Boolean)
|
|
470
|
+
.map(
|
|
471
|
+
(n) =>
|
|
472
|
+
(CLAUDE_TOOL_ALIASES as Record<string, string>)[
|
|
473
|
+
n.toLowerCase()
|
|
474
|
+
] ?? null,
|
|
475
|
+
)
|
|
476
|
+
.filter((n): n is string => n !== null),
|
|
477
|
+
);
|
|
478
|
+
cfg.deniedTools = [...denied];
|
|
479
|
+
cfg.explicitTools = false;
|
|
480
|
+
// Keep tools display as the built-in default; resolution will
|
|
481
|
+
// filter parentNativeTools instead.
|
|
482
|
+
cfg.tools = existing.tools;
|
|
483
|
+
} else if (!hasExplicitAllowlist && hasDenylist && existing.tools) {
|
|
484
|
+
// No explicit allowlist but a Claude denylist is present – apply
|
|
485
|
+
// the denylist to the built-in's own toolset, not the generic
|
|
486
|
+
// full set, to avoid turning a denylist into an escalation
|
|
487
|
+
// (e.g. scout `disallowedTools: Bash` should stay read-only).
|
|
488
|
+
const denied = new Set(
|
|
489
|
+
(rawDisallowedTools ?? "")
|
|
490
|
+
.split(",")
|
|
491
|
+
.map((s) => s.trim())
|
|
492
|
+
.filter(Boolean)
|
|
493
|
+
.map(
|
|
494
|
+
(n) =>
|
|
495
|
+
(CLAUDE_TOOL_ALIASES as Record<string, string>)[
|
|
496
|
+
n.toLowerCase()
|
|
497
|
+
] ?? null,
|
|
498
|
+
)
|
|
499
|
+
.filter((n): n is string => n !== null),
|
|
500
|
+
);
|
|
501
|
+
cfg.tools = existing.tools.filter((t) => !denied.has(t));
|
|
502
|
+
cfg.explicitTools = true;
|
|
503
|
+
} else {
|
|
504
|
+
if (!hasExplicitTools && existing.tools) {
|
|
505
|
+
cfg.tools = existing.tools;
|
|
506
|
+
}
|
|
507
|
+
cfg.explicitTools = hasExplicitTools;
|
|
508
|
+
}
|
|
509
|
+
cfg.explicitModel = hasExplicitModel;
|
|
510
|
+
cfg.explicitThinking = hasExplicitThinking;
|
|
511
|
+
// Preserve built-in semantics for model/thinking/workspace handling
|
|
512
|
+
// in task-resolution – the overridden profile is still a built-in
|
|
513
|
+
// by name, just with a custom prompt/tools.
|
|
514
|
+
cfg.builtin = true;
|
|
515
|
+
if (!rawWorkspace?.trim() && existing.workspace) {
|
|
516
|
+
cfg.workspace = existing.workspace;
|
|
517
|
+
} else if (hasExplicitWorkspace) {
|
|
518
|
+
cfg.workspace = rawWorkspace as AgentConfig["workspace"];
|
|
519
|
+
} else if (hasInvalidWorkspace) {
|
|
520
|
+
console.warn(
|
|
521
|
+
`[delegate] invalid workspace '${rawWorkspace}' in ${filePath}; preserving built-in '${existing.workspace}'. Expected "shared" or "scratch".`,
|
|
522
|
+
);
|
|
523
|
+
if (existing.workspace) cfg.workspace = existing.workspace;
|
|
524
|
+
}
|
|
525
|
+
cfg.scope = scope;
|
|
526
|
+
agents.set(cfg.name, cfg);
|
|
527
|
+
overriddenBuiltins.add(cfg.name);
|
|
528
|
+
}
|
|
403
529
|
continue;
|
|
404
530
|
}
|
|
405
|
-
if (
|
|
531
|
+
if (!agents.has(cfg.name)) {
|
|
406
532
|
cfg.scope = scope;
|
|
407
533
|
agents.set(cfg.name, cfg);
|
|
408
534
|
}
|
package/delegate.ts
CHANGED
|
@@ -5,7 +5,6 @@ export type {
|
|
|
5
5
|
SessionAction,
|
|
6
6
|
WorkspaceMode,
|
|
7
7
|
TicketAction,
|
|
8
|
-
DelegateAction,
|
|
9
8
|
DelegateArguments,
|
|
10
9
|
TaskDef,
|
|
11
10
|
AsyncTicket,
|
|
@@ -72,9 +71,10 @@ export {
|
|
|
72
71
|
notifyWaiters,
|
|
73
72
|
deliverTicketResults,
|
|
74
73
|
resolveFinalTicketStatus,
|
|
74
|
+
settleTicket,
|
|
75
75
|
formatCompletedTicket,
|
|
76
76
|
} from "./tickets.ts";
|
|
77
|
-
export type { TicketDelivery } from "./tickets.ts";
|
|
77
|
+
export type { TicketDelivery, SettleTicketOptions } from "./tickets.ts";
|
|
78
78
|
export {
|
|
79
79
|
recordTreeNavigation,
|
|
80
80
|
getCurrentLeafId,
|
|
@@ -98,6 +98,7 @@ export {
|
|
|
98
98
|
export type { ActiveTicketSummary } from "./status.ts";
|
|
99
99
|
export { getHostDeps, invalidateHostDepsCache } from "./host.ts";
|
|
100
100
|
export type { HostDeps, HostDepsOptions } from "./host.ts";
|
|
101
|
+
export { registerProviderExtensionNotifier } from "./provider-extensions.ts";
|
|
101
102
|
export {
|
|
102
103
|
aggregateTaskResults,
|
|
103
104
|
emptyUsage,
|
package/dispatch.ts
CHANGED
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
deliverTicketResults,
|
|
7
7
|
sweepTickets,
|
|
8
8
|
resolveFinalTicketStatus,
|
|
9
|
-
|
|
9
|
+
settleTicket,
|
|
10
10
|
notifyWaiters,
|
|
11
11
|
} from "./tickets.ts";
|
|
12
12
|
import { getConcurrencyLimit, getMaxAsyncTickets } from "./config.ts";
|
|
@@ -319,6 +319,16 @@ export function dispatchAsync(input: AsyncDispatchInput): DelegateToolResult {
|
|
|
319
319
|
// Worker must store the TaskResult back into ticket.results, since
|
|
320
320
|
// formatCompletedTicket/handlePoll read from there. Without the write,
|
|
321
321
|
// completed async tasks would be reported as PENDING.
|
|
322
|
+
//
|
|
323
|
+
// Worker settlement is complete before these live-runtime observers run.
|
|
324
|
+
// In particular, result delivery is allowed to fail without re-entering the
|
|
325
|
+
// worker completion path; the terminal ticket remains available to poll.
|
|
326
|
+
const finishLiveSettlement = (t: AsyncTicket): void => {
|
|
327
|
+
syncDelegateStatus();
|
|
328
|
+
settleAsyncCall(t, callSpan);
|
|
329
|
+
finishTicketDelivery(pi, t);
|
|
330
|
+
};
|
|
331
|
+
|
|
322
332
|
const completion = mapConcurrentByModel(
|
|
323
333
|
resolved,
|
|
324
334
|
(t) => getModelKey(t.model),
|
|
@@ -346,21 +356,16 @@ export function dispatchAsync(input: AsyncDispatchInput): DelegateToolResult {
|
|
|
346
356
|
// NOT be marked "done" — that would mask incomplete work as
|
|
347
357
|
// complete. resolveFinalTicketStatus returns "failed" for that
|
|
348
358
|
// case and for any case with a failed task.
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
syncTicketBusyIndex(ticket);
|
|
360
|
-
}
|
|
361
|
-
syncDelegateStatus();
|
|
362
|
-
settleAsyncCall(ticket, callSpan);
|
|
363
|
-
finishTicketDelivery(pi, ticket);
|
|
359
|
+
// A "cancelling" ticket that outlived its workers settles as
|
|
360
|
+
// "cancelled": the per-task results record what actually happened;
|
|
361
|
+
// the ticket state reports that the batch was aborted by the caller.
|
|
362
|
+
settleTicket(ticket, {
|
|
363
|
+
status:
|
|
364
|
+
ticket.status === "running"
|
|
365
|
+
? resolveFinalTicketStatus(ticket)
|
|
366
|
+
: "cancelled",
|
|
367
|
+
});
|
|
368
|
+
finishLiveSettlement(ticket);
|
|
364
369
|
})
|
|
365
370
|
.catch((err) => {
|
|
366
371
|
// Defense-in-depth — should not happen if individual tasks catch properly.
|
|
@@ -370,17 +375,11 @@ export function dispatchAsync(input: AsyncDispatchInput): DelegateToolResult {
|
|
|
370
375
|
settleAsyncCall(ticket, callSpan);
|
|
371
376
|
return;
|
|
372
377
|
}
|
|
373
|
-
|
|
374
|
-
ticket.status
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
ticket.error = err instanceof Error ? err.message : String(err);
|
|
379
|
-
ticket.completedAt = Date.now();
|
|
380
|
-
syncTicketBusyIndex(ticket);
|
|
381
|
-
syncDelegateStatus();
|
|
382
|
-
settleAsyncCall(ticket, callSpan);
|
|
383
|
-
finishTicketDelivery(pi, ticket);
|
|
378
|
+
settleTicket(ticket, {
|
|
379
|
+
status: ticket.status === "cancelling" ? "cancelled" : "failed",
|
|
380
|
+
error: err instanceof Error ? err.message : String(err),
|
|
381
|
+
});
|
|
382
|
+
finishLiveSettlement(ticket);
|
|
384
383
|
});
|
|
385
384
|
ticket.completion = completion;
|
|
386
385
|
|
package/extension.ts
CHANGED
|
@@ -18,10 +18,8 @@ import {
|
|
|
18
18
|
} from "./dispatch.ts";
|
|
19
19
|
import { renderDelegateCall, renderDelegateResult } from "./render-result.ts";
|
|
20
20
|
import { hostCompatError } from "./host-compat.ts";
|
|
21
|
-
import {
|
|
22
|
-
|
|
23
|
-
registerProviderExtensionNotifier,
|
|
24
|
-
} from "./host.ts";
|
|
21
|
+
import { invalidateHostDepsCache } from "./host.ts";
|
|
22
|
+
import { registerProviderExtensionNotifier } from "./provider-extensions.ts";
|
|
25
23
|
import { recordTreeNavigation, resetLeafTracking } from "./leaf.ts";
|
|
26
24
|
import { closeAllPooledAgents } from "./pool.ts";
|
|
27
25
|
import {
|
package/format.ts
CHANGED
|
@@ -442,7 +442,7 @@ export function formatCompletedTask(
|
|
|
442
442
|
|
|
443
443
|
// ── Shared live-progress row helpers ───────────────────────────────────────
|
|
444
444
|
// These dedupe the per-task computations the LLM-facing poll view
|
|
445
|
-
// (
|
|
445
|
+
// (ticket-format.ts) and the TUI branches (render-branches) both need. Each is
|
|
446
446
|
// pure over TaskProgress/TaskResult, so it tests without a renderer.
|
|
447
447
|
|
|
448
448
|
/** The in-flight tool activity (no result yet), or null — the "current thing
|
|
@@ -499,7 +499,7 @@ export function waitingLabel(runningCount: number, cap: number): string {
|
|
|
499
499
|
|
|
500
500
|
/** Touched-files summary relative to cwd ("src/a.ts, src/b.ts"), or null when
|
|
501
501
|
* none resolve under cwd. Was byte-for-byte duplicated in formatCompletedTask
|
|
502
|
-
* and
|
|
502
|
+
* and ticket-format.ts. */
|
|
503
503
|
export function relativeTouchedSummary(
|
|
504
504
|
files: string[],
|
|
505
505
|
cwd: string,
|
package/host-cache.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A generation-guarded async memo.
|
|
3
|
+
*
|
|
4
|
+
* Host deps are expensive (`ResourceLoader.reload()` is ~1.2s cold) and are
|
|
5
|
+
* shared across the parallel tasks of a single delegate dispatch, then thrown
|
|
6
|
+
* away so the next dispatch observes edits to auth, models, settings, and
|
|
7
|
+
* context files. That gives three requirements the plain
|
|
8
|
+
* `Map<string, Promise<T>>` pattern does not meet:
|
|
9
|
+
*
|
|
10
|
+
* - **in-flight dedup** — concurrent tasks with the same key must await one
|
|
11
|
+
* build, not start N reloads;
|
|
12
|
+
* - **generation guard** — an invalidation during a build must not let that
|
|
13
|
+
* older build install its now-stale value, nor let it delete the in-flight
|
|
14
|
+
* marker a newer build has since installed for the same key;
|
|
15
|
+
* - **one invalidation path** — every reset goes through `invalidate()`.
|
|
16
|
+
* Before this was factored out, two test-only helpers cleared the maps
|
|
17
|
+
* directly without bumping the generation, leaving exactly the stale-write
|
|
18
|
+
* window the guard exists to close.
|
|
19
|
+
*/
|
|
20
|
+
export class GenerationCache<T> {
|
|
21
|
+
private entries = new Map<string, T>();
|
|
22
|
+
private inflight = new Map<string, Promise<T>>();
|
|
23
|
+
private generation = 0;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Return the cached value for `key`, joining an in-flight build or starting
|
|
27
|
+
* one. When `cacheable` is false the value is built and returned without ever
|
|
28
|
+
* being stored or shared.
|
|
29
|
+
*/
|
|
30
|
+
async resolve(
|
|
31
|
+
key: string,
|
|
32
|
+
cacheable: boolean,
|
|
33
|
+
build: () => Promise<T>,
|
|
34
|
+
): Promise<T> {
|
|
35
|
+
if (!cacheable) return build();
|
|
36
|
+
|
|
37
|
+
const cached = this.entries.get(key);
|
|
38
|
+
if (cached !== undefined) return cached;
|
|
39
|
+
const pending = this.inflight.get(key);
|
|
40
|
+
if (pending) return pending;
|
|
41
|
+
|
|
42
|
+
const generation = this.generation;
|
|
43
|
+
const promise = build().then((value) => {
|
|
44
|
+
// A build that outlived its generation is stale by definition; return it
|
|
45
|
+
// to its own caller but never publish it.
|
|
46
|
+
if (this.generation === generation) this.entries.set(key, value);
|
|
47
|
+
return value;
|
|
48
|
+
});
|
|
49
|
+
this.inflight.set(key, promise);
|
|
50
|
+
try {
|
|
51
|
+
return await promise;
|
|
52
|
+
} finally {
|
|
53
|
+
// An invalidation can let a newer generation install its own in-flight
|
|
54
|
+
// build for this key. Never let the older promise delete that marker.
|
|
55
|
+
if (this.inflight.get(key) === promise) this.inflight.delete(key);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Drop every cached and in-flight value, invalidating builds already running. */
|
|
60
|
+
invalidate(): void {
|
|
61
|
+
this.generation++;
|
|
62
|
+
this.entries.clear();
|
|
63
|
+
this.inflight.clear();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Cached values only — in-flight builds are not observable here. */
|
|
67
|
+
values(): Iterable<T> {
|
|
68
|
+
return this.entries.values();
|
|
69
|
+
}
|
|
70
|
+
}
|