@nredd/pi-subagents 0.22.0-nredd.7 → 0.22.0-nredd.8

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.
Files changed (46) hide show
  1. package/README.md +124 -37
  2. package/package.json +8 -8
  3. package/src/agent-file-toggle.ts +6 -4
  4. package/src/agent-manager.ts +131 -28
  5. package/src/agent-runner.ts +155 -62
  6. package/src/cross-extension-rpc.ts +11 -36
  7. package/src/custom-agents.ts +9 -5
  8. package/src/default-agents.ts +2 -4
  9. package/src/enabled-models.ts +8 -7
  10. package/src/enterprise-local.ts +5 -5
  11. package/src/home-paths.ts +27 -0
  12. package/src/index.ts +191 -90
  13. package/src/invocation-config.ts +3 -16
  14. package/src/memory.ts +9 -9
  15. package/src/mention-clone.ts +9 -5
  16. package/src/model-resolver.ts +41 -11
  17. package/src/model-scope.ts +2 -1
  18. package/src/model-selection.ts +159 -0
  19. package/src/model-tiers.ts +171 -0
  20. package/src/nested-tools.ts +22 -26
  21. package/src/output-file.ts +23 -1
  22. package/src/package-resources.ts +25 -10
  23. package/src/process-budget.ts +6 -3
  24. package/src/quota-admission.ts +21 -9
  25. package/src/resource-loader.ts +51 -0
  26. package/src/schedule.ts +36 -18
  27. package/src/settings.ts +42 -12
  28. package/src/skill-loader.ts +8 -7
  29. package/src/stale-ctx.ts +16 -0
  30. package/src/types.ts +27 -0
  31. package/src/ui/agent-row.ts +13 -3
  32. package/src/ui/agent-widget.ts +15 -4
  33. package/src/ui/conversation-viewer.ts +6 -3
  34. package/src/ui/fallback-flag.ts +90 -0
  35. package/src/ui/fleet-list.ts +363 -53
  36. package/src/ui/fleet-state.ts +154 -0
  37. package/src/ui/notification-view.ts +18 -2
  38. package/src/ui/workflow-card.ts +5 -1
  39. package/src/ui/workflow-dialog.ts +10 -0
  40. package/src/workflow/host.ts +43 -45
  41. package/src/workflow/journal.ts +13 -1
  42. package/src/workflow/progress.ts +4 -0
  43. package/src/workflow/runtime.ts +25 -8
  44. package/src/workflow/saved.ts +19 -17
  45. package/src/workflow/task.ts +10 -0
  46. package/src/workflow/worker-source.ts +156 -38
@@ -30,7 +30,10 @@ import { getAgentConfig } from "./agent-types.js";
30
30
  import { type Clock, realClock, type TimerHandle } from "./clock.js";
31
31
  import { assignHandle, handleBase } from "./mention.js";
32
32
  import type { ModelCallEvent } from "./model-call.js";
33
- import { describeModel, describePhysicalModel, describeVirtualModel, isVirtualModel } from "./model-resolver.js";
33
+ import { describeModel, describePhysicalModel, describeVirtualModel, isVirtualModel, type ModelFallback } from "./model-resolver.js";
34
+ import { admitModelSelection, describeSelection, type ModelSelection, modelSelectionNote, selectModel } from "./model-selection.js";
35
+ import { type TierDisclosure, tierDisclosure } from "./model-tiers.js";
36
+ import { appendModelFallbackEntry, appendModelTierEntry } from "./output-file.js";
34
37
  import {
35
38
  assertWithinBudgetDepth,
36
39
  BUDGET_QUEUE_FULL_ERROR,
@@ -41,6 +44,8 @@ import {
41
44
  releaseBudget,
42
45
  tryAcquireBudget,
43
46
  } from "./process-budget.js";
47
+ import { describeBlock } from "./quota-admission.js";
48
+ import { isStaleCtxError } from "./stale-ctx.js";
44
49
  import type { AgentInvocation, AgentRecord, AgentTombstone, IsolationMode, MentionResolution, Pool, SubagentType, ThinkingLevel } from "./types.js";
45
50
  import { addUsage, type LifetimeUsage } from "./usage.js";
46
51
  import type { CompiledSchema } from "./workflow/json-schema.js";
@@ -59,6 +64,10 @@ function relabelFromResponse(record: AgentRecord, message: AssistantMessageInfo
59
64
  if (!physical) return;
60
65
  record.invocation ??= {};
61
66
  Object.assign(record.invocation, physical);
67
+ if ((record.modelTier?.kind === "routed" || record.modelTier?.kind === "static") && record.modelTier.answered !== physical.modelId) {
68
+ record.modelTier = { ...record.modelTier, answered: physical.modelId };
69
+ if (record.outputFile) appendModelTierEntry(record.outputFile, record.id, record.modelTier);
70
+ }
62
71
  }
63
72
 
64
73
  export type OnAgentComplete = (record: AgentRecord) => void;
@@ -163,12 +172,17 @@ export interface EvictedNote {
163
172
  evictedAfterMin: number;
164
173
  /** The `.output` transcript when one was written, else the session file. */
165
174
  transcript?: string;
175
+ /** The run's model fallback, so even an evicted agent's id still discloses it. */
176
+ modelFallback?: ModelFallback;
177
+ modelTier?: TierDisclosure;
166
178
  }
167
179
 
168
180
  /** The text a tool returns for an id whose record was evicted. */
169
181
  export function describeEvicted(note: EvictedNote): string {
170
182
  const where = note.transcript ? `; transcript: ${note.transcript}` : "";
171
- return `Agent "${note.id}" (${note.description}) ${note.status}, evicted after ${note.evictedAfterMin} min${where}`;
183
+ const disclosure = modelSelectionNote(note);
184
+ const fallback = disclosure ? `\n${disclosure}` : "";
185
+ return `Agent "${note.id}" (${note.description}) ${note.status}, evicted after ${note.evictedAfterMin} min${where}${fallback}`;
172
186
  }
173
187
 
174
188
  /**
@@ -282,6 +296,8 @@ interface SpawnArgs {
282
296
  }
283
297
 
284
298
  interface SpawnOptions {
299
+ /** Home inherited by nested callers; the manager's home is the default. */
300
+ homeDir?: string;
285
301
  description: string;
286
302
  /**
287
303
  * Optional memorable name for this instance, becoming a second handle
@@ -308,7 +324,14 @@ interface SpawnOptions {
308
324
  * `spawnTopLevel` strips it from anything a caller sends.
309
325
  */
310
326
  reclaim?: { handle: string; alias?: string };
311
- model?: Model<any>;
327
+ model?: Model<any> | string;
328
+ /** Internal preflight outcome, stored on the record without resolving again. */
329
+ modelSelection?: ModelSelection;
330
+ /** Historical outcome only, never used to label the continuation. */
331
+ previousModelTier?: TierDisclosure;
332
+ /** Historical fallback only, never used to label the continuation. */
333
+ previousModelFallback?: ModelFallback;
334
+
312
335
  maxTurns?: number;
313
336
  isolated?: boolean;
314
337
  inheritContext?: boolean;
@@ -472,6 +495,7 @@ const CHILD_SHUTDOWN_TIMEOUT_MS = 3_000;
472
495
  * `assertActive()` from a bare timer callback — an uncaughtException that kills pi (#242).
473
496
  */
474
497
  async function shutdownChildSession(session: AgentSession | undefined): Promise<void> {
498
+ let deadline: ReturnType<typeof setTimeout> | undefined;
475
499
  try {
476
500
  const runner = session?.extensionRunner;
477
501
  // Optional all the way down: on a pi without the getter, or a stubbed session from a
@@ -482,14 +506,44 @@ async function shutdownChildSession(session: AgentSession | undefined): Promise<
482
506
  // already torn down — one hung handler would leave a dead terminal.
483
507
  await Promise.race([
484
508
  runner.emit({ type: "session_shutdown", reason: "quit" }),
485
- new Promise<void>(resolve => setTimeout(resolve, CHILD_SHUTDOWN_TIMEOUT_MS).unref()),
509
+ new Promise<void>(resolve => {
510
+ deadline = setTimeout(resolve, CHILD_SHUTDOWN_TIMEOUT_MS);
511
+ deadline.unref();
512
+ }),
486
513
  ]);
487
514
  }
488
515
  } catch { /* a partial session must degrade, not take the teardown down with it */ }
516
+ finally {
517
+ // Promise.race does not cancel its loser, even when a handler rejects.
518
+ if (deadline !== undefined) clearTimeout(deadline);
519
+ }
489
520
  // Always, even on timeout: disposal is what this function ultimately exists to do.
490
521
  try { session?.dispose?.(); } catch { /* ignore */ }
491
522
  }
492
523
 
524
+ /**
525
+ * Wrap an observer the child's own agent loop calls (usage, compaction) so a
526
+ * throw cannot reach that loop. pi records a throw from a session listener as
527
+ * the turn's failure, so an observer closed over a replaced session's `pi`
528
+ * ("This extension ctx is stale ...") used to end a detached child with an
529
+ * empty error turn and "No output" (#145). That failure is expected after a
530
+ * reload and dropped silently. Any other throw is a real observer defect: it
531
+ * is logged once per observer and also kept from killing the run.
532
+ */
533
+ function guardObserver<A extends unknown[]>(name: string, observer: ((...args: A) => void) | undefined): ((...args: A) => void) | undefined {
534
+ if (!observer) return undefined;
535
+ let logged = false;
536
+ return (...args: A) => {
537
+ try {
538
+ observer(...args);
539
+ } catch (error) {
540
+ if (isStaleCtxError(error) || logged) return;
541
+ logged = true;
542
+ console.error(`pi-subagents: ${name} observer failed; further failures are not logged`, error);
543
+ }
544
+ };
545
+ }
546
+
493
547
  export class AgentManager {
494
548
  private agents = new Map<string, AgentRecord>();
495
549
  private cleanupInterval: TimerHandle;
@@ -561,11 +615,12 @@ export class AgentManager {
561
615
  onUsage?: OnAgentUsage,
562
616
  onSteered?: OnAgentSteered,
563
617
  private readonly clock: Clock = realClock,
618
+ private readonly homeDir?: string,
564
619
  ) {
565
620
  this.onComplete = onComplete;
566
621
  this.onStart = onStart;
567
- this.onCompact = onCompact;
568
- this.onUsage = onUsage;
622
+ this.onCompact = guardObserver("compaction", onCompact);
623
+ this.onUsage = guardObserver("usage", onUsage);
569
624
  this.onSteered = onSteered;
570
625
  this.maxConcurrent = maxConcurrent;
571
626
  // Sweep finished records past their retention window (see cleanup()).
@@ -645,6 +700,19 @@ export class AgentManager {
645
700
  // call, not minutes later at drain. Throw (not warn): programmatic callers
646
701
  // can fix and retry; the RPC layer converts throws into error envelopes.
647
702
  assertValidSpawnCwd(options.cwd);
703
+ const selection = options.modelSelection ?? selectModel({
704
+ homeDir: options.homeDir ?? this.homeDir,
705
+ agentType: type,
706
+ definitionModel: getAgentConfig(type)?.model,
707
+ paramModel: options.model,
708
+ parentModel: ctx.model,
709
+ registry: ctx.modelRegistry,
710
+ cwd: options.configCwd ?? ctx.cwd,
711
+ agentLabel: getAgentConfig(type)?.displayName,
712
+ });
713
+ if (typeof selection === "string") throw new Error(selection);
714
+ if (selection.scopeWarning && !options.modelSelection) ctx.ui.notify(selection.scopeWarning, "warning");
715
+ options = { ...options, model: selection.model, modelSelection: selection };
648
716
 
649
717
  const id = randomUUID().slice(0, 17);
650
718
  const abortController = new AbortController();
@@ -684,6 +752,11 @@ export class AgentManager {
684
752
  // `options` has stopped being the interesting object.
685
753
  blocking: options.blocking,
686
754
  invocation: options.invocation,
755
+ modelFallback: selection.fallback,
756
+ modelSelection: selection,
757
+ modelTier: selection.tier ? tierDisclosure(selection.tier) : undefined,
758
+ previousModelTier: options.previousModelTier,
759
+ previousModelFallback: options.previousModelFallback,
687
760
  depth: options.depth ?? 1,
688
761
  parentAgentId: options.parentAgentId,
689
762
  workflowId: options.workflowId,
@@ -826,28 +899,29 @@ export class AgentManager {
826
899
  release();
827
900
  markLeft();
828
901
  },
829
- cancel: () => {},
902
+ // enqueueBudget only registers; grant cannot run until the next pump,
903
+ // after this entry has been initialized and published in the queue.
904
+ cancel: enqueueBudget({
905
+ depth: record.depth ?? 1,
906
+ ready: () => pool === undefined || this.poolHasRoom(pool),
907
+ grant: (lease) => {
908
+ this.queue = this.queue.filter(e => e !== entry);
909
+ if (this.agents.get(id) !== record || record.status !== "queued") {
910
+ releaseBudget(lease);
911
+ entry.release();
912
+ return;
913
+ }
914
+ record.budgetLease = lease;
915
+ // Detached, and never rejects: a late failure (e.g. strict worktree
916
+ // isolation) lands on the record inside `launch`. The release waits
917
+ // for that startup to SETTLE: a release at grant time would wake a
918
+ // blocked `spawnAndWait` while `record.promise` was still undefined,
919
+ // and it would read a healthy agent as one that never ran.
920
+ void entry.start().then(() => entry.release(), () => entry.release());
921
+ },
922
+ }),
830
923
  left,
831
924
  };
832
- entry.cancel = enqueueBudget({
833
- depth: record.depth ?? 1,
834
- ready: () => pool === undefined || this.poolHasRoom(pool),
835
- grant: (lease) => {
836
- this.queue = this.queue.filter(e => e !== entry);
837
- if (this.agents.get(id) !== record || record.status !== "queued") {
838
- releaseBudget(lease);
839
- entry.release();
840
- return;
841
- }
842
- record.budgetLease = lease;
843
- // Detached, and never rejects: a late failure (e.g. strict worktree
844
- // isolation) lands on the record inside `launch`. The release waits
845
- // for that startup to SETTLE: a release at grant time would wake a
846
- // blocked `spawnAndWait` while `record.promise` was still undefined,
847
- // and it would read a healthy agent as one that never ran.
848
- void entry.start().then(() => entry.release(), () => entry.release());
849
- },
850
- });
851
925
  this.queue.push(entry);
852
926
  }
853
927
 
@@ -954,6 +1028,24 @@ export class AgentManager {
954
1028
  record.startGate = undefined;
955
1029
  this.chargePool(record.chargedPool, 1);
956
1030
 
1031
+ // Hold the pool slot BEFORE awaiting admission, just as for worktree creation.
1032
+ try {
1033
+ if (options.modelSelection?.tierBinding && !options.modelSelection.tierAdmitted) {
1034
+ const block = await admitModelSelection(options.modelSelection, ctx.modelRegistry, pi.events);
1035
+ record.modelTier = options.modelSelection.tier ? tierDisclosure(options.modelSelection.tier) : undefined;
1036
+ record.modelFallback = options.modelSelection.fallback;
1037
+ if (block) throw new Error(`${modelSelectionNote(record)} ${describeBlock(block)} The agent was not started.`);
1038
+ if (record.abortController?.signal.aborted || options.signal?.aborted) throw new Error("Agent aborted during model admission.");
1039
+ }
1040
+ if (record.modelTier) {
1041
+ record.invocation ??= {};
1042
+ Object.assign(record.invocation, describeSelection(options.modelSelection, ctx.modelRegistry));
1043
+ }
1044
+ } catch (error) {
1045
+ this.chargePool(record.chargedPool, -1);
1046
+ throw error;
1047
+ }
1048
+
957
1049
  // Worktree isolation: try to create a temporary git worktree. Strict —
958
1050
  // fail loud if not possible (no silent fallback to main tree). Done BEFORE
959
1051
  // the run is kicked off so a failure doesn't leave a half-running agent.
@@ -1035,8 +1127,10 @@ export class AgentManager {
1035
1127
 
1036
1128
  const promise = runAgent(ctx, type, prompt, {
1037
1129
  pi,
1130
+ homeDir: options.homeDir ?? this.homeDir,
1038
1131
  agentId: id,
1039
- model: options.model,
1132
+ model: options.modelSelection?.model,
1133
+ modelSelection: record.modelSelection!,
1040
1134
  maxTurns: options.maxTurns,
1041
1135
  isolated: options.isolated,
1042
1136
  inheritContext: options.inheritContext,
@@ -1104,7 +1198,9 @@ export class AgentManager {
1104
1198
  const requested = record.invocation.requestedThinking ?? record.invocation.thinking;
1105
1199
  Object.assign(
1106
1200
  record.invocation,
1107
- isVirtualModel(session.model) ? describeVirtualModel(session.model) : describeModel(session.model),
1201
+ record.modelTier?.kind === "routed" && record.modelTier.admitted
1202
+ ? describeSelection(options.modelSelection, ctx.modelRegistry)
1203
+ : isVirtualModel(session.model) ? describeVirtualModel(session.model) : describeModel(session.model),
1108
1204
  );
1109
1205
  // Guarded for the reason above: a session that reports no level keeps
1110
1206
  // the request rather than losing it. Overwriting unconditionally would
@@ -1117,6 +1213,9 @@ export class AgentManager {
1117
1213
  }
1118
1214
  }
1119
1215
  }
1216
+ // The transcript says it too: it is what someone opens to find out what ran.
1217
+ if (record.modelFallback && record.outputFile) appendModelFallbackEntry(record.outputFile, record.id, record.modelFallback);
1218
+ if (record.modelTier && record.outputFile) appendModelTierEntry(record.outputFile, record.id, record.modelTier);
1120
1219
  // Flush any steers that arrived before the session was ready
1121
1220
  if (record.pendingSteers?.length) {
1122
1221
  for (const msg of record.pendingSteers) {
@@ -1862,6 +1961,8 @@ export class AgentManager {
1862
1961
  description: record.description,
1863
1962
  sessionFile: record.sessionFile,
1864
1963
  completedAt: record.completedAt ?? this.clock.now(),
1964
+ ...(record.modelTier ? { modelTier: record.modelTier } : {}),
1965
+ ...(record.modelFallback ? { modelFallback: record.modelFallback } : {}),
1865
1966
  });
1866
1967
  // Bound the memory a long session can accumulate. Oldest first, since the
1867
1968
  // agent someone still wants to reach is the one they used most recently.
@@ -1882,6 +1983,8 @@ export class AgentManager {
1882
1983
  status: record.status,
1883
1984
  evictedAfterMin: Math.max(0, Math.round((this.clock.now() - completedAt) / 60_000)),
1884
1985
  transcript: record.outputFile ?? record.sessionFile,
1986
+ ...(record.modelTier ? { modelTier: record.modelTier } : {}),
1987
+ ...(record.modelFallback ? { modelFallback: record.modelFallback } : {}),
1885
1988
  });
1886
1989
  while (this.evictedNotes.size > MAX_EVICTED_NOTES) {
1887
1990
  const oldest = this.evictedNotes.keys().next().value;