@aeondave/pi-persona 1.17.1 → 1.17.3

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 (103) hide show
  1. package/AGENTS.md +59 -28
  2. package/CHANGELOG.md +234 -0
  3. package/README.md +16 -12
  4. package/agents/architect.md +21 -0
  5. package/agents/balthasar.md +17 -22
  6. package/agents/casper.md +17 -23
  7. package/agents/evidence-verifier.md +31 -16
  8. package/agents/melchior.md +17 -22
  9. package/agents/operator.md +15 -55
  10. package/agents/research.md +14 -48
  11. package/agents/reviewer.md +20 -39
  12. package/agents/scout.md +14 -15
  13. package/agents/verifier.md +15 -16
  14. package/docs/ARCHITECTURE.md +183 -55
  15. package/docs/EXPERIENCE.md +19 -9
  16. package/docs/MONITORS.md +62 -3
  17. package/docs/REFERENCE.md +82 -32
  18. package/docs/SPINE.md +2 -2
  19. package/docs/STRATEGIES.md +23 -8
  20. package/docs/TELEMETRY.md +1 -0
  21. package/flows/deep-review.flow.json +1 -1
  22. package/package.json +9 -8
  23. package/personas/audit.md +28 -17
  24. package/personas/dev.md +30 -32
  25. package/personas/elite.md +223 -221
  26. package/personas/judge.md +25 -19
  27. package/personas/magi.md +26 -29
  28. package/personas/planner.md +31 -24
  29. package/personas/researcher.md +25 -26
  30. package/personas/swarm.md +35 -25
  31. package/personas/verify.md +30 -19
  32. package/src/agents/agent.ts +20 -0
  33. package/src/bridge.ts +3 -2
  34. package/src/core/brief.ts +429 -356
  35. package/src/core/config.ts +15 -10
  36. package/src/core/model-catalog.ts +171 -0
  37. package/src/core/model-tier.ts +89 -0
  38. package/src/core/models.ts +13 -4
  39. package/src/core/monitor-output.ts +85 -0
  40. package/src/core/monitor.ts +34 -2
  41. package/src/core/nudge.ts +176 -23
  42. package/src/core/seed.ts +99 -6
  43. package/src/core/session-runs.ts +87 -0
  44. package/src/core/settings.ts +1 -1
  45. package/src/core/timer.ts +34 -9
  46. package/src/engine/adapter.ts +12 -8
  47. package/src/engine/async.ts +165 -146
  48. package/src/engine/child.ts +2 -2
  49. package/src/engine/host-module.ts +12 -0
  50. package/src/engine/inproc.ts +12 -7
  51. package/src/engine/stream.ts +4 -20
  52. package/src/exocom/install.ts +2 -1
  53. package/src/extension/engine.ts +43 -16
  54. package/src/extension/hooks.ts +106 -36
  55. package/src/extension/persona-model-settings.ts +31 -13
  56. package/src/extension/shared.ts +15 -7
  57. package/src/extension.ts +505 -241
  58. package/src/monitor/archive.ts +235 -0
  59. package/src/monitor/session.ts +21 -1
  60. package/src/orchestration/flow-journal.ts +26 -5
  61. package/src/orchestration/flow-run.ts +38 -13
  62. package/src/orchestration/flow.ts +9 -2
  63. package/src/orchestration/reducers.ts +9 -5
  64. package/src/orchestration/render.ts +1 -1
  65. package/src/orchestration/sdk.ts +17 -5
  66. package/src/orchestration/strategies/compete.ts +5 -1
  67. package/src/orchestration/strategies/council-rounds.ts +2 -0
  68. package/src/orchestration/strategies/critic-loop.ts +3 -0
  69. package/src/orchestration/strategies/debate.ts +2 -0
  70. package/src/orchestration/strategies/fanout.ts +3 -2
  71. package/src/orchestration/strategies/judge.ts +5 -1
  72. package/src/orchestration/strategies/magi.ts +40 -23
  73. package/src/orchestration/strategies/map.ts +13 -3
  74. package/src/orchestration/strategies/pair.ts +2 -0
  75. package/src/orchestration/strategies/pipeline.ts +2 -0
  76. package/src/orchestration/strategies/synthesize.ts +27 -7
  77. package/src/orchestration/strategy.ts +31 -0
  78. package/src/persona/persona.ts +7 -3
  79. package/src/telemetry/contract.ts +1 -1
  80. package/src/tools/background-run.ts +151 -0
  81. package/src/tools/council.ts +77 -52
  82. package/src/tools/delegate-tool.ts +310 -280
  83. package/src/tools/delegate.ts +22 -210
  84. package/src/tools/exocom-work.ts +18 -53
  85. package/src/tools/exocom.ts +15 -19
  86. package/src/tools/flow.ts +85 -28
  87. package/src/tools/intercom-tool.ts +108 -118
  88. package/src/tools/intercom.ts +1 -1
  89. package/src/tools/models.ts +19 -9
  90. package/src/tools/monitor.ts +39 -6
  91. package/src/tools/timer.ts +11 -13
  92. package/src/ui/agent-overlay.ts +55 -14
  93. package/src/ui/agent-tree.ts +84 -18
  94. package/src/ui/compact-widget.ts +25 -0
  95. package/src/ui/failure-summary.ts +55 -0
  96. package/src/ui/literal-tool-result.ts +24 -0
  97. package/src/ui/model-picker.ts +158 -81
  98. package/src/ui/persona-settings.ts +9 -3
  99. package/src/ui/presentation.ts +24 -0
  100. package/src/ui/report-body.ts +63 -0
  101. package/src/ui/session-cards.ts +189 -0
  102. package/src/ui/wake-rows.ts +262 -0
  103. package/teams.yaml +5 -1
@@ -7,6 +7,10 @@
7
7
 
8
8
  import { isThinkingLevel } from "./types.ts";
9
9
 
10
+ /** Settled-but-uncollected async runs retained by default. Equal to the `tasks` cap (RUN_LIMITS.maxChildren,
11
+ * 64), so one maximum-width call can never evict its own first results before the batch is delivered. */
12
+ export const DEFAULT_ASYNC_RETAIN = 64;
13
+
10
14
  export interface PiPersonaConfig {
11
15
  disabled: boolean;
12
16
  extraDirs: string[];
@@ -24,14 +28,14 @@ export interface PiPersonaConfig {
24
28
  /** Engine backend for sub-agents: "inproc" (run in-process via `createAgentSession`,
25
29
  * the default) or "child" (spawn `pi -p`, the baseline). */
26
30
  engine?: "child" | "inproc";
27
- /** Fast stall/message wakeup interval (ms): while async children run, a tick checks progress but
28
- * stays SILENT unless a leg NEWLY looks stalled or a sub-agent messaged the supervisor — so a
31
+ /** Fast stall/message tick (ms): while async children run, a tick stays SILENT unless a leg NEWLY
32
+ * looks stalled (operator-only card) or a sub-agent messaged the supervisor (model wake), so a
29
33
  * healthy run never interrupts. On by default (30000); PI_PERSONA_PEEK_MS=0 opts out (the routine
30
34
  * check-in below is independent). */
31
35
  peekEveryMs: number;
32
36
  /** Routine check-in interval (ms): how often, while async children run, the operator gets a durable,
33
37
  * expandable compact progress entry. It is status-only and does not start a model turn. Deliberately
34
- * slow and independent of the fast stall/message wakeup. 300000 (5 min) by default;
38
+ * slow and independent of the fast stall/message tick. 300000 (5 min) by default;
35
39
  * PI_PERSONA_CHECKIN_MS=0 opts out. */
36
40
  checkInEveryMs: number;
37
41
  /** Per-agent hard wall-clock cap (ms): an OPT-IN lifetime ceiling. When set (>0) it settles even
@@ -54,8 +58,9 @@ export interface PiPersonaConfig {
54
58
  * 300000 (5 min) by default; PI_PERSONA_AGENT_STARTUP_MS=0 disables it. */
55
59
  agentStartupTimeoutMs: number;
56
60
  /** Delegation nudge: when a delegating supervisor grinds a RUN of hands-on commands by hand (a
57
- * by-hand sweep) without a hand-off, append a reminder to the offending tool's result. On by
58
- * default; PI_PERSONA_NUDGE=off opts out. */
61
+ * by-hand sweep) without a hand-off, append a reminder to the offending tool's result. The same
62
+ * switch gates the call-time wake guards (the repeated peek/result refusal and the long-sleep
63
+ * block). On by default; PI_PERSONA_NUDGE=off opts out of all of them. */
59
64
  nudge: boolean;
60
65
  /** Cross-process broker (spec B1-B7): on by default so child-engine legs (MCP, worktree,
61
66
  * `PI_PERSONA_ENGINE=child`) expose the same steer/contact_supervisor plane in-process
@@ -67,9 +72,9 @@ export interface PiPersonaConfig {
67
72
  * additionally gated by the persona's canUseBus. */
68
73
  exocom: boolean;
69
74
  /** How many settled-but-uncollected async runs {@link AsyncRunTracker} retains before
70
- * FIFO-evicting the oldest completed ones. 25 by default (today's hardcoded bound); set
71
- * PI_PERSONA_ASYNC_RETAIN=<n> (a finite value >= 1) to raise it under heavy fan-out. A
72
- * value < 1 (junk, negative, or 0 — not a meaningful retention bound) keeps the default. */
75
+ * FIFO-evicting the oldest completed ones. {@link DEFAULT_ASYNC_RETAIN} by default; set
76
+ * PI_PERSONA_ASYNC_RETAIN=<n> (a finite value >= 1) to change it. A value < 1 (junk,
77
+ * negative, or 0 — not a meaningful retention bound) keeps the default. */
73
78
  asyncRetain: number;
74
79
  /** Opt-in wider {@link DelegationLedger} veto key: OFF (default) keys purely on
75
80
  * `agent+model+task` (today's veto *behavior* — unchanged; the key bytes themselves have
@@ -171,7 +176,7 @@ export function resolveConfig(env: Env): PiPersonaConfig {
171
176
  // steerable without a hidden env pin. Off-words restore pre-broker spawn env.
172
177
  broker: !isOff(env.PI_PERSONA_BROKER ?? "on"),
173
178
  exocom: false,
174
- asyncRetain: 25,
179
+ asyncRetain: DEFAULT_ASYNC_RETAIN,
175
180
  ledgerV2: false,
176
181
  spine: "",
177
182
  spineLegs: "",
@@ -199,7 +204,7 @@ export function resolveConfig(env: Env): PiPersonaConfig {
199
204
  if (Number.isFinite(startup) && startup >= 0) config.agentStartupTimeoutMs = startup;
200
205
  }
201
206
  // A finite value >= 1 sets the retention bound; 0 isn't meaningful here (unlike the interval
202
- // knobs above, where 0 opts out) so it — like junk/negative — falls back to the default 25.
207
+ // knobs above, where 0 opts out) so it — like junk/negative — falls back to the default.
203
208
  const asyncRetainRaw = env.PI_PERSONA_ASYNC_RETAIN?.trim();
204
209
  if (asyncRetainRaw !== undefined && asyncRetainRaw !== "") {
205
210
  const retain = Number(asyncRetainRaw);
@@ -0,0 +1,171 @@
1
+ /** Compact discovery only: version-shaped ids are a display heuristic, not release dates,
2
+ * account entitlement or model quality. Keep the full catalog for queries and exact-pin resolution. */
3
+ import type { ModelLite } from "./models.ts";
4
+ import { modelCostRank, modelCostTiers, type ModelCostMetadata } from "./model-tier.ts";
5
+
6
+ interface CatalogEntry {
7
+ model: ModelLite;
8
+ root: string;
9
+ lane: string;
10
+ version: string[];
11
+ snapshot: string | undefined;
12
+ }
13
+
14
+ const textOrder = (a: string, b: string): number => a < b ? -1 : a > b ? 1 : 0;
15
+ const refKey = (model: ModelLite): string => `${model.provider}\0${model.id}`;
16
+
17
+ function entry(model: ModelLite): CatalogEntry {
18
+ let base = model.id.toLowerCase();
19
+ let snapshot: string | undefined;
20
+ // Snapshot tokens can precede adapter suffixes (e.g. -20250514-v1:0). Dots stay
21
+ // version separators; validate the calendar so arbitrary eight-digit versions survive.
22
+ base = base.replace(/(^|[-_@/:])((?:19|20)\d{2})-?(\d{2})-?(\d{2})(?=$|[-_@/:])/g, (match: string, prefix: string, yearText: string, monthText: string, dayText: string) => {
23
+ const year = Number(yearText);
24
+ const month = Number(monthText);
25
+ const day = Number(dayText);
26
+ const parsed = new Date(Date.UTC(year, month - 1, day));
27
+ if (parsed.getUTCFullYear() === year && parsed.getUTCMonth() === month - 1 && parsed.getUTCDate() === day) {
28
+ const date = `${yearText}${monthText}${dayText}`;
29
+ if (snapshot === undefined || date > snapshot) snapshot = date;
30
+ return prefix;
31
+ }
32
+ return match;
33
+ });
34
+ // Keep quantity tokens such as 8b/70b/128k as distinct lanes; they are not release versions.
35
+ const tokens = base.match(/\d+(?:\.\d+)?[bkmt]\b|[\p{L}]+|\d+(?:[._]\d+)*/gu) ?? [];
36
+ const words = tokens.filter((token) => /\p{L}/u.test(token));
37
+ const version = tokens.filter((token) => !/\p{L}/u.test(token)).flatMap((token) => token.split(/[._]/).map((part) => part.replace(/^0+(?=\d)/, "")));
38
+ return {
39
+ model,
40
+ root: words[0] ?? base,
41
+ lane: words.length > 0 && version.length > 0 ? words.join("-") : `opaque:${base}`,
42
+ version,
43
+ snapshot,
44
+ };
45
+ }
46
+
47
+ /** Descending numeric tuples, without floating-point loss on unusual catalog ids. */
48
+ function versionOrder(a: CatalogEntry, b: CatalogEntry): number {
49
+ for (let i = 0; i < Math.max(a.version.length, b.version.length); i++) {
50
+ const left = a.version[i] ?? "0";
51
+ const right = b.version[i] ?? "0";
52
+ const order = right.length - left.length || textOrder(right, left);
53
+ if (order) return order;
54
+ }
55
+ return 0;
56
+ }
57
+
58
+ function representativeOrder(a: CatalogEntry, b: CatalogEntry): number {
59
+ return versionOrder(a, b)
60
+ || Number(a.snapshot !== undefined) - Number(b.snapshot !== undefined)
61
+ || textOrder(b.snapshot ?? "", a.snapshot ?? "")
62
+ || textOrder(a.model.id, b.model.id);
63
+ }
64
+
65
+ function candidateOrder(a: CatalogEntry, b: CatalogEntry, session?: ModelLite): number {
66
+ const active = session ? refKey(session) : undefined;
67
+ return Number(refKey(a.model) !== active) - Number(refKey(b.model) !== active)
68
+ || Number(a.model.provider !== session?.provider) - Number(b.model.provider !== session?.provider)
69
+ || textOrder(a.model.provider, b.model.provider)
70
+ || textOrder(a.root, b.root)
71
+ || versionOrder(a, b)
72
+ || textOrder(a.lane, b.lane)
73
+ || representativeOrder(a, b);
74
+ }
75
+
76
+ /** Share a truncated provider's slots across roots; names identify groups, not capabilities. */
77
+ function rotateRoots(candidates: readonly CatalogEntry[], preferredRoot?: string): CatalogEntry[] {
78
+ const byRoot = new Map<string, CatalogEntry[]>();
79
+ for (const candidate of candidates) {
80
+ const group = byRoot.get(candidate.root) ?? [];
81
+ group.push(candidate);
82
+ byRoot.set(candidate.root, group);
83
+ }
84
+ const groups = [...byRoot.entries()].sort(([a], [b]) =>
85
+ Number(a !== preferredRoot) - Number(b !== preferredRoot) || textOrder(a, b),
86
+ ).map(([, group]) => group);
87
+ const rotated: CatalogEntry[] = [];
88
+ for (let row = 0; rotated.length < candidates.length; row++) {
89
+ for (const group of groups) {
90
+ const candidate = group[row];
91
+ if (candidate) rotated.push(candidate);
92
+ }
93
+ }
94
+ return rotated;
95
+ }
96
+
97
+ /** Rank full-catalog search matches without collapsing versions or snapshots. */
98
+ export function orderModelCandidates(models: readonly ModelLite[], session?: ModelLite): ModelLite[] {
99
+ const unique = new Map(models.map((model) => [refKey(model), model]));
100
+ return [...unique.values()].map(entry).sort((a, b) => candidateOrder(a, b, session)).map((candidate) => candidate.model);
101
+ }
102
+
103
+ /** One representative per provider/version-line/tier, plus the exact active model even when older.
104
+ * Named tiers remain separate without a table of model releases. Opaque ids retain lexical order;
105
+ * within a textual root, numeric generations precede older generations across all its lanes.
106
+ * Only a provider that exceeds its remaining slots rotates roots, with the active root first. */
107
+ export function shortlistModels(models: readonly ModelLite[], session?: ModelLite, max = 40): ModelLite[] {
108
+ const limit = Number.isFinite(max) ? Math.max(0, Math.floor(max)) : 40;
109
+ if (limit === 0) return [];
110
+ const unique = new Map(models.map((model) => [refKey(model), model]));
111
+ const active = session ? unique.get(refKey(session)) : undefined;
112
+ const representatives = new Map<string, CatalogEntry>();
113
+ for (const model of unique.values()) {
114
+ const candidate = entry(model);
115
+ const key = `${model.provider}\0${candidate.lane}`;
116
+ const previous = representatives.get(key);
117
+ if (!previous || representativeOrder(candidate, previous) < 0) representatives.set(key, candidate);
118
+ }
119
+ const ordered = [...representatives.values()].sort((a, b) => candidateOrder(a, b, session));
120
+ const byProvider = new Map<string, CatalogEntry[]>();
121
+ for (const candidate of ordered) {
122
+ if (active && refKey(candidate.model) === refKey(active)) continue;
123
+ const group = byProvider.get(candidate.model.provider) ?? [];
124
+ group.push(candidate);
125
+ byProvider.set(candidate.model.provider, group);
126
+ }
127
+ const selected = active ? [active] : [];
128
+ for (const [provider, candidates] of byProvider) {
129
+ const remaining = limit - selected.length;
130
+ if (remaining === 0) break;
131
+ const balanced = candidates.length > remaining
132
+ ? rotateRoots(candidates, provider === active?.provider ? entry(active).root : undefined)
133
+ : candidates;
134
+ selected.push(...balanced.slice(0, remaining).map((candidate) => candidate.model));
135
+ }
136
+ return selected;
137
+ }
138
+
139
+ /** Recommendation families use the model name after a namespace, unlike rotation's vendor root.
140
+ * Dots before letters delimit namespace segments; numeric version dots stay in the model name. */
141
+ function recommendationGroup(model: ModelLite): { family: string; major: string | undefined } {
142
+ const id = model.id.toLowerCase();
143
+ let nameStart = 0;
144
+ for (const separator of id.matchAll(/\/|\.(?=\p{L})/gu)) nameStart = separator.index + 1;
145
+ const name = entry({ provider: model.provider, id: id.slice(nameStart) });
146
+ return { family: `${id.slice(0, nameStart)}\0${name.root}`, major: name.version[0] };
147
+ }
148
+
149
+ /** Advisory neighbors of the current model; full queries and exact pins use the full catalog. */
150
+ export function recommendModels(models: readonly ModelCostMetadata[], session?: ModelLite, max = 40): ModelCostMetadata[] {
151
+ if (!session) return shortlistModels(models, undefined, max);
152
+ const limit = Number.isFinite(max) ? Math.max(0, Math.floor(max)) : 40;
153
+ if (limit === 0) return [];
154
+ const anchor = recommendationGroup(session);
155
+ const neighbors = models.filter((model) => {
156
+ if (model.provider !== session.provider) return false;
157
+ const candidate = recommendationGroup(model);
158
+ return candidate.family === anchor.family && (anchor.major === undefined || candidate.major === anchor.major);
159
+ });
160
+ // Filter before latest-per-lane selection: a newer generation must not erase a neighbor
161
+ // of an older working supervisor. Price bands keep the full uncapped provider reference.
162
+ const selected = shortlistModels(neighbors, session, Number.MAX_SAFE_INTEGER).map(entry);
163
+ const tiers = modelCostTiers(models, shortlistModels(models, undefined, Number.MAX_SAFE_INTEGER));
164
+ const active = refKey(session);
165
+ selected.sort((a, b) =>
166
+ Number(refKey(a.model) !== active) - Number(refKey(b.model) !== active)
167
+ || modelCostRank(tiers.get(`${a.model.provider}/${a.model.id}`)) - modelCostRank(tiers.get(`${b.model.provider}/${b.model.id}`))
168
+ || candidateOrder(a, b, session),
169
+ );
170
+ return selected.slice(0, limit).map((candidate) => candidate.model);
171
+ }
@@ -0,0 +1,89 @@
1
+ /** Relative advertised cost within one provider; never capability, throughput, or effort. */
2
+ export type ModelTier = "low" | "medium" | "high";
3
+
4
+ /** Presentation order only: unknown prices follow known provider-relative bands. */
5
+ export function modelCostRank(tier: ModelTier | undefined): number {
6
+ return tier === "low" ? 0 : tier === "medium" ? 1 : tier === "high" ? 2 : 3;
7
+ }
8
+
9
+ export interface ModelCostMetadata {
10
+ provider: string;
11
+ id: string;
12
+ name?: string;
13
+ reasoning?: boolean;
14
+ contextWindow?: number;
15
+ cost?: { input: number; output: number };
16
+ thinkingLevelMap?: Partial<Record<string, unknown>>;
17
+ }
18
+
19
+ /**
20
+ * Rank distinct positive input+output prices within each provider, using an equal-token
21
+ * input/output proxy. The first ceil(n/3) prices are low, through ceil(2n/3) medium,
22
+ * and the rest high. Aliases cannot change the cutoffs. At least three distinct prices
23
+ * are required; missing/zero prices remain unknown. No model names or ids imply a tier.
24
+ * An uncapped reference keeps labels stable across searches; outside prices clamp to
25
+ * its low/high bands. These are catalog comparisons, not account billing estimates.
26
+ */
27
+ export function modelCostTiers(models: readonly ModelCostMetadata[], reference: readonly ModelCostMetadata[] = models): Map<string, ModelTier> {
28
+ const pricesByProvider = new Map<string, Set<number>>();
29
+ for (const model of reference) {
30
+ const price = priceProxy(model);
31
+ if (price === undefined) continue;
32
+ let prices = pricesByProvider.get(model.provider);
33
+ if (!prices) {
34
+ prices = new Set();
35
+ pricesByProvider.set(model.provider, prices);
36
+ }
37
+ prices.add(price);
38
+ }
39
+ const cutoffs = new Map<string, { low: number; medium: number }>();
40
+ for (const [provider, prices] of pricesByProvider) {
41
+ if (prices.size < 3) continue;
42
+ const sorted = [...prices].sort((a, b) => a - b);
43
+ const low = sorted[Math.ceil(sorted.length / 3) - 1];
44
+ const medium = sorted[Math.ceil(2 * sorted.length / 3) - 1];
45
+ if (low !== undefined && medium !== undefined) cutoffs.set(provider, { low, medium });
46
+ }
47
+ const tiers = new Map<string, ModelTier>();
48
+ for (const model of models) {
49
+ const price = priceProxy(model);
50
+ const bands = cutoffs.get(model.provider);
51
+ if (price === undefined || !bands) continue;
52
+ tiers.set(`${model.provider}/${model.id}`, price <= bands.low ? "low" : price <= bands.medium ? "medium" : "high");
53
+ }
54
+ return tiers;
55
+ }
56
+
57
+ function positiveFinite(value: number | undefined): value is number {
58
+ return value !== undefined && Number.isFinite(value) && value > 0;
59
+ }
60
+
61
+ function priceProxy(model: ModelCostMetadata): number | undefined {
62
+ if (!positiveFinite(model.cost?.input) || !positiveFinite(model.cost?.output)) return undefined;
63
+ const price = model.cost.input + model.cost.output;
64
+ if (!positiveFinite(price)) return undefined;
65
+ // Decimal catalog prices can sum to adjacent binary floats; do not invent a new price rank.
66
+ const normalized = Number(price.toPrecision(12));
67
+ return positiveFinite(normalized) ? normalized : undefined;
68
+ }
69
+
70
+ function shortNumber(value: number): string {
71
+ return value.toPrecision(3).replace(/(?:\.0+|(\.\d+?)0+)(?=e|$)/, "$1");
72
+ }
73
+
74
+ function contextLabel(value: number): string {
75
+ if (value >= 1_000_000) return `${shortNumber(value / 1_000_000)}M`;
76
+ if (value >= 1_000) return `${shortNumber(value / 1_000)}k`;
77
+ return shortNumber(value);
78
+ }
79
+
80
+ /** Host catalog hints; API prices are advertised values, not an OAuth billing estimate. */
81
+ export function modelCandidateDetails(model: ModelCostMetadata, tier?: ModelTier): string {
82
+ const details: string[] = [`cost:${tier ?? "unknown"}`];
83
+ if (model.reasoning === true) details.push("reasoning");
84
+ if (positiveFinite(model.contextWindow)) details.push(`ctx ${contextLabel(model.contextWindow)}`);
85
+ if (positiveFinite(model.cost?.input) && positiveFinite(model.cost?.output)) {
86
+ details.push(`API $${shortNumber(model.cost.input)}/$${shortNumber(model.cost.output)} per 1M in/out`);
87
+ }
88
+ return `[${details.join("; ")}]`;
89
+ }
@@ -46,6 +46,8 @@ export type ModelResolution =
46
46
  | { ok: false; reason: "unknown" | "ambiguous" | "blocked"; candidates: string[] };
47
47
 
48
48
  const full = (m: ModelLite): string => `${m.provider}/${m.id}`;
49
+ /** Loose-match form of a model ref: lower-case, version dots folded to dashes. */
50
+ const looseKey = (s: string): string => s.toLowerCase().replace(/(\d)\.(?=\d)/g, "$1-");
49
51
 
50
52
  function dedupe(models: ModelLite[]): ModelLite[] {
51
53
  const seen = new Set<string>();
@@ -118,16 +120,23 @@ export function resolveModelRef(
118
120
  policy?: ModelRoutingPolicy,
119
121
  ): ModelResolution {
120
122
  const wanted = ref.trim();
121
- const all = models.map(full);
122
123
  const exact = models.find((m) => full(m) === wanted);
123
124
  // An exact provider-qualified pin is an explicit user choice. Never rewrite or reject it
124
125
  // because the current routing policy is intended only for loose automatic selection.
125
126
  if (exact) return { ok: true, ref: wanted };
126
127
 
127
- // Candidate set: a unique exact id (no provider), else a case-insensitive substring.
128
+ // Candidate set: a unique exact id (no provider), else a loose match — case-insensitive, dots
129
+ // and dashes interchangeable in versions ('opus-4.6' ≡ 'opus-4-6'), and a `provider/` prefix
130
+ // scoping the loose id to that provider's ids ('claude-pro-max-native/opus-4-6').
128
131
  const byId = models.filter((m) => m.id === wanted);
129
- const lower = wanted.toLowerCase();
130
- const rawCandidates = dedupe(byId.length > 0 ? byId : models.filter((m) => full(m).toLowerCase().includes(lower)));
132
+ const slash = wanted.indexOf("/");
133
+ const scopedProvider = slash > 0 ? wanted.slice(0, slash) : undefined;
134
+ const scoped = scopedProvider ? models.filter((m) => m.provider === scopedProvider) : [];
135
+ const loose = looseKey(scoped.length > 0 ? wanted.slice(slash + 1) : wanted);
136
+ const pool = scoped.length > 0 ? scoped : models;
137
+ const rawCandidates = dedupe(byId.length > 0 ? byId : pool.filter((m) => looseKey(scoped.length > 0 ? m.id : full(m)).includes(loose)));
138
+ // With nothing to match, the catalog itself is the answer — session provider first.
139
+ const all = [...models.filter((m) => m.provider === preferProvider), ...models.filter((m) => m.provider !== preferProvider)].map(full);
131
140
  const candidates = policy ? rawCandidates.filter((m) => isModelProviderAllowed(m, policy)) : rawCandidates;
132
141
  if (policy) {
133
142
  const allCandidatesArePolicyBlocked = rawCandidates.length > 0 && rawCandidates.every((m) => allowedModelProviders(m.id, policy) !== undefined && !isModelProviderAllowed(m, policy));
@@ -0,0 +1,85 @@
1
+ /** Pure, bounded terminal-output projection. Raw output must never be replayed as terminal commands. */
2
+ export const MONITOR_OUTPUT_TAIL_CHARS = 4_000;
3
+
4
+ /** Neutralize display-direction/line controls in complete monitor text; never mutate raw captures. */
5
+ export function neutralizeMonitorUnicode(text: string): string {
6
+ return text.replace(/[\u200e\u200f\u202a-\u202e\u2066-\u2069]/g, "").replace(/[\u2028\u2029]/g, " ");
7
+ }
8
+
9
+ type ControlState = "text" | "escape" | "escapeIntermediate" | "csi" | "osc" | "oscEscape" | "string" | "stringEscape";
10
+
11
+ /**
12
+ * One stream's sanitized tail. A fixed-size ring bounds storage even for a giant unterminated
13
+ * line. Escape parsing keeps only a finite state (no pending payload), independently per stream,
14
+ * so split/unterminated OSC, CSI and DCS cannot leak into the surrounding display structure.
15
+ * CR replaces the current progress line; CRLF completes it. This is not a terminal emulator.
16
+ */
17
+ export class MonitorOutputTail {
18
+ private readonly chars: string[] = [];
19
+ private start = 0;
20
+ private length = 0;
21
+ private state: ControlState = "text";
22
+ private carriageReturn = false;
23
+
24
+ append(chunk: string): void {
25
+ for (let i = 0; i < chunk.length; i++) {
26
+ const char = chunk[i]!;
27
+ const code = chunk.charCodeAt(i);
28
+ if (this.state === "osc" || this.state === "string") {
29
+ if (code === 0x9c || (code === 0x07 && this.state === "osc")) this.state = "text";
30
+ else if (code === 0x1b) this.state = this.state === "osc" ? "oscEscape" : "stringEscape";
31
+ continue;
32
+ }
33
+ if (this.state === "oscEscape" || this.state === "stringEscape") {
34
+ if (char === "\\" || code === 0x9c || (code === 0x07 && this.state === "oscEscape")) this.state = "text";
35
+ else if (code !== 0x1b) this.state = this.state === "oscEscape" ? "osc" : "string";
36
+ continue;
37
+ }
38
+ if (code === 0x1b) { this.state = "escape"; continue; }
39
+ if (this.state === "escape") {
40
+ if (char === "[") this.state = "csi";
41
+ else if (char === "]") this.state = "osc";
42
+ else if (char === "P" || char === "X" || char === "^" || char === "_") this.state = "string";
43
+ else this.state = code >= 0x20 && code <= 0x2f ? "escapeIntermediate" : "text";
44
+ continue;
45
+ }
46
+ if (this.state === "escapeIntermediate" || this.state === "csi") {
47
+ if (code >= (this.state === "csi" ? 0x40 : 0x30) && code <= 0x7e) this.state = "text";
48
+ continue;
49
+ }
50
+ if (code === 0x9b) { this.state = "csi"; continue; }
51
+ if (code === 0x9d) { this.state = "osc"; continue; }
52
+ if (code === 0x90 || code === 0x98 || code === 0x9e || code === 0x9f) { this.state = "string"; continue; }
53
+ if (char === "\r") { this.carriageReturn = true; continue; }
54
+ if (char === "\n") { this.carriageReturn = false; this.push("\n"); continue; }
55
+ if (code < 0x20 && code !== 0x09 || code >= 0x7f && code <= 0x9f) continue;
56
+ // Inert display text must not carry bidi direction overrides or Unicode line separators.
57
+ if (code >= 0x202a && code <= 0x202e || code >= 0x2066 && code <= 0x2069 || code === 0x200e || code === 0x200f) continue;
58
+ if (this.carriageReturn) {
59
+ while (this.length > 0 && this.chars[(this.start + this.length - 1) % MONITOR_OUTPUT_TAIL_CHARS] !== "\n") this.length--;
60
+ this.carriageReturn = false;
61
+ }
62
+ this.push(code === 0x09 || code === 0x2028 || code === 0x2029 ? " " : char);
63
+ }
64
+ }
65
+
66
+ get text(): string {
67
+ let text = Array.from({ length: this.length }, (_, i) => this.chars[(this.start + i) % MONITOR_OUTPUT_TAIL_CHARS]!).join("");
68
+ // Eviction/chunk splits can expose half a surrogate pair: never render that half alone.
69
+ if (text.charCodeAt(0) >= 0xdc00 && text.charCodeAt(0) <= 0xdfff) text = text.slice(1);
70
+ const last = text.charCodeAt(text.length - 1);
71
+ if (last >= 0xd800 && last <= 0xdbff) text = text.slice(0, -1);
72
+ return text;
73
+ }
74
+
75
+ get preview(): string {
76
+ const text = this.text.trimEnd();
77
+ return text.slice(text.lastIndexOf("\n") + 1).trim();
78
+ }
79
+
80
+ private push(char: string): void {
81
+ this.chars[(this.start + this.length) % MONITOR_OUTPUT_TAIL_CHARS] = char;
82
+ if (this.length < MONITOR_OUTPUT_TAIL_CHARS) this.length++;
83
+ else this.start = (this.start + 1) % MONITOR_OUTPUT_TAIL_CHARS;
84
+ }
85
+ }
@@ -38,6 +38,13 @@ export interface MonitorEvent {
38
38
  terminal: boolean;
39
39
  }
40
40
 
41
+ /** Raw process output for UI/log observers only; never a semantic wake event. */
42
+ export interface MonitorOutput {
43
+ id: string;
44
+ stream: "stdout" | "stderr";
45
+ text: string;
46
+ }
47
+
41
48
  export interface MonitorProcessHandlers {
42
49
  onStdout: (chunk: string) => void;
43
50
  onStderr: (chunk: string) => void;
@@ -50,11 +57,22 @@ export interface MonitorProcess {
50
57
  }
51
58
 
52
59
  export interface MonitorManagerDeps {
60
+ /** Internal activation namespace for persisted ids; not a user-facing monitor parameter. */
61
+ idPrefix?: string;
53
62
  now: () => number;
54
63
  setTimer: (fn: () => void, ms: number) => unknown;
55
64
  clearTimer: (handle: unknown) => void;
56
65
  start: (spec: MonitorSpec, handlers: MonitorProcessHandlers) => MonitorProcess;
57
66
  onEvent: (event: MonitorEvent) => void;
67
+ /** Accepted nonempty chunks, BEFORE semantic processing. Never called after terminal/removal.
68
+ * Observer failures are isolated: display/logging cannot change wakes or process lifetime. */
69
+ onOutput?: (output: MonitorOutput) => void;
70
+ /** Called once per admitted monitor, BEFORE its process starts (never for a rejected spec).
71
+ * Every announced monitor later reaches exactly one of: a terminal `onEvent`, or `onRemoved`. */
72
+ onArmed?: (entry: MonitorEntry) => void;
73
+ /** Called once per monitor dropped WITHOUT a wake event: `cancel`, `cancelAll`, or a start that
74
+ * failed before it ran. A terminal event is never followed by this. */
75
+ onRemoved?: (entry: MonitorEntry) => void;
58
76
  }
59
77
 
60
78
  export type MonitorArmResult =
@@ -143,7 +161,7 @@ export class MonitorManager {
143
161
  const timeoutMs = request.timeoutMs ?? MONITOR_DEFAULT_TIMEOUT_MS;
144
162
  const maxEvents = request.maxEvents ?? MONITOR_DEFAULT_MAX_EVENTS;
145
163
  const now = this.deps.now();
146
- const id = `monitor-${++this.sequence}`;
164
+ const id = `${this.deps.idPrefix ?? "monitor"}-${++this.sequence}`;
147
165
  const entry: MonitorEntry = {
148
166
  id,
149
167
  label: request.label.trim(),
@@ -169,6 +187,7 @@ export class MonitorManager {
169
187
  lineLength: 0,
170
188
  };
171
189
  this.monitors.set(id, active);
190
+ this.deps.onArmed?.(copyEntry(entry));
172
191
 
173
192
  const handlers: MonitorProcessHandlers = {
174
193
  onStdout: (chunk) => this.receiveStdout(id, chunk),
@@ -282,6 +301,8 @@ export class MonitorManager {
282
301
  private receiveStdout(id: string, chunk: string): void {
283
302
  const active = this.monitors.get(id);
284
303
  if (!active || active.terminal || !chunk) return;
304
+ this.observeOutput(id, "stdout", chunk);
305
+ if (active.terminal) return;
285
306
  if (active.entry.mode === "exit") {
286
307
  active.stdoutTail = appendTail(active.stdoutTail, chunk, MONITOR_MAX_DIAGNOSTIC_CHARS);
287
308
  return;
@@ -318,10 +339,20 @@ export class MonitorManager {
318
339
 
319
340
  private receiveStderr(id: string, chunk: string): void {
320
341
  const active = this.monitors.get(id);
321
- if (!active || active.terminal) return;
342
+ if (!active || active.terminal || !chunk) return;
343
+ this.observeOutput(id, "stderr", chunk);
344
+ if (active.terminal) return;
322
345
  active.stderrTail = appendTail(active.stderrTail, chunk, MONITOR_MAX_DIAGNOSTIC_CHARS);
323
346
  }
324
347
 
348
+ private observeOutput(id: string, stream: MonitorOutput["stream"], text: string): void {
349
+ try {
350
+ this.deps.onOutput?.({ id, stream, text });
351
+ } catch {
352
+ /* UI/log observers cannot suppress a semantic event or interrupt process cleanup. */
353
+ }
354
+ }
355
+
325
356
  private receiveExit(id: string, code: number | null, signal?: string): void {
326
357
  const active = this.monitors.get(id);
327
358
  if (!active || active.terminal) return;
@@ -411,5 +442,6 @@ export class MonitorManager {
411
442
  } catch {
412
443
  /* best effort; cancellation remains terminal */
413
444
  }
445
+ this.deps.onRemoved?.(copyEntry(active.entry));
414
446
  }
415
447
  }