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

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 (61) hide show
  1. package/README.md +245 -77
  2. package/package.json +16 -13
  3. package/src/abortable.ts +1 -1
  4. package/src/agent-file-toggle.ts +6 -5
  5. package/src/agent-manager.ts +489 -129
  6. package/src/agent-runner.ts +244 -93
  7. package/src/child-context.ts +19 -1
  8. package/src/completion-outbox.ts +22 -3
  9. package/src/cross-extension-rpc.ts +21 -42
  10. package/src/custom-agents.ts +11 -6
  11. package/src/default-agents.ts +21 -21
  12. package/src/detached-results.ts +18 -2
  13. package/src/enabled-models.ts +8 -7
  14. package/src/enterprise-local.ts +7 -6
  15. package/src/error-message.ts +11 -0
  16. package/src/git-path.ts +66 -0
  17. package/src/group-join.ts +4 -10
  18. package/src/home-paths.ts +27 -0
  19. package/src/index.ts +898 -297
  20. package/src/invocation-config.ts +4 -21
  21. package/src/memory.ts +9 -9
  22. package/src/mention-clone.ts +13 -8
  23. package/src/model-resolver.ts +92 -25
  24. package/src/model-scope.ts +2 -1
  25. package/src/model-selection.ts +170 -0
  26. package/src/model-tiers.ts +176 -0
  27. package/src/nested-tools.ts +99 -44
  28. package/src/notification-result.ts +118 -0
  29. package/src/output-file.ts +23 -1
  30. package/src/package-resources.ts +25 -10
  31. package/src/plan-mode-children.ts +176 -0
  32. package/src/process-budget.ts +6 -3
  33. package/src/prompts.ts +7 -2
  34. package/src/quota-admission.ts +35 -13
  35. package/src/resource-loader.ts +65 -0
  36. package/src/schedule.ts +94 -30
  37. package/src/settings.ts +63 -13
  38. package/src/skill-loader.ts +244 -93
  39. package/src/stale-ctx.ts +16 -0
  40. package/src/status-note.ts +28 -5
  41. package/src/types.ts +79 -2
  42. package/src/ui/agent-mention.ts +5 -2
  43. package/src/ui/agent-row.ts +13 -3
  44. package/src/ui/agent-widget.ts +55 -25
  45. package/src/ui/conversation-viewer.ts +10 -5
  46. package/src/ui/fallback-flag.ts +114 -0
  47. package/src/ui/fleet-list.ts +431 -86
  48. package/src/ui/fleet-state.ts +154 -0
  49. package/src/ui/notification-view.ts +22 -3
  50. package/src/ui/workflow-card.ts +6 -9
  51. package/src/ui/workflow-dialog.ts +36 -13
  52. package/src/workflow/host.ts +47 -50
  53. package/src/workflow/journal.ts +13 -1
  54. package/src/workflow/meta.ts +7 -4
  55. package/src/workflow/progress.ts +8 -3
  56. package/src/workflow/runtime.ts +48 -15
  57. package/src/workflow/saved.ts +21 -21
  58. package/src/workflow/task.ts +22 -7
  59. package/src/workflow/tool-description.ts +2 -2
  60. package/src/workflow/worker-source.ts +162 -40
  61. package/src/worktree.ts +309 -19
@@ -26,10 +26,11 @@
26
26
  *
27
27
  * That said: our injected globals are themselves host closures, so
28
28
  * `agent.constructor` is still the host `Function`. The hygiene shrinks the
29
- * surface; it does not close the hole. **`codeGeneration: { strings: false }` is
30
- * the load-bearing defense** — it makes `Function("…")` and `eval("…")` throw
31
- * `EvalError`, so a captured host `Function` cannot compile anything. Treat this
32
- * as a determinism boundary, not a security boundary against a hostile script.
29
+ * surface; it does not close the hole. `codeGeneration: { strings: false }`
30
+ * makes realm-local `Function` and `eval` throw `EvalError`, but does not stop a
31
+ * captured host `Function`. The stack formatter stays realm-native rather than
32
+ * adding another host callback. Treat this as a determinism boundary, not a
33
+ * security boundary against a hostile script.
33
34
  *
34
35
  * ## Why determinism is a prelude and not a stub
35
36
  *
@@ -48,9 +49,8 @@
48
49
  /**
49
50
  * Runs inside the realm, ahead of the script body, on a single line.
50
51
  *
51
- * One line matters: the body is compiled at `\n` + line 1, and the host passes
52
- * `lineOffset: -1` so reported line numbers match the file the author wrote. Any
53
- * newline in here shifts every stack frame in every workflow script.
52
+ * The shared compiler puts this and the wrapper on the body's first line.
53
+ * No lineOffset is needed (Bun ignores it); only first-line columns need mapping.
54
54
  */
55
55
  const DETERMINISM_PRELUDE =
56
56
  "const Date = (function () {" +
@@ -96,13 +96,13 @@ const pendingCalls = new Map();
96
96
  */
97
97
  let spentOutput = 0;
98
98
 
99
- function callHost(method, payload) {
99
+ function callHost(method, payload, onModelNote) {
100
100
  // Drain first, so the phase() that named this agent reaches the host ahead of
101
101
  // the agent entry rather than a tick behind it.
102
102
  flushProgress();
103
103
  return new Promise(function (resolve, reject) {
104
104
  const callId = nextCallId++;
105
- pendingCalls.set(callId, { resolve: resolve, reject: reject });
105
+ pendingCalls.set(callId, { resolve: resolve, reject: reject, onModelNote: onModelNote });
106
106
  port.postMessage({ type: "call", callId: callId, method: method, payload: payload });
107
107
  });
108
108
  }
@@ -114,6 +114,7 @@ port.on("message", function (message) {
114
114
  if (!waiter) return;
115
115
  pendingCalls.delete(message.callId);
116
116
  if (message.ok) {
117
+ if (message.modelNote !== undefined && waiter.onModelNote) waiter.onModelNote(message.modelNote);
117
118
  waiter.resolve(message.value);
118
119
  return;
119
120
  }
@@ -523,6 +524,8 @@ async function agentIn(scope, prompt, opts) {
523
524
  const phaseIndex = phaseName !== undefined ? definePhaseIn(scope, phaseName) : scope.ambientPhaseIndex;
524
525
  const phaseTitle = phaseName !== undefined ? scopedTitle(scope, phaseName) : scope.ambientPhaseTitle;
525
526
 
527
+ // Set when the host ran the child on another model than configured.
528
+ let modelNote;
526
529
  const result = await callHost("agent", {
527
530
  prompt: text,
528
531
  label: label,
@@ -535,20 +538,60 @@ async function agentIn(scope, prompt, opts) {
535
538
  resume: resume,
536
539
  effort: effort,
537
540
  schema: schema,
538
- });
539
- if (result === undefined || result === null) return null;
541
+ }, function (note) { modelNote = note; });
542
+ if (result === undefined || result === null) {
543
+ // A failed child is a null; its note would otherwise be lost with it.
544
+ if (modelNote !== undefined) logIn(scope, modelNote);
545
+ return null;
546
+ }
540
547
  if (schema === undefined) return result;
541
548
  // Parsed with the realm's own JSON.parse so the script gets an object whose
542
549
  // prototype is its own — \`x instanceof Object\` and \`x.list instanceof Array\`
543
550
  // both hold, and it survives assertBoundary if the script returns it.
544
551
  try {
545
- return realmParse(result);
552
+ const parsed = realmParse(result);
553
+ // A schema'd payload must stay exactly what the schema describes, so the
554
+ // note cannot lead it as text. It goes to the run log, and onto a plain
555
+ // object payload as a sibling \`_modelNote\` the script can read.
556
+ if (modelNote !== undefined) {
557
+ logIn(scope, modelNote);
558
+ if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) && canCarryModelNote(schema) && !Object.prototype.hasOwnProperty.call(parsed, "_modelNote")) parsed._modelNote = modelNote;
559
+ }
560
+ return parsed;
546
561
  } catch (error) {
547
562
  logIn(scope, "agent(): the host returned a structured result that is not JSON");
548
563
  return null;
549
564
  }
550
565
  }
551
566
 
567
+ /**
568
+ * Whether the host may add a sibling \`_modelNote\` to a schema'd payload.
569
+ *
570
+ * Deliberately conservative: only a PLAIN object-with-properties schema, using
571
+ * nothing but the keywords below, qualifies. Anything that could declare the
572
+ * field somewhere we do not look (allOf/anyOf/oneOf/$ref/patternProperties/
573
+ * if-then-else/dependent schemas/a schema-valued additionalProperties) or forbid
574
+ * it (additionalProperties: false) never gets an injected field, and the note
575
+ * goes to the run log instead.
576
+ */
577
+ var PLAIN_SCHEMA_KEYS = ["$schema", "type", "properties", "required", "description", "title"];
578
+ function canCarryModelNote(schema) {
579
+ if (!schema || typeof schema !== "object" || Array.isArray(schema)) return false;
580
+ if (schema.type !== "object") return false;
581
+ const props = schema.properties;
582
+ if (!props || typeof props !== "object" || Array.isArray(props)) return false;
583
+ if (Object.prototype.hasOwnProperty.call(props, "_modelNote")) return false;
584
+ const keys = Object.keys(schema);
585
+ for (let i = 0; i < keys.length; i++) {
586
+ if (PLAIN_SCHEMA_KEYS.indexOf(keys[i]) === -1) {
587
+ // \`additionalProperties: true\` changes nothing, so it stays plain.
588
+ if (keys[i] === "additionalProperties" && schema.additionalProperties === true) continue;
589
+ return false;
590
+ }
591
+ }
592
+ return true;
593
+ }
594
+
552
595
  /**
553
596
  * A barrier: every thunk starts now, and nothing past the await runs until all
554
597
  * of them have settled. A thunk that throws resolves to null rather than
@@ -675,19 +718,7 @@ async function workflowIn(scope, nameOrRef, args) {
675
718
  child.ambientPhaseIndex = definePhaseIn(child, undefined);
676
719
  child.ambientPhaseTitle = scopedTitle(child, undefined);
677
720
 
678
- let run;
679
- try {
680
- const compiled = new vm.Script(
681
- // \`meta\` is deliberately not a parameter: the body still opens with its
682
- // own \`const meta = { ... }\` (extractMeta strips only the \`export\`), so a
683
- // parameter of that name would collide with it.
684
- "(async (agent, phase, log, workflow, console, args) => {" + PRELUDE + "\\n" + loaded.body + "\\n})",
685
- { filename: "workflow:" + loaded.name + ".js", lineOffset: -1 }
686
- );
687
- run = compiled.runInContext(realmContext);
688
- } catch (error) {
689
- throw new Error('workflow("' + label + '"): ' + describe(error));
690
- }
721
+ const run = compileWorkflow(loaded.body, "workflow:" + loaded.name + ".js");
691
722
 
692
723
  const value = await run(child.agent, child.phase, child.log, child.workflow, child.console, args);
693
724
  checkBoundary(value, 'the result of workflow("' + label + '")');
@@ -721,6 +752,73 @@ function makeBudget() {
721
752
  };
722
753
  }
723
754
 
755
+ /* ------------------------------------------------------------------ *
756
+ * Authored source locations
757
+ * ------------------------------------------------------------------ */
758
+
759
+ // Neither lineOffset nor columnOffset is implemented by Bun 1.3/1.4. Keep
760
+ // authored lines intact and map only the prefix on line 1. Both entry points
761
+ // compile the same function; calling it outside the VM adds no synthetic frame.
762
+ const WORKFLOW_PREFIX = "(async (agent, phase, log, workflow, console, args) => {" + PRELUDE;
763
+ let registerWorkflowFile = null;
764
+
765
+ // Compiled from source in the realm, not installed as a host callback. Only
766
+ // primitive prefix length and filenames cross into its private realm-native Set.
767
+ function installAuthoredStacks(prefixLength) {
768
+ const workflowFiles = new Set();
769
+ function authoredFrame(frame) {
770
+ const text = String(frame);
771
+ const file = frame.getFileName();
772
+ if (!workflowFiles.has(file) || frame.getLineNumber() !== 1) return text;
773
+ const location = file + ":1:";
774
+ const at = text.lastIndexOf(location);
775
+ if (at === -1) return text;
776
+ const start = at + location.length;
777
+ // Bun 1.3's getColumnNumber() is zero-based, but CallSite.toString()
778
+ // is one-based on all supported engines.
779
+ const column = Number.parseInt(text.slice(start), 10);
780
+ if (column <= prefixLength) return text;
781
+ const digits = String(column).length;
782
+ return text.slice(0, start) + (column - prefixLength) + text.slice(start + digits);
783
+ }
784
+ Error.prepareStackTrace = function (error, frames) {
785
+ return Error.prototype.toString.call(error) + frames.map(function (frame) {
786
+ return "\\n at " + authoredFrame(frame);
787
+ }).join("");
788
+ };
789
+ return function (filename) { workflowFiles.add(filename); };
790
+ }
791
+
792
+ function compileWorkflow(body, filename) {
793
+ registerWorkflowFile(filename);
794
+ try {
795
+ return new vm.Script(WORKFLOW_PREFIX + body + "\\n})", { filename: filename }).runInContext(realmContext);
796
+ } catch (error) {
797
+ // Parser errors are created outside the realm. Retain the actual error,
798
+ // but remove the generated prefix from a first-line source/caret excerpt.
799
+ if (error && typeof error.stack === "string") {
800
+ if (error.stack.startsWith(filename + ":1\\n")) {
801
+ const lines = error.stack.split("\\n");
802
+ if (lines[1]?.startsWith(WORKFLOW_PREFIX)) {
803
+ lines[1] = lines[1].slice(WORKFLOW_PREFIX.length);
804
+ if (lines[2] !== undefined) lines[2] = lines[2].slice(WORKFLOW_PREFIX.length);
805
+ error.stack = lines.join("\\n");
806
+ }
807
+ } else if (error.sourceURL === filename && error.stack.startsWith("[native code]:0\\n")) {
808
+ // Bun 1.3 defers parsing until runInContext and loses the source
809
+ // excerpt/column. It still supplies the authored line; don't invent
810
+ // a caret for a column the engine did not report.
811
+ const line = error.line;
812
+ const sourceLines = body.split(/\\r\\n|[\\n\\r\\u2028\\u2029]/);
813
+ if (Number.isInteger(line) && line > 0 && line <= sourceLines.length) {
814
+ error.stack = filename + ":" + line + "\\n" + sourceLines[line - 1] + "\\n" + error.stack.slice(error.stack.indexOf("\\n") + 1);
815
+ }
816
+ }
817
+ }
818
+ throw error;
819
+ }
820
+ }
821
+
724
822
  /* ------------------------------------------------------------------ *
725
823
  * Run
726
824
  * ------------------------------------------------------------------ */
@@ -754,28 +852,52 @@ async function main() {
754
852
  sandbox.meta = realmParse(workerData.metaJson);
755
853
  sandbox.args = workerData.argsJson === undefined ? undefined : realmParse(workerData.argsJson);
756
854
 
757
- const script = new vm.Script("(async () => {" + PRELUDE + "\\n" + workerData.body + "\\n})()", {
758
- filename: "workflow.js",
759
- // The wrapper adds exactly one line above the body; undo it so a thrown
760
- // error points at the line the author wrote.
761
- lineOffset: -1,
762
- });
763
-
764
- const value = await script.runInContext(context);
855
+ // Structured CallSites retain constructor-expression columns on both V8 and
856
+ // JSC. Formatting changes no Error constructor, prototype, identity or cause.
857
+ registerWorkflowFile = vm.runInContext(
858
+ "(" + installAuthoredStacks.toString() + ")(" + WORKFLOW_PREFIX.length + ")",
859
+ context,
860
+ { filename: "workflow-stack.js" }
861
+ );
862
+ const run = compileWorkflow(workerData.body, "workflow.js");
863
+ const value = await run(rootScope.agent, rootScope.phase, rootScope.log, rootScope.workflow, rootScope.console, sandbox.args);
765
864
  checkBoundary(value, "the workflow result");
766
865
  flushProgress();
767
- port.postMessage({
866
+ const result = {
768
867
  type: "complete",
769
868
  resultJson: value === undefined ? undefined : JSON.stringify(value),
770
- });
869
+ };
870
+ if (workerData.collectCoverage) {
871
+ result.coverage = vm.runInContext("globalThis.__coverage__", context);
872
+ }
873
+ port.postMessage(result);
874
+ }
875
+
876
+ /** Serialize diagnostics only: keep realm errors untouched and bound cause traversal/output. */
877
+ function failureDetails(error) {
878
+ const limit = 16384;
879
+ let message = "Workflow threw an unreadable error.";
880
+ let stack = "";
881
+ const seen = new Set();
882
+ try {
883
+ message = String(error && error.message ? error.message : error).slice(0, 4096);
884
+ let current = error;
885
+ for (let depth = 0; depth < 8 && stack.length < limit; depth++) {
886
+ if (seen.has(current)) break;
887
+ seen.add(current);
888
+ const text = current && typeof current.stack === "string" ? current.stack : String(current);
889
+ stack += (depth === 0 ? "" : "\\nCaused by: ") + text.slice(0, limit - stack.length);
890
+ if (!current || current.cause === undefined) break;
891
+ current = current.cause;
892
+ }
893
+ } catch {
894
+ // A diagnostic getter must not prevent the failed run from settling.
895
+ }
896
+ return { type: "error", message: message, stack: stack.slice(0, limit) || undefined };
771
897
  }
772
898
 
773
899
  main().catch(function (error) {
774
900
  flushProgress();
775
- port.postMessage({
776
- type: "error",
777
- message: error && error.message ? String(error.message) : String(error),
778
- stack: error && error.stack ? String(error.stack) : undefined,
779
- });
901
+ port.postMessage(failureDetails(error));
780
902
  });
781
903
  `;