@evandu/super-pi 0.1.165 → 0.1.166

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/agents/coder.md +13 -1
  2. package/agents/critic.md +6 -2
  3. package/agents/patcher.md +6 -2
  4. package/agents/planner.md +14 -2
  5. package/agents/project-leader.md +25 -0
  6. package/agents/reviewer.md +13 -1
  7. package/agents/scout.md +6 -2
  8. package/agents/super-pi-task/core.ts +10 -6
  9. package/agents/verifier.md +6 -2
  10. package/dist/client/.super-pi-client-sources.json +10 -8
  11. package/dist/client/assets/{CodeMirrorEditor-B3-4Qpd1.js → CodeMirrorEditor-q5ckKYpW.js} +2 -2
  12. package/dist/client/assets/{dist-D45Y5G5v.js → dist-3Qp_-Z3t.js} +1 -1
  13. package/dist/client/assets/{dist-Do72hsvz.js → dist-B61EUjUz.js} +1 -1
  14. package/dist/client/assets/{dist-DF05sEDO.js → dist-BN9HZG4O.js} +1 -1
  15. package/dist/client/assets/{dist-rFG_iRW3.js → dist-BOU0i9JW.js} +1 -1
  16. package/dist/client/assets/{dist-DGOgW2hA.js → dist-BT3fZ0wT.js} +1 -1
  17. package/dist/client/assets/{dist-CN6rdrJA.js → dist-B_l0KBjV.js} +1 -1
  18. package/dist/client/assets/{dist-Di8I1Dsi.js → dist-Bb-gT7aj.js} +1 -1
  19. package/dist/client/assets/{dist-C2mhG_5m.js → dist-CBqBGuBl.js} +1 -1
  20. package/dist/client/assets/{dist-Cn4Q7-eG.js → dist-CXXAxAE4.js} +1 -1
  21. package/dist/client/assets/{dist-DtNwDyK1.js → dist-Crew6BC1.js} +1 -1
  22. package/dist/client/assets/{dist-CL9_Ff9R.js → dist-Cs1P7JrO.js} +1 -1
  23. package/dist/client/assets/{dist-wW-2h0X5.js → dist-CyTiEqGG.js} +1 -1
  24. package/dist/client/assets/{dist-hUAwe2Kn.js → dist-D10kcMrw.js} +1 -1
  25. package/dist/client/assets/{dist-DRgOr5sP.js → dist-DMynScoj.js} +1 -1
  26. package/dist/client/assets/{dist-QTqWnNGE.js → dist-DRHWMjx_.js} +1 -1
  27. package/dist/client/assets/{dist-BIqjsoet.js → dist-DSIy9Ers.js} +1 -1
  28. package/dist/client/assets/{dist-2Ax3GOhT.js → dist-DdfZJR8e.js} +1 -1
  29. package/dist/client/assets/{dist-BD4w6y25.js → dist-DiqGp2ie.js} +1 -1
  30. package/dist/client/assets/{dist-Bh9jVMEA.js → dist-YFQ3ixZU.js} +1 -1
  31. package/dist/client/assets/{dist-BWsed8P8.js → dist-_pAt4bRw.js} +1 -1
  32. package/dist/client/assets/{dist-BhiW2HYK.js → dist-bLHDT2q5.js} +1 -1
  33. package/dist/client/assets/{dist-DQnuHIqA.js → dist-dbLsO_41.js} +1 -1
  34. package/dist/client/assets/{index-Wtlj0s4R.css → index-C6YWfUu2.css} +1 -1
  35. package/dist/client/assets/{index-BUTxkA3D.js → index-dARLbQ-8.js} +19 -19
  36. package/dist/client/index.html +2 -2
  37. package/dist/server/agents.d.ts +11 -0
  38. package/dist/server/agents.js +50 -6
  39. package/dist/server/application/project-service.js +12 -2
  40. package/dist/server/store.d.ts +17 -0
  41. package/dist/server/store.js +97 -1
  42. package/dist/server/subagent-runs.js +19 -1
  43. package/dist/server/task-tool.d.ts +1 -1
  44. package/dist/server/task-tool.js +4 -3
  45. package/dist/server/types.d.ts +40 -1
  46. package/package.json +9 -2
@@ -8,8 +8,8 @@
8
8
  <link rel="alternate icon" href="/favicon.ico" />
9
9
  <link rel="apple-touch-icon" href="/favicon.svg" />
10
10
  <title>Super Pi</title>
11
- <script type="module" crossorigin src="/assets/index-BUTxkA3D.js"></script>
12
- <link rel="stylesheet" crossorigin href="/assets/index-Wtlj0s4R.css">
11
+ <script type="module" crossorigin src="/assets/index-dARLbQ-8.js"></script>
12
+ <link rel="stylesheet" crossorigin href="/assets/index-C6YWfUu2.css">
13
13
  </head>
14
14
  <body>
15
15
  <div id="root"></div>
@@ -175,6 +175,16 @@ export interface ResolveAgentDirectoriesOptions {
175
175
  env?: NodeJS.ProcessEnv;
176
176
  rootDir?: string;
177
177
  }
178
+ /**
179
+ * The agents directory that ships WITH the package: the definitions Super Pi itself carries.
180
+ *
181
+ * WHY IT IS A FUNCTION AND NOT A CONSTANT. The package root is not the same directory in every
182
+ * layout - it is the repository root in a checkout and the installed package root in an npm install
183
+ * - so the path is derived from the same `rootDir` the discovery uses, and both sides of the
184
+ * "is this built-in?" question ask THIS function. If the two ever disagreed, a user agent could be
185
+ * filed as built-in and a built-in one as the user's, and the picker would sort them wrongly.
186
+ */
187
+ export declare function builtinAgentsDir(rootDir?: string): string;
178
188
  /**
179
189
  * Resolves the ordered list of directories to discover agents from:
180
190
  * 1. Custom directories configured by the user in app settings (or options)
@@ -187,6 +197,7 @@ export interface ResolveAgentDirectoriesOptions {
187
197
  export declare function resolveAgentDirectories(options?: ResolveAgentDirectoriesOptions): string[];
188
198
  export declare function readAgents(directory: string | readonly string[], options?: {
189
199
  mode?: AgentModeFilter;
200
+ builtinDir?: string;
190
201
  }): Promise<AgentScan>;
191
202
  /** Parses one definition file, or returns the reason it is not a usable agent. */
192
203
  export declare function parseDefinition(file: string, source: string): AgentDefinition | AgentProblem;
@@ -392,6 +392,18 @@ export function expandTilde(filepath) {
392
392
  }
393
393
  return filepath;
394
394
  }
395
+ /**
396
+ * The agents directory that ships WITH the package: the definitions Super Pi itself carries.
397
+ *
398
+ * WHY IT IS A FUNCTION AND NOT A CONSTANT. The package root is not the same directory in every
399
+ * layout - it is the repository root in a checkout and the installed package root in an npm install
400
+ * - so the path is derived from the same `rootDir` the discovery uses, and both sides of the
401
+ * "is this built-in?" question ask THIS function. If the two ever disagreed, a user agent could be
402
+ * filed as built-in and a built-in one as the user's, and the picker would sort them wrongly.
403
+ */
404
+ export function builtinAgentsDir(rootDir) {
405
+ return resolve(join(rootDir ?? resolve(import.meta.dirname, "../.."), "agents"));
406
+ }
395
407
  /**
396
408
  * Resolves the ordered list of directories to discover agents from:
397
409
  * 1. Custom directories configured by the user in app settings (or options)
@@ -433,11 +445,25 @@ export function resolveAgentDirectories(options = {}) {
433
445
  catch {
434
446
  // homedir() might throw in restricted sandbox environments
435
447
  }
436
- const root = options.rootDir ?? resolve(import.meta.dirname, "../..");
437
- add(join(root, "agents"));
448
+ add(builtinAgentsDir(options.rootDir));
438
449
  }
439
450
  return dirs;
440
451
  }
452
+ /**
453
+ * Whether two paths name the same directory.
454
+ *
455
+ * WHY THE COMPARISON IS NOT A PLAIN `===`. The paths that reach here were built by joining and
456
+ * resolving, so they are already absolute - but Windows paths are case-insensitive and a directory
457
+ * typed by hand (`d:\agents`) is the same directory as the one the app resolved itself
458
+ * (`D:\Agents`). Comparing them as strings would call the package's own agents directory "the user's"
459
+ * and put the built-in agents at the bottom of the picker, which is the defect this whole change is
460
+ * about.
461
+ */
462
+ function sameDirectory(left, right) {
463
+ const a = resolve(left);
464
+ const b = resolve(right);
465
+ return process.platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b;
466
+ }
441
467
  export async function readAgents(directory, options = {}) {
442
468
  const mode = options.mode ?? "primary";
443
469
  const rawDirs = Array.isArray(directory) ? directory : [directory];
@@ -445,6 +471,9 @@ export async function readAgents(directory, options = {}) {
445
471
  const agents = [];
446
472
  const problems = [];
447
473
  const seenIds = new Set();
474
+ // The directory whose definitions are the PACKAGE'S OWN. Everything else is the user's, and the
475
+ // two are listed separately so the package's agents can come first (see the sort at the end).
476
+ const builtinDir = options.builtinDir ?? builtinAgentsDir();
448
477
  for (const dir of dirs) {
449
478
  let entries;
450
479
  try {
@@ -455,6 +484,7 @@ export async function readAgents(directory, options = {}) {
455
484
  continue;
456
485
  throw error;
457
486
  }
487
+ const isBuiltin = sameDirectory(dir, builtinDir);
458
488
  for (const file of entries.filter((name) => name.toLowerCase().endsWith(".md")).sort()) {
459
489
  // A FILE THAT CANNOT BE READ IS THE SAME KIND OF PROBLEM AS ONE THAT CANNOT BE PARSED.
460
490
  //
@@ -487,17 +517,31 @@ export async function readAgents(directory, options = {}) {
487
517
  else {
488
518
  if (!seenIds.has(parsed.id)) {
489
519
  seenIds.add(parsed.id);
490
- agents.push({ ...parsed, sourceDir: dir });
520
+ agents.push({ ...parsed, sourceDir: dir, builtin: isBuiltin });
491
521
  }
492
522
  }
493
523
  }
494
524
  }
495
525
  // The half that was NOT asked for is filtered out here rather than marked in the UI: sub-agents are
496
526
  // invoked BY an agent and never chosen by a human, so listing one in the picker would promise a role
497
- // the runtime cannot honour. The remainder is alphabetical by id, so either roster reads as a list
498
- // rather than as a directory listing.
527
+ // the runtime cannot honour.
528
+ //
529
+ // The remainder is the PACKAGE'S OWN agents first, then the user's, each group alphabetical by id.
530
+ //
531
+ // WHY THE BUILT-IN GROUP COMES FIRST. The picker used to sort purely by id, which scattered the
532
+ // package's own agents - the ones drawn with a colour dot - among the user's own with no visible
533
+ // rule to say which was which. The built-in group is now a group, so the colours read as "these are
534
+ // the ones that ship with Super Pi" instead of as noise.
535
+ //
536
+ // WHY THE GROUP ORDER DOES NOT TOUCH THE OVERRIDE RULE. The rule that decides WHICH definition of
537
+ // an id survives was applied above, while reading: the first directory to offer an id wins, and the
538
+ // user's directories are read before the package's own. Sorting here only reorders definitions that
539
+ // already survived that decision, so a user's copy of a built-in agent still wins - and it is then
540
+ // listed as the user's, because it IS the user's file.
499
541
  return {
500
- agents: agents.filter((agent) => agent.mode === mode).sort((a, b) => a.id.localeCompare(b.id)),
542
+ agents: agents
543
+ .filter((agent) => agent.mode === mode)
544
+ .sort((a, b) => (a.builtin === b.builtin ? a.id.localeCompare(b.id) : a.builtin ? -1 : 1)),
501
545
  problems,
502
546
  };
503
547
  }
@@ -521,7 +521,15 @@ export class ProjectService {
521
521
  report zero running agents. The concurrency guard is not touched by this: the in-flight task
522
522
  it counts (`reserved`) already covers the busy leader, so the cap behaves exactly as before. */
523
523
  const leaderIsWorker = lightweight(p) && p.ownerConversationId !== undefined;
524
- return { tokens: sessions.reduce((sum, s) => sum + s.tokens, 0) + runs.reduce((sum, r) => sum + tokens(r.usage), 0),
524
+ /* ONE ACCOUNTING PATH, AND IT IS THE SESSION TREE.
525
+ The number below is the sum of the member sessions' usage, and NOTHING ELSE. A one-shot call
526
+ (a sub-agent) used to be added here from the run records on top of that sum, which was a
527
+ SECOND path to the same money: the run's usage was already inside the conversation's usage
528
+ (the store folds it in when the run settles), so the same tokens were counted twice.
529
+ Now it is counted once - through the session - and a project's budget therefore includes what
530
+ its one-shot calls cost, which is the whole point of a budget a person can predict.
531
+ See `Store.accountSubagentUsage`. */
532
+ return { tokens: sessions.reduce((sum, s) => sum + s.tokens, 0),
525
533
  usageKnown: sessions.length > 0 && sessions.every((s) => Boolean(this.store.conversation(s.id)?.usage)) && runs.every((r) => Boolean(r.usage)),
526
534
  activeWorkers: sessions.filter((s) => (!coordinatorIds.has(s.id) || (leaderIsWorker && s.id === p.ownerConversationId)) && busy(s.status)).length + runs.filter((r) => r.status === "running").length,
527
535
  // THE PROJECT'S OWN MEMORY TREE, named once so the client does not have to infer it from
@@ -679,7 +687,9 @@ export class ProjectService {
679
687
  lines.push(`- 预算上限:${p.budget?.maxTokens ?? DEFAULT_MAX_TOKENS} Token`);
680
688
  // THE USAGE IS REPORTED IN THE SAME BILLING-EQUIVALENT UNITS THE BUDGET IS SPENT IN, and the
681
689
  // formula is printed next to the number: a budget nobody can predict is a budget nobody trusts.
682
- lines.push(`- 已用等效 Token:${this.resources(p).tokens}(口径:输入 + 输出 + 缓存写入×${CACHE_WRITE_WEIGHT} + 缓存读取×${CACHE_READ_WEIGHT},取整)`);
690
+ // The sentence after the formula says what the number INCLUDES - a one-shot call (a sub-agent)
691
+ // is part of it, so a reader who dispatches children can see what that cost them.
692
+ lines.push(`- 已用等效 Token:${this.resources(p).tokens}(口径:输入 + 输出 + 缓存写入×${CACHE_WRITE_WEIGHT} + 缓存读取×${CACHE_READ_WEIGHT},取整;含项目内的一次性调用(subagent))`);
683
693
  lines.push(`- 任务:共 ${p.tasks.length} 个,已完成 ${p.tasks.filter((t) => t.status === "completed").length} 个`);
684
694
  lines.push("", "## 交付与验收", "");
685
695
  if (!p.tasks.length)
@@ -924,6 +924,23 @@ export declare class Store {
924
924
  * as durable as the conversation it belongs to.
925
925
  */
926
926
  setSubagentRun(runId: string, changes: SubagentRunUpdate): Promise<SubagentRun>;
927
+ /**
928
+ * Adds ONE settled run's usage to the ledger of the conversation that dispatched it.
929
+ *
930
+ * CALLED AT THE SETTLE, ONCE PER RUN, and the run's FINAL accumulated usage is what it reads - the
931
+ * run record already holds the total of every finished message the child produced, so nothing is
932
+ * re-derived here and no message is counted twice.
933
+ *
934
+ * THE FLAG AND THE LEDGER ENTRY ARE WRITTEN IN ONE WRITE, and that is the whole crash-safety
935
+ * argument: `foldRunUsage` mutates both in the same synchronous block and this persists the state
936
+ * once, so a server that dies here cannot leave the money counted twice (flag written, entry not)
937
+ * or not at all (entry written, flag not). A run that was folded already is left alone, so calling
938
+ * this twice is a no-op rather than a second bill.
939
+ *
940
+ * IT PERSISTS THE WHOLE STATE rather than the two rows, because the two rows ARE one fact: the
941
+ * run's flag and the conversation's ledger entry have to become true together or not at all.
942
+ */
943
+ accountSubagentUsage(runId: string): Promise<boolean>;
927
944
  /**
928
945
  * Replaces the model's task list, and persists it.
929
946
  *
@@ -105,6 +105,71 @@ function reconcileSubagentRuns(state) {
105
105
  }
106
106
  return orphaned;
107
107
  }
108
+ /**
109
+ * ONE RUN'S USAGE, FOLDED INTO THE LEDGER OF THE CONVERSATION THAT DISPATCHED IT.
110
+ *
111
+ * WHY THE CHILD'S MONEY BELONGS TO THE PARENT'S LEDGER. A one-shot call spends real money on a
112
+ * child Pi, and the only number a budget is compared against is a conversation's `usage`. A ledger
113
+ * that held the parent's own turns alone would let a conversation multiply its real cost past its
114
+ * own cap simply by dispatching children instead of answering - which is precisely what design §5
115
+ * exists to prevent ("写预算的人必须能自己算出这个数是多少钱"). So the child's FINAL accumulated
116
+ * usage is added to the parent's, once, when the run settles.
117
+ *
118
+ * ONCE, AND ONLY ONCE, AND WHY THE FLAG IS THE WHOLE MECHANISM. A run reports its usage on every
119
+ * finished assistant message, and the recorder settles the run with the run's TOTAL - so folding
120
+ * per message would bill every message twice. The fold therefore happens once, on the settled run,
121
+ * and `usageAccounted` says which side of that line the run is on. The flag and the ledger entry
122
+ * are written in ONE store write, so a server that dies between them cannot leave the money
123
+ * counted twice or not at all.
124
+ *
125
+ * THE FIELDS ARE ADDED, NOT RE-DERIVED. A conversation's usage and a run's usage are the same
126
+ * shape (`input` / `output` / `cacheRead` / `cacheWrite` / `cost` / `turns`), so the sum is a sum -
127
+ * no second pricing formula, and no place where the two could disagree about what a token costs.
128
+ *
129
+ * A RUN WITH NO CONVERSATION IS NOT FOLDED, and is not an error: the conversation it belonged to
130
+ * was deleted while the child was running, so there is no ledger left to add to. Deleting a
131
+ * conversation deletes its runs too, so the money is not counted twice either - it is simply not
132
+ * counted, because nobody is left to be billed for it.
133
+ */
134
+ function foldRunUsage(state, run) {
135
+ if (!run?.usage || run.usageAccounted)
136
+ return false;
137
+ const conversation = (state.conversations ?? []).find((item) => item?.id === run.conversationId);
138
+ if (!conversation)
139
+ return false;
140
+ const previous = conversation.usage ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 };
141
+ conversation.usage = {
142
+ input: previous.input + run.usage.input,
143
+ output: previous.output + run.usage.output,
144
+ cacheRead: previous.cacheRead + run.usage.cacheRead,
145
+ cacheWrite: previous.cacheWrite + run.usage.cacheWrite,
146
+ cost: previous.cost + run.usage.cost,
147
+ turns: previous.turns + run.usage.turns,
148
+ };
149
+ conversation.usageUpdatedAt = now();
150
+ run.usageAccounted = true;
151
+ return true;
152
+ }
153
+ /**
154
+ * THE STARTUP SWEEP FOR RUNS THAT PREDATE THE LEDGER.
155
+ *
156
+ * A `state.json` written before the conversation ledger existed holds runs whose usage was never
157
+ * added to the conversation that dispatched them - because at the time, nothing did that. Left
158
+ * alone, the project's budget would silently DROP for every project that had already spent money
159
+ * on one-shot calls, which is the kind of change nobody can explain afterwards.
160
+ *
161
+ * So every run that carries usage and has not been accounted for is folded in here, once, on the
162
+ * next boot. The flag makes the sweep idempotent: a run folded by this sweep is flagged, and the
163
+ * next boot leaves it alone. Mutated in place, like every other sweep in this file, and persisted
164
+ * by the single write `open` performs when this reports a change.
165
+ */
166
+ function foldUnaccountedRunUsage(state) {
167
+ let folded = 0;
168
+ for (const run of state.subagentRuns ?? [])
169
+ if (foldRunUsage(state, run))
170
+ folded += 1;
171
+ return folded;
172
+ }
108
173
  /**
109
174
  * Why a conversation the previous process left `running` is failed, in the same spirit and for the same
110
175
  * audience as `ORPHANED_RUN_ERROR`: this lands in a field the chat pane renders verbatim, so it names the
@@ -634,11 +699,15 @@ export class Store {
634
699
  const interrupted = reconcileConversations(state);
635
700
  const requeued = reconcileWaitingMessages(state);
636
701
  const todos = reconcileTodos(state);
702
+ // THE USAGE SWEEP. A run recorded before the conversation ledger existed has its money sitting in
703
+ // the run record and nowhere else, so the ledger is brought up to date here - once, and only
704
+ // for the runs that carry usage and have not been accounted for.
705
+ const foldedUsage = foldUnaccountedRunUsage(state);
637
706
  const store = new Store(repository, normalizeMessageTimestamps(state));
638
707
  // Persisted here, in `open`, rather than left for the first unrelated write: a reconciled record that
639
708
  // is only rewritten by a later mutation would still read `running` to anything that opened the file
640
709
  // in the meantime, and would be reconciled a SECOND time on the next restart.
641
- if (defaulted || orphaned.length || orphanedWorkflows.length || orphanedTasks.changed || interrupted.length || requeued.recovered.length || requeued.changed || todos.changed) {
710
+ if (defaulted || orphaned.length || orphanedWorkflows.length || orphanedTasks.changed || interrupted.length || requeued.recovered.length || requeued.changed || todos.changed || foldedUsage) {
642
711
  for (const run of orphaned)
643
712
  console.warn(`Super Pi: sub-agent run ${run.id} was still marked running when the last server stopped - it is now failed, because no child process survived it.`);
644
713
  for (const plan of orphanedWorkflows)
@@ -660,6 +729,10 @@ export class Store {
660
729
  console.warn(`Super Pi: message ${entry.message.id} of conversation ${entry.conversationId} was still marked sending when the last server stopped - it is waiting again and cancellable, because the Pi that would have delivered it is gone.`);
661
730
  for (const entry of requeued.folded)
662
731
  console.warn(`Super Pi: message ${entry.message.id} of conversation ${entry.conversationId} was waiting in the old queue format - it has been moved into that conversation's transcript.`);
732
+ // Announced, because a budget that silently changed shape is a budget nobody can explain: this
733
+ // says the money those runs spent is now part of the conversations that dispatched them.
734
+ if (foldedUsage)
735
+ console.warn(`Super Pi: the usage of ${foldedUsage} sub-agent run(s) recorded before the usage ledger existed was added to the conversations that dispatched them.`);
663
736
  await store.persist();
664
737
  }
665
738
  return store;
@@ -2542,6 +2615,29 @@ export class Store {
2542
2615
  await this.persistTransaction({ kind: "subagent.replace", run: structuredClone(run) });
2543
2616
  return structuredClone(run);
2544
2617
  }
2618
+ /**
2619
+ * Adds ONE settled run's usage to the ledger of the conversation that dispatched it.
2620
+ *
2621
+ * CALLED AT THE SETTLE, ONCE PER RUN, and the run's FINAL accumulated usage is what it reads - the
2622
+ * run record already holds the total of every finished message the child produced, so nothing is
2623
+ * re-derived here and no message is counted twice.
2624
+ *
2625
+ * THE FLAG AND THE LEDGER ENTRY ARE WRITTEN IN ONE WRITE, and that is the whole crash-safety
2626
+ * argument: `foldRunUsage` mutates both in the same synchronous block and this persists the state
2627
+ * once, so a server that dies here cannot leave the money counted twice (flag written, entry not)
2628
+ * or not at all (entry written, flag not). A run that was folded already is left alone, so calling
2629
+ * this twice is a no-op rather than a second bill.
2630
+ *
2631
+ * IT PERSISTS THE WHOLE STATE rather than the two rows, because the two rows ARE one fact: the
2632
+ * run's flag and the conversation's ledger entry have to become true together or not at all.
2633
+ */
2634
+ async accountSubagentUsage(runId) {
2635
+ const run = this.state.subagentRuns.find((item) => item.id === runId);
2636
+ if (!foldRunUsage(this.state, run))
2637
+ return false;
2638
+ await this.persist();
2639
+ return true;
2640
+ }
2545
2641
  /**
2546
2642
  * Replaces the model's task list, and persists it.
2547
2643
  *
@@ -305,7 +305,7 @@ export class SubagentRunRecorder {
305
305
  // `output`; the streamed copy is only a fallback for a child that never sent a `done`.
306
306
  const output = result.finalText || streamed;
307
307
  const truncated = output.length > PER_TASK_OUTPUT_CAP;
308
- return await settle({
308
+ const settled = await settle({
309
309
  status: "completed",
310
310
  output: truncated ? output.slice(0, PER_TASK_OUTPUT_CAP) : output,
311
311
  // `undefined` rather than `false`: the key is absent on an untruncated run, so it serializes
@@ -321,6 +321,24 @@ export class SubagentRunRecorder {
321
321
  failure: undefined,
322
322
  finishedAt: new Date().toISOString(),
323
323
  });
324
+ // THE CHILD'S MONEY IS THE CALLER'S MONEY, AND IT IS ADDED ONCE.
325
+ //
326
+ // The run has just been settled with its FINAL accumulated usage, and that is the number the
327
+ // dispatching conversation's ledger is given - not one message's worth, and not one addition
328
+ // per message the child produced. A project budget reads the conversation's ledger, so a child
329
+ // that is not in there is a child nobody is charged for.
330
+ //
331
+ // A FAILURE HERE IS ANNOUNCED AND SWALLOWED: the run record is already on disk, so the work is
332
+ // real and recorded, and losing the ledger entry must not turn a finished run into a failed one.
333
+ // The next start folds it in - the startup sweep accounts for every run whose usage has not been
334
+ // accounted for - so the money is late rather than lost.
335
+ try {
336
+ await this.hooks.store.accountSubagentUsage(id);
337
+ }
338
+ catch (error) {
339
+ console.warn(`Super Pi: the usage of sub-agent run ${id} could not be added to the usage of conversation ${conversationId}: ${error instanceof Error ? error.message : "unknown error"}`);
340
+ }
341
+ return settled;
324
342
  }
325
343
  catch (error) {
326
344
  const stopped = error instanceof SubagentCancelled;
@@ -262,7 +262,7 @@ export declare const SESSION_STOP_TOOL_NAME = "session_stop";
262
262
  export declare const SESSION_REPORT_TOOL_NAME = "session_report";
263
263
  export declare const SESSION_STOP_SUBTREE_TOOL_NAME = "session_stop_subtree";
264
264
  /** Model-facing description for session_spawn. */
265
- export declare const SESSION_SPAWN_DESCRIPTION = "Create a new sibling conversation in the current workspace and immediately dispatch its initial task.\n\nDIFFERENCE FROM THE 'task' TOOL:\n- The 'task' tool delegates to a transient, one-shot subagent process (scout, critic, patcher) that runs once, returns its final text, and terminates. It has no conversation history and does not appear in the workspace sidebar.\n- 'session_spawn' creates a real, persistent sibling CONVERSATION in Super Pi. It appears in the sidebar, retains multi-turn message history, can receive follow-up messages via session_dispatch, can be inspected via session_status, and can be stopped via session_stop. The user can also switch to it in the Web UI.\n\nWHEN TO USE THIS:\n- Long-running, multi-phase tracks of work that require ongoing context, multiple turns of dialogue, or independent user visibility.\n- Parallel work streams (e.g., Track A: backend refactor, Track B: frontend component update) where both need full conversational capabilities.\n- When the user asks you to act as an orchestrator/manager who delegates to multiple sessions.\n\nWHEN NOT TO USE THIS:\n- For one-off, focused delegation with a single answer: use 'task' (with scout/critic/patcher). It is faster, cheaper, and returns directly to your transcript.\n- For running shell commands or build watchers: use 'run_async'.\n- For a linear multi-step subagent pipeline: use 'workflow'.\n\nUsage notes:\n1. Spawning is ASYNCHRONOUS by default: it creates the conversation, enqueues the initial task, and returns the new conversationId immediately. The sibling session runs in the background.\n2. The initial task should provide clear context and goals since the new session starts with a fresh conversation history.\n3. Use session_status with the returned conversationId to check progress, or session_dispatch to send further messages.\n4. The new session reports completion to its DIRECT parent automatically. 'parentConversationId' optionally selects that parent; by default it is the calling session.\n5. The new session always belongs to the calling session's workspace. Pass 'cwd' as a workspace-relative subdirectory (for example, 'packages/a') when it should work below the workspace root; absolute paths and traversal are rejected.\n6. Optionally pass 'model' to choose which model the NEW session runs on: {\"provider\":\"...\",\"modelId\":\"...\"} - the same \"provider/modelId\" pair the model picker shows, and BOTH halves are required. Leave 'model' out and the new session starts on the workspace default model.\n7. Optionally pass 'watchdogTimeoutSeconds' to declare how long YOU are willing to wait for a report before you want to be reminded: a positive number of seconds. If the new session stays silent that long, you receive a '[Watchdog \u63D0\u9192]' message naming it. A report from the child (session_report) restarts the clock; the watchdog never stops the child. Leave it out to run without a watchdog.";
265
+ export declare const SESSION_SPAWN_DESCRIPTION = "Create a child conversation in the current workspace and immediately dispatch its initial task. You can specify a primary agent as the child's role.\n\nDIFFERENCE FROM THE 'task' TOOL:\n- The 'task' tool delegates to a transient, one-shot subagent process (scout, critic, patcher) that runs once, returns its final text, and terminates. It has no conversation history and does not appear in the workspace sidebar.\n- 'session_spawn' creates a real, persistent child CONVERSATION in Super Pi. It appears in the sidebar, retains multi-turn message history, can receive follow-up messages via session_dispatch, can be inspected via session_status, and can be stopped via session_stop. The user can also switch to it in the Web UI.\n\nWHEN TO USE THIS:\n- Long-running, multi-phase tracks of work that require ongoing context, multiple turns of dialogue, or independent user visibility.\n- Multi-phase or complex work where the user needs each step to be visible and available for inspection or intervention: create a child session with session_spawn.\n- Parallel work streams (e.g., Track A: backend refactor, Track B: frontend component update) where both need full conversational capabilities.\n- When the user asks you to act as an orchestrator/manager who delegates to multiple sessions.\n\nWHEN NOT TO USE THIS:\n- For one-off, focused delegation with a single answer: use 'task' (with scout/critic/patcher). It is faster, cheaper, and returns directly to your transcript.\n- For running shell commands or build watchers: use 'run_async'.\n- For a single one-off delegation with a single answer: use 'task'.\n\nUsage notes:\n1. Spawning is ASYNCHRONOUS by default: it creates the conversation, enqueues the initial task, and returns the new conversationId immediately. The sibling session runs in the background.\n2. The initial task should provide clear context and goals since the new session starts with a fresh conversation history.\n3. Use session_status with the returned conversationId to check progress, or session_dispatch to send further messages.\n4. The new session reports completion to its DIRECT parent automatically. 'parentConversationId' optionally selects that parent; by default it is the calling session.\n5. The new session always belongs to the calling session's workspace. Pass 'cwd' as a workspace-relative subdirectory (for example, 'packages/a') when it should work below the workspace root; absolute paths and traversal are rejected.\n6. Optionally pass 'model' to choose which model the NEW session runs on: {\"provider\":\"...\",\"modelId\":\"...\"} - the same \"provider/modelId\" pair the model picker shows, and BOTH halves are required. Leave 'model' out and the new session starts on the workspace default model.\n7. Optionally pass 'watchdogTimeoutSeconds' to declare how long YOU are willing to wait for a report before you want to be reminded: a positive number of seconds. If the new session stays silent that long, you receive a '[Watchdog \u63D0\u9192]' message naming it. A report from the child (session_report) restarts the clock; the watchdog never stops the child. Leave it out to run without a watchdog.";
266
266
  /** Model-facing description for session_status. */
267
267
  export declare const SESSION_STATUS_DESCRIPTION = "Inspect the progress, run state, and latest activity of sibling conversations created by the orchestrator.\n\nWHEN TO USE THIS:\n- To check whether a spawned sibling conversation is still running, has finished (idle), is stopped, or encountered an error.\n- To read the latest assistant progress snippet without bloating your context with the session's entire raw message transcript.\n- To list all sibling conversations under your coordination when conversationId is omitted.\n\nUsage notes:\n1. When 'conversationId' is provided: returns a compact summary for that specific session, including its run status ('idle', 'running', 'waiting', 'stopped', 'failed'), messageCount, turnCount, lastAssistantSnippet, lastUserSnippet, activeTool, and error (if any).\n2. When 'conversationId' is omitted: returns a summary list of sibling conversations whose direct parent is 'parentConversationId' (the calling session by default).\n3. By default it never returns full transcripts; concise summaries keep your context window lean. Set 'includeOutput' with a conversationId to read the final output from its result file, capped to the last 64Ki characters with an explicit truncation notice.";
268
268
  /** Model-facing description for session_dispatch. */
@@ -367,21 +367,22 @@ export const SESSION_STOP_TOOL_NAME = "session_stop";
367
367
  export const SESSION_REPORT_TOOL_NAME = "session_report";
368
368
  export const SESSION_STOP_SUBTREE_TOOL_NAME = "session_stop_subtree";
369
369
  /** Model-facing description for session_spawn. */
370
- export const SESSION_SPAWN_DESCRIPTION = `Create a new sibling conversation in the current workspace and immediately dispatch its initial task.
370
+ export const SESSION_SPAWN_DESCRIPTION = `Create a child conversation in the current workspace and immediately dispatch its initial task. You can specify a primary agent as the child's role.
371
371
 
372
372
  DIFFERENCE FROM THE 'task' TOOL:
373
373
  - The 'task' tool delegates to a transient, one-shot subagent process (scout, critic, patcher) that runs once, returns its final text, and terminates. It has no conversation history and does not appear in the workspace sidebar.
374
- - 'session_spawn' creates a real, persistent sibling CONVERSATION in Super Pi. It appears in the sidebar, retains multi-turn message history, can receive follow-up messages via session_dispatch, can be inspected via session_status, and can be stopped via session_stop. The user can also switch to it in the Web UI.
374
+ - 'session_spawn' creates a real, persistent child CONVERSATION in Super Pi. It appears in the sidebar, retains multi-turn message history, can receive follow-up messages via session_dispatch, can be inspected via session_status, and can be stopped via session_stop. The user can also switch to it in the Web UI.
375
375
 
376
376
  WHEN TO USE THIS:
377
377
  - Long-running, multi-phase tracks of work that require ongoing context, multiple turns of dialogue, or independent user visibility.
378
+ - Multi-phase or complex work where the user needs each step to be visible and available for inspection or intervention: create a child session with session_spawn.
378
379
  - Parallel work streams (e.g., Track A: backend refactor, Track B: frontend component update) where both need full conversational capabilities.
379
380
  - When the user asks you to act as an orchestrator/manager who delegates to multiple sessions.
380
381
 
381
382
  WHEN NOT TO USE THIS:
382
383
  - For one-off, focused delegation with a single answer: use 'task' (with scout/critic/patcher). It is faster, cheaper, and returns directly to your transcript.
383
384
  - For running shell commands or build watchers: use 'run_async'.
384
- - For a linear multi-step subagent pipeline: use 'workflow'.
385
+ - For a single one-off delegation with a single answer: use 'task'.
385
386
 
386
387
  Usage notes:
387
388
  1. Spawning is ASYNCHRONOUS by default: it creates the conversation, enqueues the initial task, and returns the new conversationId immediately. The sibling session runs in the background.
@@ -464,7 +464,17 @@ export type Conversation = {
464
464
  status: RunStatus;
465
465
  messages: Message[];
466
466
  archivedAt?: string;
467
- /** Cumulative primary-assistant usage observed since readout collection began; excludes subagents. */
467
+ /**
468
+ * Cumulative usage observed for this conversation: its OWN assistant messages AND the one-shot
469
+ * (sub-agent) calls it dispatched.
470
+ *
471
+ * THE SUB-AGENT'S USAGE IS IN HERE ON PURPOSE. A child Pi spends real money, and a budget that
472
+ * only counts the parent's own turns is a budget that can be multiplied past its own cap by
473
+ * dispatching children instead of answering - which is exactly what design §5 forbids, because
474
+ * "写预算的人必须能自己算出这个数是多少钱" is only true if the number includes every call the
475
+ * conversation made. The child's final accumulated usage is folded in ONCE, when the run settles
476
+ * (see `Store.accountSubagentUsage`), so the sum here equals what was really spent.
477
+ */
468
478
  usage?: SubagentUsage;
469
479
  /** False means cost is only a sum of reported costs, not a complete price (zero is not "free"). */
470
480
  usageCostKnown?: boolean;
@@ -601,6 +611,21 @@ export type AgentDefinition = {
601
611
  prompt: string;
602
612
  /** The directory this agent definition was discovered from. */
603
613
  sourceDir?: string;
614
+ /**
615
+ * Whether this definition came from the PACKAGE'S OWN agents directory - the ones Super Pi ships -
616
+ * rather than from a directory the user pointed the app at.
617
+ *
618
+ * WHY IT IS DECIDED BY THE SERVER AND NOT BY THE CLIENT. The picker used to interleave the two
619
+ * kinds alphabetically, so the built-in agents - the ones with a colour dot - were scattered among
620
+ * the user's own. The client cannot work that out on its own: it is told where each definition came
621
+ * from, but "is this directory the package's own" is a question about the app's installation, and
622
+ * only the server knows where it is installed. So the server answers it here, at read time, and the
623
+ * client just orders by the answer.
624
+ *
625
+ * It describes the definition that WON, not the id: when a user's directory shadows a built-in
626
+ * agent of the same id, the user's file is the one that is listed, and it is the user's.
627
+ */
628
+ builtin?: boolean;
604
629
  };
605
630
  /**
606
631
  * What the picker receives. The prompt body is deliberately NOT sent: it is a whole system prompt,
@@ -830,6 +855,20 @@ export type SubagentRun = {
830
855
  /** True when `output` was cut for the model's benefit; the browser still saw all of it. */
831
856
  outputTruncated?: boolean;
832
857
  usage?: SubagentUsage;
858
+ /**
859
+ * TRUE once this run's usage has been folded into the usage ledger of the conversation that
860
+ * dispatched it.
861
+ *
862
+ * IT IS THE FLAG THAT MAKES THE FOLD HAPPEN EXACTLY ONCE. The child's money is added to the
863
+ * parent conversation's `usage` when the run settles, and a project budget reads that ledger -
864
+ * so a run whose usage were folded twice would be billed twice, and a run whose usage were never
865
+ * folded would be free. The flag says which side of that line this run is on, and it is written
866
+ * in the SAME store write as the ledger entry, so there is no window in which the two disagree.
867
+ *
868
+ * ABSENT means "not accounted for yet", which is what a `state.json` written before this feature
869
+ * existed says about every run in it - and what the startup sweep repairs, once, on the next boot.
870
+ */
871
+ usageAccounted?: boolean;
833
872
  error?: string;
834
873
  /**
835
874
  * How long the run took, measured by THIS server between writing the `running` row and settling it.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evandu/super-pi",
3
- "version": "0.1.165",
3
+ "version": "0.1.166",
4
4
  "description": "Web control plane and companion for Pi",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -48,6 +48,7 @@
48
48
  "scripts/stream-transport.test.mjs",
49
49
  "scripts/remote-transport.test.mjs",
50
50
  "scripts/browser-safe-port.test.mjs",
51
+ "scripts/agent-picker-order.test.mjs",
51
52
  "scripts/inline-thinking.test.mjs",
52
53
  "scripts/inline-thinking-close-tag.test.mjs",
53
54
  "scripts/pi-session.test.mjs",
@@ -69,6 +70,7 @@
69
70
  "scripts/verify-source-text.test.mjs",
70
71
  "scripts/runtime-extension-ui.test.mjs",
71
72
  "scripts/agent-mechanism.test.mjs",
73
+ "scripts/builtin-agents-project.test.mjs",
72
74
  "scripts/super-pi-tool-names.test.mjs",
73
75
  "scripts/agent-wiring.test.mjs",
74
76
  "scripts/message-timestamps.test.mjs",
@@ -95,6 +97,7 @@
95
97
  "scripts/subagent-noise.test.mjs",
96
98
  "scripts/subagent-response-receipt.test.mjs",
97
99
  "scripts/subagent-runs.test.mjs",
100
+ "scripts/subagent-usage-accounting.test.mjs",
98
101
  "scripts/ws-flood-regression.test.mjs",
99
102
  "scripts/conversation-reconcile.test.mjs",
100
103
  "scripts/subagent-dispatch.test.mjs",
@@ -226,7 +229,9 @@
226
229
  "scripts/session-report-tool.test.mjs",
227
230
  "scripts/session-stop-subtree.test.mjs",
228
231
  "scripts/awaiting-background-status.test.mjs",
229
- "scripts/project-lifecycle-e2e.test.mjs"
232
+ "scripts/project-lifecycle-e2e.test.mjs",
233
+ "scripts/agent-tiers.test.mjs",
234
+ "scripts/design-principles.test.mjs"
230
235
  ],
231
236
  "check:browser": [
232
237
  "scripts/interrupt-browser.test.mjs",
@@ -260,6 +265,8 @@
260
265
  "scripts/workspace-identity-browser.test.mjs",
261
266
  "scripts/sidebar-tools-browser.test.mjs",
262
267
  "scripts/project-employee-panel-browser.test.mjs",
268
+ "scripts/project-evidence-panel-browser.test.mjs",
269
+ "scripts/builtin-agents-project-browser.test.mjs",
263
270
  "scripts/tool-runtime-browser.test.mjs",
264
271
  "scripts/session-context-menu-browser.test.mjs",
265
272
  "scripts/archive-cascade-browser.test.mjs",