@bermudi/pi-delegate 0.1.10 → 0.1.12

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/extension.ts CHANGED
@@ -18,12 +18,13 @@ 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
- invalidateHostDepsCache,
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";
25
+ import { reconfigureGlobalConcurrency } from "./concurrency.ts";
26
+ import { reloadDelegateConfig, getMaxConcurrent } from "./config.ts";
27
+ import { warnLegacyDelegateSettingsMoved } from "./settings.ts";
27
28
  import {
28
29
  activeTicketSummary,
29
30
  clearDelegateStatusContext,
@@ -125,6 +126,17 @@ export default function delegateExtension(pi: ExtensionAPI): void {
125
126
  prepareArguments: normalizeDelegateArguments,
126
127
 
127
128
  async execute(_id, params: DelegateArguments, signal, onUpdate, ctx) {
129
+ // Reload user-edited delegate.json at the start of every execution.
130
+ // Help, poll, cancel, wait, and invalid calls observe new settings, and
131
+ // the global concurrency cap is reconfigured so hot-reloaded maxConcurrent
132
+ // takes effect for subsequent acquisitions. A parse/read error keeps the
133
+ // previous snapshot and warns instead of falling back to defaults.
134
+ warnLegacyDelegateSettingsMoved(ctx.cwd, (message) =>
135
+ ctx.ui.notify(message, "warning"),
136
+ );
137
+ reloadDelegateConfig();
138
+ reconfigureGlobalConcurrency(getMaxConcurrent());
139
+
128
140
  // Prime the UI notice for best-effort provider extensions that load for
129
141
  // subagents (host.ts consumes it where the fact is discovered). Every
130
142
  // execute re-primes so a stale ctx never sticks.
package/format.ts CHANGED
@@ -2,6 +2,7 @@ import * as fs from "node:fs";
2
2
  import * as os from "node:os";
3
3
  import * as path from "node:path";
4
4
  import { renderOutputForLLM } from "./spill.ts";
5
+ import { getOutputSpillThreshold, getOutputSpillTail } from "./config.ts";
5
6
  import type {
6
7
  ResolvedTask,
7
8
  TaskProgress,
@@ -355,7 +356,13 @@ function isResumableSessionFile(sessionFile: string): boolean {
355
356
  * notice instead of a retry hint — so the parent model is told to re-dispatch
356
357
  * fresh rather than left to fabricate a path or chase a dead resume.
357
358
  */
358
- export function formatFailedTask(r: TaskResult, cwd?: string): string[] {
359
+ export function formatFailedTask(
360
+ r: TaskResult,
361
+ cwd?: string,
362
+ /** Dispatch-scoped snapshot for output-spill bounds; falls back to live
363
+ * config when unset (direct callers, legacy tests). */
364
+ config?: import("./config.ts").DelegateConfig,
365
+ ): string[] {
359
366
  const parts: string[] = [];
360
367
  const isAbort = r.error === "Aborted";
361
368
  // Empty string is falsy but not nullish — `||` covers both undefined and "".
@@ -366,8 +373,16 @@ export function formatFailedTask(r: TaskResult, cwd?: string): string[] {
366
373
  parts.push(`[${isAbort ? "ABORTED" : "FAILED"}: ${failParts.join(" · ")}]`);
367
374
 
368
375
  // Surface partial assistant output even when the task did not complete.
376
+ // Pass the dispatch-scoped spill bounds so a mid-task config change cannot
377
+ // widen (or narrow) the partial output the parent sees — the bounds captured
378
+ // when the task started are the ones the caller committed to.
369
379
  if (r.output && r.output !== "(no output)") {
370
- parts.push(renderOutputForLLM(r.output, r.agent));
380
+ parts.push(
381
+ renderOutputForLLM(r.output, r.agent, {
382
+ thresholdChars: getOutputSpillThreshold(config),
383
+ tailChars: getOutputSpillTail(config),
384
+ }),
385
+ );
371
386
  }
372
387
 
373
388
  if (r.sessionFile && isResumableSessionFile(r.sessionFile)) {
@@ -414,6 +429,8 @@ export function formatFailedTask(r: TaskResult, cwd?: string): string[] {
414
429
  export function formatCompletedTask(
415
430
  task: ResolvedTask,
416
431
  result: TaskResult,
432
+ /** Dispatch-scoped snapshot for output-spill bounds. */
433
+ config?: import("./config.ts").DelegateConfig,
417
434
  ): string[] {
418
435
  const parts: string[] = [];
419
436
  // `|| task.sessionAction` covers action-only tasks (close/list/...) where prompt is
@@ -425,7 +442,7 @@ export function formatCompletedTask(
425
442
  for (const w of task.warnings) parts.push(`[WARNING: ${w}]`);
426
443
  }
427
444
  if (result.error) {
428
- parts.push(...formatFailedTask(result, task.cwd));
445
+ parts.push(...formatFailedTask(result, task.cwd, config));
429
446
  } else {
430
447
  const meta = [
431
448
  `OK | ${fmtDuration(result.durationMs)} | ${fmtTokens(result.tokens)} tokens`,
@@ -434,15 +451,44 @@ export function formatCompletedTask(
434
451
  const touched = relativeTouchedSummary(result.touchedFiles, task.cwd);
435
452
  if (touched) meta.push(`touched (best-effort): ${touched}`);
436
453
  parts.push(
437
- `[${meta.join(" · ")}]\n\n${renderOutputForLLM(result.output, result.agent)}`,
454
+ `[${meta.join(" · ")}]\n\n${renderOutputForLLM(
455
+ result.output,
456
+ result.agent,
457
+ {
458
+ thresholdChars: getOutputSpillThreshold(config),
459
+ tailChars: getOutputSpillTail(config),
460
+ },
461
+ )}`,
462
+ );
463
+ }
464
+ if (result.integration) {
465
+ const integration = result.integration;
466
+ parts.push(
467
+ `[INTEGRATION: ${integration.status} · proposed ${integration.proposedFiles.length} file(s) · applied ${integration.appliedFiles.length} file(s)]`,
438
468
  );
469
+ if (integration.baselineRef)
470
+ parts.push(`baseline ref: ${integration.baselineRef}`);
471
+ if (integration.proposalRef)
472
+ parts.push(`proposal ref: ${integration.proposalRef}`);
473
+ if (integration.patchPath)
474
+ parts.push(`full patch: ${integration.patchPath}`);
475
+ if (integration.worktreePath)
476
+ parts.push(`conflict worktree: ${integration.worktreePath}`);
477
+ for (const conflict of integration.conflicts ?? []) {
478
+ parts.push(`conflict: ${conflict.path}: ${conflict.reason}`);
479
+ }
480
+ if (integration.status === "applied_unverified") {
481
+ parts.push(
482
+ 'Changes were applied but not verified. Suggested next call: delegate({ tasks: [{ agent: "reviewer", workspace: "scratch", prompt: "Review the applied isolated changes and run the relevant tests." }] })',
483
+ );
484
+ }
439
485
  }
440
486
  return parts;
441
487
  }
442
488
 
443
489
  // ── Shared live-progress row helpers ───────────────────────────────────────
444
490
  // These dedupe the per-task computations the LLM-facing poll view
445
- // (tickets.handlePoll) and the TUI branches (render-branches) both need. Each is
491
+ // (ticket-format.ts) and the TUI branches (render-branches) both need. Each is
446
492
  // pure over TaskProgress/TaskResult, so it tests without a renderer.
447
493
 
448
494
  /** The in-flight tool activity (no result yet), or null — the "current thing
@@ -499,7 +545,7 @@ export function waitingLabel(runningCount: number, cap: number): string {
499
545
 
500
546
  /** Touched-files summary relative to cwd ("src/a.ts, src/b.ts"), or null when
501
547
  * none resolve under cwd. Was byte-for-byte duplicated in formatCompletedTask
502
- * and tickets.handlePoll. */
548
+ * and ticket-format.ts. */
503
549
  export function relativeTouchedSummary(
504
550
  files: string[],
505
551
  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
+ }