@aeondave/pi-persona 1.15.2

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 (165) hide show
  1. package/AGENTS.md +249 -0
  2. package/CHANGELOG.md +260 -0
  3. package/LICENSE +21 -0
  4. package/README.md +172 -0
  5. package/agents/balthasar.md +32 -0
  6. package/agents/casper.md +33 -0
  7. package/agents/evidence-verifier.md +23 -0
  8. package/agents/melchior.md +32 -0
  9. package/agents/operator.md +63 -0
  10. package/agents/research.md +58 -0
  11. package/agents/reviewer.md +49 -0
  12. package/agents/scout.md +23 -0
  13. package/agents/verifier.md +21 -0
  14. package/assets/banner.png +0 -0
  15. package/assets/demo1.gif +0 -0
  16. package/assets/demo1.mp4 +0 -0
  17. package/assets/workflow.png +0 -0
  18. package/contracts/finding.contract.json +9 -0
  19. package/docs/ARCHITECTURE.md +727 -0
  20. package/docs/EXPERIENCE.md +96 -0
  21. package/docs/MONITORS.md +84 -0
  22. package/docs/README.md +53 -0
  23. package/docs/REFERENCE.md +408 -0
  24. package/docs/SPINE.md +152 -0
  25. package/docs/STRATEGIES.md +215 -0
  26. package/docs/TELEMETRY.md +9 -0
  27. package/flows/deep-review.flow.json +8 -0
  28. package/flows/gated-build.flow.json +9 -0
  29. package/package.json +106 -0
  30. package/personas/audit.md +29 -0
  31. package/personas/dev.md +52 -0
  32. package/personas/elite.md +256 -0
  33. package/personas/judge.md +32 -0
  34. package/personas/magi.md +41 -0
  35. package/personas/planner.md +37 -0
  36. package/personas/researcher.md +37 -0
  37. package/personas/swarm.md +38 -0
  38. package/personas/verify.md +31 -0
  39. package/presets/magi-rounds.preset.json +5 -0
  40. package/prompts/spine.md +11 -0
  41. package/prompts/spine.worker.md +9 -0
  42. package/src/agents/agent.ts +78 -0
  43. package/src/bridge.ts +209 -0
  44. package/src/bus/broker/client.ts +379 -0
  45. package/src/bus/broker/framing.ts +43 -0
  46. package/src/bus/broker/host.ts +407 -0
  47. package/src/bus/broker/messages.ts +87 -0
  48. package/src/bus/broker/paths.ts +31 -0
  49. package/src/bus/contact.ts +145 -0
  50. package/src/bus/inproc.ts +279 -0
  51. package/src/bus/peers.ts +114 -0
  52. package/src/core/brief.ts +356 -0
  53. package/src/core/capabilities.ts +114 -0
  54. package/src/core/config.ts +228 -0
  55. package/src/core/contract.ts +391 -0
  56. package/src/core/data-root.ts +260 -0
  57. package/src/core/discovery.ts +33 -0
  58. package/src/core/display-label.ts +47 -0
  59. package/src/core/fence.ts +151 -0
  60. package/src/core/format.ts +13 -0
  61. package/src/core/frontmatter.ts +202 -0
  62. package/src/core/models.ts +192 -0
  63. package/src/core/monitor.ts +415 -0
  64. package/src/core/naming.ts +42 -0
  65. package/src/core/nudge.ts +238 -0
  66. package/src/core/ownership.ts +102 -0
  67. package/src/core/permissions.ts +56 -0
  68. package/src/core/pi-compat.ts +50 -0
  69. package/src/core/seed.ts +770 -0
  70. package/src/core/session-identity.ts +89 -0
  71. package/src/core/time.ts +202 -0
  72. package/src/core/timer.ts +221 -0
  73. package/src/core/types.ts +28 -0
  74. package/src/engine/adapter.ts +257 -0
  75. package/src/engine/async.ts +898 -0
  76. package/src/engine/change-report.ts +143 -0
  77. package/src/engine/child.ts +532 -0
  78. package/src/engine/errors.ts +30 -0
  79. package/src/engine/fallback.ts +57 -0
  80. package/src/engine/handles.ts +21 -0
  81. package/src/engine/inproc.ts +857 -0
  82. package/src/engine/signals.ts +11 -0
  83. package/src/engine/spec-preflight.ts +33 -0
  84. package/src/engine/stream.ts +369 -0
  85. package/src/engine/worktree.ts +144 -0
  86. package/src/exocom/activation.ts +31 -0
  87. package/src/exocom/codes.ts +261 -0
  88. package/src/exocom/envelope.ts +180 -0
  89. package/src/exocom/gate.ts +6 -0
  90. package/src/exocom/guards.ts +45 -0
  91. package/src/exocom/inbound.ts +141 -0
  92. package/src/exocom/install.ts +970 -0
  93. package/src/exocom/ledger.ts +603 -0
  94. package/src/exocom/limits.ts +20 -0
  95. package/src/exocom/paths.ts +35 -0
  96. package/src/exocom/plane.ts +1116 -0
  97. package/src/exocom/registry.ts +307 -0
  98. package/src/exocom/scope.ts +42 -0
  99. package/src/exocom/status.ts +86 -0
  100. package/src/exocom/untrusted.ts +40 -0
  101. package/src/exocom/wait.ts +23 -0
  102. package/src/exocom/write-guard.ts +89 -0
  103. package/src/extension/broker-host.ts +181 -0
  104. package/src/extension/clock.ts +14 -0
  105. package/src/extension/engine.ts +321 -0
  106. package/src/extension/hooks.ts +796 -0
  107. package/src/extension/identity.ts +244 -0
  108. package/src/extension/shared.ts +175 -0
  109. package/src/extension.ts +2221 -0
  110. package/src/loader.ts +218 -0
  111. package/src/monitor/process.ts +116 -0
  112. package/src/monitor/session.ts +61 -0
  113. package/src/orchestration/flow-journal.ts +86 -0
  114. package/src/orchestration/flow-run.ts +301 -0
  115. package/src/orchestration/flow.ts +125 -0
  116. package/src/orchestration/judge.ts +78 -0
  117. package/src/orchestration/model-retry.ts +85 -0
  118. package/src/orchestration/parallel.ts +107 -0
  119. package/src/orchestration/params.ts +23 -0
  120. package/src/orchestration/reducers.ts +123 -0
  121. package/src/orchestration/render.ts +127 -0
  122. package/src/orchestration/roster.ts +133 -0
  123. package/src/orchestration/sdk.ts +326 -0
  124. package/src/orchestration/strategies/compete.ts +182 -0
  125. package/src/orchestration/strategies/council-rounds.ts +124 -0
  126. package/src/orchestration/strategies/critic-loop.ts +168 -0
  127. package/src/orchestration/strategies/debate.ts +126 -0
  128. package/src/orchestration/strategies/fanout.ts +20 -0
  129. package/src/orchestration/strategies/judge.ts +117 -0
  130. package/src/orchestration/strategies/magi.ts +239 -0
  131. package/src/orchestration/strategies/map.ts +327 -0
  132. package/src/orchestration/strategies/pair.ts +72 -0
  133. package/src/orchestration/strategies/pipeline.ts +91 -0
  134. package/src/orchestration/strategies/synthesize.ts +99 -0
  135. package/src/orchestration/strategy.ts +49 -0
  136. package/src/orchestration/types.ts +31 -0
  137. package/src/orchestration/voting.ts +126 -0
  138. package/src/persona/config-store.ts +215 -0
  139. package/src/persona/controller.ts +173 -0
  140. package/src/persona/gating.ts +82 -0
  141. package/src/persona/orchestrate.ts +83 -0
  142. package/src/persona/persona.ts +294 -0
  143. package/src/persona/spine.ts +221 -0
  144. package/src/persona/state.ts +28 -0
  145. package/src/telemetry/contract.ts +244 -0
  146. package/src/telemetry/paths.ts +14 -0
  147. package/src/telemetry/producer.ts +390 -0
  148. package/src/tools/council.ts +200 -0
  149. package/src/tools/delegate-tool.ts +637 -0
  150. package/src/tools/delegate.ts +661 -0
  151. package/src/tools/exocom-work.ts +328 -0
  152. package/src/tools/exocom.ts +476 -0
  153. package/src/tools/flow.ts +117 -0
  154. package/src/tools/intercom-tool.ts +292 -0
  155. package/src/tools/intercom.ts +181 -0
  156. package/src/tools/models.ts +39 -0
  157. package/src/tools/monitor.ts +66 -0
  158. package/src/tools/timer.ts +105 -0
  159. package/src/ui/agent-overlay.ts +526 -0
  160. package/src/ui/agent-tree.ts +307 -0
  161. package/src/ui/live-clock.ts +42 -0
  162. package/src/ui/model-picker.ts +160 -0
  163. package/src/ui/presentation.ts +170 -0
  164. package/src/ui/usage.ts +133 -0
  165. package/teams.yaml +27 -0
@@ -0,0 +1,221 @@
1
+ /**
2
+ * The spine — the shared behavioral layer injected between Pi's base prompt and the
3
+ * persona body (docs/SPINE.md). This module owns SOURCE resolution only: which files, if
4
+ * any, supply the layer. Composition lives in `persona.ts` (supervisor turns) and in
5
+ * the engines (delegated legs).
6
+ *
7
+ * Two texts, not one: the supervisor gets `spine.md`, a delegated leg gets `spine.worker.md`.
8
+ * They are different prompts doing different jobs — the supervisor text tells its reader to
9
+ * confirm irreversible actions with the user, and a leg runs headless (`pi -p --no-session`)
10
+ * with no user to confirm with, so that rule would make it stall or hand the question back as
11
+ * its deliverable. Two SELECTORS for the same reason: whether each text helps is a separate
12
+ * question, and the A/B in docs/SPINE.md needs the arms that turn one on without the other.
13
+ *
14
+ * Pure over an injected reader, mirroring `resolveConfig`'s injected env, so the whole
15
+ * precedence table is unit-testable without touching a disk.
16
+ */
17
+
18
+ import { readFileSync, statSync } from "node:fs";
19
+ import { dirname, resolve } from "node:path";
20
+ import { fileURLToPath } from "node:url";
21
+
22
+ /** Size ceiling for a spine file. The layer is prepended to EVERY supervisor turn AND every
23
+ * delegated leg, so a mistyped path aimed at a log, a binary, or a big JSON would silently
24
+ * inflate every request — multiplied by the fanout width. 64 KiB is orders of magnitude past
25
+ * any hand-written behavioral layer (the bundled ones are ~2 KiB) and well short of a file
26
+ * worth paying for on every turn. Past it we degrade like every other bad input: a warning
27
+ * and no spine. */
28
+ export const MAX_SPINE_BYTES = 64 * 1024;
29
+
30
+ /** The outcome of one read attempt. The three cases are kept apart because they send a user to
31
+ * three different places: permissions, content, and the size of the file they pointed at. */
32
+ export type SpineRead = { kind: "ok"; text: string } | { kind: "missing" } | { kind: "oversize"; bytes: number };
33
+
34
+ /** Reads a file, or reports why it yielded nothing usable. */
35
+ export type SpineReader = (path: string) => SpineRead;
36
+
37
+ export interface SpineSources {
38
+ /** `config.spine` — the SUPERVISOR selector: `""` (off), `"on"`, or an explicit path. */
39
+ selector: string;
40
+ /** `config.spineLegs` — the same grammar for the delegated legs. A separate selector because
41
+ * the two texts are separable decisions and the A/B in docs/SPINE.md has to isolate them;
42
+ * `config` has already applied the "follows `selector` unless set" default, so by the time
43
+ * it arrives here it is just another selector. */
44
+ workerSelector: string;
45
+ /** `<persona data dir>/spine.md` — the user's own copy, which shadows the bundled one. */
46
+ userPath: string;
47
+ /** The `prompts/spine.md` shipped with the package. */
48
+ bundledPath: string;
49
+ /** `<persona data dir>/spine.worker.md` — the user's own copy of the delegated-leg text. */
50
+ workerUserPath: string;
51
+ /** The `prompts/spine.worker.md` shipped with the package. */
52
+ workerBundledPath: string;
53
+ /** Read-only upgrade compatibility: skip a user candidate already identified as the exact
54
+ * pristine v1.8.0 prompt and fall through to the current bundle. Explicit selectors do not use
55
+ * candidate lists, so they remain literal even when they name the same file. */
56
+ skipUserPath?: boolean;
57
+ skipWorkerUserPath?: boolean;
58
+ read: SpineReader;
59
+ }
60
+
61
+ export interface ResolvedSpine {
62
+ /** The supervisor layer text, trimmed. Absent ⇒ no spine on supervisor turns. */
63
+ text?: string;
64
+ /** Which file supplied it (diagnostics / `/doctor`). */
65
+ source?: string;
66
+ /** The delegated-leg layer text, trimmed. Absent ⇒ legs run without one; they are never
67
+ * given the supervisor text as a stand-in (see the module header). */
68
+ worker?: string;
69
+ workerSource?: string;
70
+ /** Degradation notice: a spine was asked for and something did not supply one. The
71
+ * session continues without it — a missing prompt file is never a hard failure. */
72
+ warning?: string;
73
+ }
74
+
75
+ /** The bundled supervisor spine that ships with the package. Derived from THIS module's own
76
+ * URL, so it resolves identically from a checkout and from an installed `node_modules/pi-persona`
77
+ * (where `files: ["prompts", …]` puts `prompts/` next to `src/`). */
78
+ export function bundledSpinePath(moduleUrl: string = import.meta.url): string {
79
+ return bundledPromptPath("spine.md", moduleUrl);
80
+ }
81
+
82
+ /** The bundled worker variant — the text a delegated leg gets. */
83
+ export function bundledWorkerSpinePath(moduleUrl: string = import.meta.url): string {
84
+ return bundledPromptPath("spine.worker.md", moduleUrl);
85
+ }
86
+
87
+ function bundledPromptPath(file: string, moduleUrl: string): string {
88
+ return resolve(dirname(fileURLToPath(moduleUrl)), "..", "..", "prompts", file);
89
+ }
90
+
91
+ /** The two filesystem calls the production reader makes, behind a seam. It exists so the ORDER
92
+ * can be witnessed: reading first and measuring the resulting string refuses exactly the same
93
+ * files, so the refusal on its own proves nothing about what was loaded to reach it. */
94
+ export interface SpineFs {
95
+ /** Byte size of the file at `path`; throws if it isn't there or can't be stat'd. */
96
+ size: (path: string) => number;
97
+ /** The file's UTF-8 contents. */
98
+ text: (path: string) => string;
99
+ }
100
+
101
+ const NODE_FS: SpineFs = { size: (path) => statSync(path).size, text: (path) => readFileSync(path, "utf8") };
102
+
103
+ /** The production reader: a missing/unreadable file is "no content", and an oversize one is
104
+ * refused on its `stat` so its bytes never enter the process at all. `fs` is defaulted — no
105
+ * caller passes it — so this stays a plain {@link SpineReader} to everything downstream. */
106
+ export function readSpineFile(path: string, fs: SpineFs = NODE_FS): SpineRead {
107
+ try {
108
+ const bytes = fs.size(path);
109
+ if (bytes > MAX_SPINE_BYTES) return { kind: "oversize", bytes };
110
+ return { kind: "ok", text: fs.text(path) };
111
+ } catch {
112
+ return { kind: "missing" };
113
+ }
114
+ }
115
+
116
+ /** One of the two roles the layer is resolved for, reduced to what the resolution actually needs. */
117
+ interface SpineRole {
118
+ selector: string;
119
+ /** `on` candidates in precedence order — the user's own copy shadows the bundled default,
120
+ * the precedence every other pi-persona resource follows. */
121
+ candidates: string[];
122
+ /** The file name to say out loud when neither candidate is there. */
123
+ file: string;
124
+ /** What is lost when this role ends up without a layer — the two roles lose different things. */
125
+ consequence: string;
126
+ }
127
+
128
+ interface ResolvedRole {
129
+ /** The text and the file it came from, together — a role either has both or has neither. */
130
+ found?: { text: string; source: string };
131
+ /** Every candidate that WAS there and still could not be used, plus a note when the role is
132
+ * left with nothing at all. Collected rather than thrown: no prompt file is a hard failure. */
133
+ problems: string[];
134
+ }
135
+
136
+ /** Resolve which files supply the spine. See the table in docs/SPINE.md. */
137
+ export function resolveSpine(sources: SpineSources): ResolvedSpine {
138
+ const { selector, workerSelector, userPath, bundledPath, workerUserPath, workerBundledPath, read } = sources;
139
+ const supervisor = resolveRole(
140
+ { selector, candidates: sources.skipUserPath ? [bundledPath] : [userPath, bundledPath], file: "spine.md", consequence: "continuing without one" },
141
+ read,
142
+ );
143
+ // The default wiring points BOTH roles at the same explicit path (the legs follow the
144
+ // supervisor selector unless set), and that is one decision: resolving it twice would stat and
145
+ // slurp the file twice and answer one mistyped path with two warnings. `on` is not shared —
146
+ // there the two roles name different files by design.
147
+ const legs =
148
+ workerSelector === selector && selector !== "" && selector !== "on"
149
+ ? supervisor
150
+ : resolveRole(
151
+ {
152
+ selector: workerSelector,
153
+ candidates: sources.skipWorkerUserPath ? [workerBundledPath] : [workerUserPath, workerBundledPath],
154
+ file: "spine.worker.md",
155
+ consequence: "delegated legs continue without one",
156
+ },
157
+ read,
158
+ );
159
+
160
+ const out: ResolvedSpine = {};
161
+ if (supervisor.found) {
162
+ out.text = supervisor.found.text;
163
+ out.source = supervisor.found.source;
164
+ }
165
+ if (legs.found) {
166
+ out.worker = legs.found.text;
167
+ out.workerSource = legs.found.source;
168
+ }
169
+ const problems = legs === supervisor ? supervisor.problems : [...supervisor.problems, ...legs.problems];
170
+ if (problems.length > 0) out.warning = `spine: ${problems.join("; ")}`;
171
+ return out;
172
+ }
173
+
174
+ /** Resolve one role's source. Off reads nothing at all, so the disabled path cannot even fail. */
175
+ function resolveRole(role: SpineRole, read: SpineReader): ResolvedRole {
176
+ const { selector, candidates, file, consequence } = role;
177
+ if (selector === "") return { problems: [] };
178
+ if (selector === "on") {
179
+ const { found, problems } = firstUsable(read, candidates);
180
+ if (found) return { found, problems };
181
+ problems.push(`no ${file} found (looked in ${candidates.join(" and ")}) — ${consequence}`);
182
+ return { problems };
183
+ }
184
+ // An explicit path is a deliberate choice: it never falls back to another file, because
185
+ // silently running a DIFFERENT behavioral layer than the one asked for is worse than none.
186
+ const explicit = read(selector);
187
+ const usable = usableText(explicit);
188
+ if (!usable) return { problems: [`${unusableReason(selector, explicit)} — ${consequence}`] };
189
+ return { found: { text: usable, source: selector }, problems: [] };
190
+ }
191
+
192
+ /** The first candidate that yields usable text, plus a note for every candidate that WAS there
193
+ * and still could not be used — an empty or oversize file the user meant to be read is worth
194
+ * saying out loud, whereas a candidate that simply isn't there is the normal case. */
195
+ function firstUsable(read: SpineReader, candidates: string[]): { found?: { text: string; source: string }; problems: string[] } {
196
+ const problems: string[] = [];
197
+ for (const path of candidates) {
198
+ const attempt = read(path);
199
+ if (attempt.kind === "missing") continue;
200
+ const text = usableText(attempt);
201
+ if (text) return { found: { text, source: path }, problems };
202
+ problems.push(`${unusableReason(path, attempt)} — skipped`);
203
+ }
204
+ return { problems };
205
+ }
206
+
207
+ /** An empty (or whitespace-only) file has nothing to inject — treat it as absent. */
208
+ function usableText(attempt: SpineRead): string | undefined {
209
+ if (attempt.kind !== "ok") return undefined;
210
+ const text = attempt.text.trim();
211
+ return text ? text : undefined;
212
+ }
213
+
214
+ /** Why a candidate yielded nothing — stated without a consequence, so each caller can attach
215
+ * its own ("continuing without one" for the single explicit file, "skipped" for a candidate
216
+ * the search moves past). */
217
+ function unusableReason(path: string, attempt: SpineRead): string {
218
+ if (attempt.kind === "missing") return `cannot read ${path}`;
219
+ if (attempt.kind === "oversize") return `${path} is ${attempt.bytes} bytes, over the ${MAX_SPINE_BYTES}-byte limit`;
220
+ return `${path} is empty`;
221
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Persona persistence — remember the last explicitly-selected persona and restore
3
+ * it on the next start. Mirrors pi-subagents-persona: the state file lives in the
4
+ * global persona folder (`<agentDir>/persona/state.json`); session-start restore
5
+ * only READS it, only user gestures (f8 / /persona) write it. Best-effort: any
6
+ * I/O failure is swallowed (persistence must never break a session).
7
+ */
8
+
9
+ import * as fs from "node:fs";
10
+ import * as path from "node:path";
11
+
12
+ export function readLastPersona(stateFile: string): string | undefined {
13
+ try {
14
+ const data = JSON.parse(fs.readFileSync(stateFile, "utf-8")) as { lastPersona?: unknown };
15
+ return typeof data.lastPersona === "string" && data.lastPersona ? data.lastPersona : undefined;
16
+ } catch {
17
+ return undefined;
18
+ }
19
+ }
20
+
21
+ export function writeLastPersona(stateFile: string, name: string | undefined): void {
22
+ try {
23
+ fs.mkdirSync(path.dirname(stateFile), { recursive: true });
24
+ fs.writeFileSync(stateFile, `${JSON.stringify({ lastPersona: name ?? null }, null, 2)}\n`);
25
+ } catch {
26
+ /* best-effort */
27
+ }
28
+ }
@@ -0,0 +1,244 @@
1
+ /** Vendor-neutral telemetry wire contract with a common lifecycle vocabulary. */
2
+ export const TELEMETRY_VERSION = 2 as const;
3
+ export const TELEMETRY_EVENT_NAME = "pi:telemetry" as const;
4
+ export const LEGACY_TELEMETRY_EVENT_NAME = "pi-persona:telemetry" as const;
5
+ export const LEGACY_TELEMETRY_VERSION = 1 as const;
6
+ export const TELEMETRY_PRODUCER_ID = "pi-persona" as const;
7
+ export const TELEMETRY_PRODUCER_VERSION = "1.15.2" as const;
8
+
9
+ export type InstanceStatus = "active" | "idle" | "stopped" | "stale" | (string & {});
10
+ export type AgentKind = "subagent" | "delegate" | "council" | "flow" | "phase" | (string & {});
11
+ export type AgentStatus = "queued" | "running" | "waiting" | "done" | "failed" | "stopped" | (string & {});
12
+ export type MessageChannel = "intercom" | "exocom" | (string & {});
13
+ export type MessageStatus = "queued" | "delivered" | "replied" | "rejected" | "failed" | (string & {});
14
+
15
+ export interface InstanceDescriptor {
16
+ displayName: string;
17
+ status: InstanceStatus;
18
+ /** Optional producer metadata. The common lifecycle does not require a persona or model. */
19
+ persona?: string;
20
+ model?: string;
21
+ pid?: number;
22
+ contextPercent?: number;
23
+ /** pi-persona adapter capability; other producers omit it. */
24
+ exocomEnabled?: boolean;
25
+ color?: string;
26
+ }
27
+
28
+ export interface AgentDescriptor {
29
+ id: string;
30
+ label: string;
31
+ kind: AgentKind;
32
+ status: AgentStatus;
33
+ parentId?: string;
34
+ agent?: string;
35
+ persona?: string;
36
+ model?: string;
37
+ }
38
+
39
+ export interface ToolDescriptor {
40
+ callId: string;
41
+ agentId: string;
42
+ name: string;
43
+ status: "running" | "done" | "failed" | (string & {});
44
+ durationMs?: number;
45
+ }
46
+
47
+ export interface MessageDescriptor {
48
+ id: string;
49
+ channel: MessageChannel;
50
+ from: string;
51
+ to: string;
52
+ kind: string;
53
+ status: MessageStatus;
54
+ expectsReply: boolean;
55
+ size: number;
56
+ replyTo?: string;
57
+ }
58
+
59
+ export interface PeerDescriptor {
60
+ sessionId: string;
61
+ displayName: string;
62
+ persona: string;
63
+ model: string;
64
+ contextPercent: number;
65
+ status: "online" | "idle";
66
+ color?: string;
67
+ sent: number;
68
+ received: number;
69
+ }
70
+
71
+ export type KnownTelemetryEventType =
72
+ | "instance.started" | "instance.updated" | "instance.heartbeat" | "instance.stopped"
73
+ | "agent.added" | "agent.updated" | "agent.removed" | "agent.cleared"
74
+ | "tool.started" | "tool.finished"
75
+ | "message.sent" | "message.received" | "message.replied" | "peers.snapshot";
76
+ /** Namespaced values from other producers are valid v2 events. */
77
+ export type TelemetryEventType = KnownTelemetryEventType | (string & {});
78
+
79
+ export interface TelemetryPayloadByType {
80
+ "instance.started": InstanceDescriptor;
81
+ "instance.updated": Partial<InstanceDescriptor>;
82
+ "instance.heartbeat": Partial<InstanceDescriptor>;
83
+ "instance.stopped": { reason: string };
84
+ "agent.added": AgentDescriptor;
85
+ "agent.updated": { id: string; patch: Partial<AgentDescriptor> };
86
+ "agent.removed": { id: string; status?: AgentStatus };
87
+ "agent.cleared": Record<string, never>;
88
+ "tool.started": ToolDescriptor;
89
+ "tool.finished": ToolDescriptor;
90
+ "message.sent": MessageDescriptor;
91
+ "message.received": MessageDescriptor;
92
+ "message.replied": MessageDescriptor;
93
+ "peers.snapshot": { peers: PeerDescriptor[] };
94
+ }
95
+
96
+ export type TelemetryPayload<T extends TelemetryEventType> = T extends keyof TelemetryPayloadByType ? TelemetryPayloadByType[T] : Record<string, unknown>;
97
+
98
+ export interface GenericTelemetryEvent {
99
+ version: typeof TELEMETRY_VERSION;
100
+ producerId: string;
101
+ producerVersion: string;
102
+ id: string;
103
+ seq: number;
104
+ ts: number;
105
+ sessionId: string;
106
+ workspaceId: string;
107
+ type: string;
108
+ payload: Record<string, unknown>;
109
+ }
110
+
111
+ export type KnownTelemetryEvent = { [K in KnownTelemetryEventType]: { version: typeof TELEMETRY_VERSION; producerId: string; producerVersion: string; id: string; seq: number; ts: number; sessionId: string; workspaceId: string; type: K; payload: TelemetryPayloadByType[K] } }[KnownTelemetryEventType];
112
+ export type TelemetryEvent<T extends TelemetryEventType = TelemetryEventType> = T extends KnownTelemetryEventType
113
+ ? { version: typeof TELEMETRY_VERSION; producerId: string; producerVersion: string; id: string; seq: number; ts: number; sessionId: string; workspaceId: string; type: T; payload: TelemetryPayload<T> }
114
+ : GenericTelemetryEvent;
115
+
116
+ const KNOWN_TYPES = new Set<KnownTelemetryEventType>([
117
+ "instance.started", "instance.updated", "instance.heartbeat", "instance.stopped",
118
+ "agent.added", "agent.updated", "agent.removed", "agent.cleared",
119
+ "tool.started", "tool.finished", "message.sent", "message.received", "message.replied", "peers.snapshot",
120
+ ]);
121
+ const SAFE_ID = /^[A-Za-z0-9._:@/-]+$/;
122
+ const TYPE_NAME = /^[A-Za-z0-9][A-Za-z0-9._:/-]*$/;
123
+ const WORKSPACE_ID = /^[a-f0-9]{24}$/;
124
+ const SENSITIVE_KEY = /(?:task|prompt|args?|command|path|query|url|cwd|output|detail|content|message|secret|token|password|authorization|headers?|api.?key|credential|private.?key)/i;
125
+
126
+ /** Drop raw prompts, arguments, activity, output and credential-shaped fields at the sink boundary. */
127
+ export function sanitizeTelemetryPayload(value: unknown, depth = 0): unknown {
128
+ if (depth > 8) return "[depth bounded]";
129
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean" || value === null) return value;
130
+ if (Array.isArray(value)) return value.slice(0, 256).map((item) => sanitizeTelemetryPayload(item, depth + 1));
131
+ if (!value || typeof value !== "object") return undefined;
132
+ const out: Record<string, unknown> = {};
133
+ for (const [key, item] of Object.entries(value).slice(0, 256)) {
134
+ if (SENSITIVE_KEY.test(key) || item === undefined) continue;
135
+ const safe = sanitizeTelemetryPayload(item, depth + 1);
136
+ if (safe !== undefined) out[key] = safe;
137
+ }
138
+ return out;
139
+ }
140
+
141
+ const KNOWN_FIELDS: Record<string, readonly string[]> = {
142
+ "instance.started": ["displayName", "persona", "model", "status", "pid", "contextPercent", "exocomEnabled", "color"],
143
+ "instance.updated": ["displayName", "persona", "model", "status", "pid", "contextPercent", "exocomEnabled", "color"],
144
+ "instance.heartbeat": ["displayName", "persona", "model", "status", "pid", "contextPercent", "exocomEnabled", "color"],
145
+ "instance.stopped": ["reason"], "agent.added": ["id", "label", "kind", "status", "parentId", "agent", "persona", "model"],
146
+ "agent.updated": ["id", "patch"], "agent.removed": ["id", "status"], "agent.cleared": [],
147
+ "tool.started": ["callId", "agentId", "name", "status"], "tool.finished": ["callId", "agentId", "name", "status", "durationMs"],
148
+ "message.sent": ["id", "channel", "from", "to", "kind", "status", "expectsReply", "size", "replyTo"],
149
+ "message.received": ["id", "channel", "from", "to", "kind", "status", "expectsReply", "size", "replyTo"],
150
+ "message.replied": ["id", "channel", "from", "to", "kind", "status", "expectsReply", "size", "replyTo"],
151
+ "peers.snapshot": ["peers"],
152
+ };
153
+
154
+ /** Producer-side projection is deliberately closed: future producers must publish a reviewed event schema. */
155
+ export function projectTelemetryPayload(type: string, value: unknown): Record<string, unknown> {
156
+ const safe = sanitizeTelemetryPayload(value);
157
+ if (!safe || typeof safe !== "object" || Array.isArray(safe)) return {};
158
+ const fields = KNOWN_FIELDS[type];
159
+ if (!fields) return {};
160
+ const projected = Object.fromEntries(fields.filter((field) => field in safe).map((field) => [field, (safe as Record<string, unknown>)[field]]));
161
+ if (type === "agent.updated" && projected.patch && typeof projected.patch === "object" && !Array.isArray(projected.patch)) {
162
+ const allowed = ["label", "kind", "status", "parentId", "agent", "persona", "model"];
163
+ projected.patch = Object.fromEntries(allowed.filter((field) => field in (projected.patch as Record<string, unknown>)).map((field) => [field, (projected.patch as Record<string, unknown>)[field]]));
164
+ }
165
+ return projected;
166
+ }
167
+
168
+ export function isKnownTelemetryEvent(event: TelemetryEvent): event is KnownTelemetryEvent {
169
+ return KNOWN_TYPES.has(event.type as KnownTelemetryEventType);
170
+ }
171
+
172
+ function objectPayload(value: unknown): Record<string, unknown> | undefined {
173
+ return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
174
+ }
175
+ function stringField(payload: Record<string, unknown>, key: string, required = true): boolean {
176
+ if (payload[key] === undefined) return !required;
177
+ return typeof payload[key] === "string" && (required ? (payload[key] as string).length > 0 : true) && (payload[key] as string).length <= 1_024;
178
+ }
179
+ function numberField(payload: Record<string, unknown>, key: string, opts: { integer?: boolean; min?: number; max?: number } = {}): boolean {
180
+ if (payload[key] === undefined) return true;
181
+ if (typeof payload[key] !== "number" || !Number.isFinite(payload[key])) return false;
182
+ if (opts.integer && !Number.isSafeInteger(payload[key])) return false;
183
+ return (opts.min === undefined || payload[key] >= opts.min) && (opts.max === undefined || payload[key] <= opts.max);
184
+ }
185
+ /** A start event may name its in-flight state with the producer's own word — `terminalTool` in the
186
+ * consumer classifies by denylist and the descriptor type is open — but it may not claim to be over. */
187
+ const TERMINAL_TOOL_STATUS = ["done", "failed"];
188
+ function validKnownPayload(type: string, payload: Record<string, unknown>): boolean {
189
+ if (type === "instance.updated" || type === "instance.heartbeat") return Object.keys(payload).every((key) => ["displayName", "persona", "model", "status", "exocomEnabled", "color", "pid", "contextPercent"].includes(key)) && ["displayName", "status"].every((key) => payload[key] === undefined || stringField(payload, key)) && ["persona", "model", "color"].every((key) => payload[key] === undefined || stringField(payload, key, false)) && (payload.exocomEnabled === undefined || typeof payload.exocomEnabled === "boolean") && numberField(payload, "pid", { integer: true, min: 0 }) && numberField(payload, "contextPercent", { min: 0, max: 100 });
190
+ if (type === "agent.cleared") return true;
191
+ if (type === "instance.started") return stringField(payload, "displayName") && stringField(payload, "status") && ["persona", "model", "color"].every((key) => payload[key] === undefined || stringField(payload, key, false)) && numberField(payload, "pid", { integer: true, min: 0 }) && numberField(payload, "contextPercent", { min: 0, max: 100 }) && (payload.exocomEnabled === undefined || typeof payload.exocomEnabled === "boolean");
192
+ if (type === "instance.stopped") return stringField(payload, "reason");
193
+ if (type === "agent.added") return stringField(payload, "id") && stringField(payload, "label") && stringField(payload, "kind") && stringField(payload, "status") && ["parentId", "agent", "persona", "model"].every((key) => payload[key] === undefined || stringField(payload, key, false));
194
+ if (type === "agent.updated") {
195
+ const patch = objectPayload(payload.patch);
196
+ return stringField(payload, "id") && patch !== undefined && Object.entries(patch).every(([key, value]) => ["label", "kind", "status", "parentId", "agent", "persona", "model"].includes(key) ? typeof value === "string" : false);
197
+ }
198
+ if (type === "agent.removed") return stringField(payload, "id") && (payload.status === undefined || ["done", "failed", "stopped"].includes(payload.status as string));
199
+ if (type === "tool.started") return stringField(payload, "callId") && stringField(payload, "agentId") && stringField(payload, "name") && stringField(payload, "status") && !TERMINAL_TOOL_STATUS.includes(payload.status as string);
200
+ if (type === "tool.finished") return stringField(payload, "callId") && stringField(payload, "agentId") && stringField(payload, "name") && ["done", "failed"].includes(payload.status as string) && numberField(payload, "durationMs", { min: 0 });
201
+ if (type === "message.sent" || type === "message.received" || type === "message.replied") return stringField(payload, "id") && stringField(payload, "channel") && stringField(payload, "from") && stringField(payload, "to") && stringField(payload, "kind") && stringField(payload, "status") && typeof payload.expectsReply === "boolean" && numberField(payload, "size", { integer: true, min: 0 }) && (payload.replyTo === undefined || stringField(payload, "replyTo"));
202
+ if (type === "peers.snapshot") return Array.isArray(payload.peers) && payload.peers.every((peer) => { const p = objectPayload(peer); return p !== undefined && stringField(p, "sessionId") && stringField(p, "displayName") && stringField(p, "persona", false) && stringField(p, "model", false) && numberField(p, "contextPercent", { min: 0, max: 100 }) && typeof p.status === "string" && numberField(p, "sent", { integer: true, min: 0 }) && numberField(p, "received", { integer: true, min: 0 }); });
203
+ return true;
204
+ }
205
+ function containsNonFinite(value: unknown): boolean {
206
+ if (typeof value === "number") return !Number.isFinite(value);
207
+ if (Array.isArray(value)) return value.some(containsNonFinite);
208
+ if (value && typeof value === "object") return Object.values(value).some(containsNonFinite);
209
+ return false;
210
+ }
211
+
212
+ function validIdentity(value: unknown, max: number): value is string {
213
+ return typeof value === "string" && value.length > 0 && value.length <= max && SAFE_ID.test(value);
214
+ }
215
+
216
+ export function parseTelemetryEvent(value: unknown): TelemetryEvent | undefined {
217
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
218
+ const e = value as Record<string, unknown>;
219
+ const legacy = e.version === LEGACY_TELEMETRY_VERSION;
220
+ if (!legacy && e.version !== TELEMETRY_VERSION) return undefined;
221
+ if (!validIdentity(e.id, 256) || !Number.isSafeInteger(e.seq) || (e.seq as number) < 1) return undefined;
222
+ if (typeof e.ts !== "number" || !Number.isFinite(e.ts) || e.ts < 0) return undefined;
223
+ if (!validIdentity(e.sessionId, 128) || typeof e.workspaceId !== "string" || !WORKSPACE_ID.test(e.workspaceId)) return undefined;
224
+ if (typeof e.type !== "string" || e.type.length === 0 || e.type.length > 160 || !TYPE_NAME.test(e.type) || !e.payload || typeof e.payload !== "object" || Array.isArray(e.payload)) return undefined;
225
+ if (legacy && !KNOWN_TYPES.has(e.type as KnownTelemetryEventType)) return undefined;
226
+ const producerId = legacy ? TELEMETRY_PRODUCER_ID : e.producerId;
227
+ const producerVersion = legacy ? "legacy-v1" : e.producerVersion;
228
+ if (!validIdentity(producerId, 96) || !validIdentity(producerVersion, 64)) return undefined;
229
+ if (KNOWN_TYPES.has(e.type as KnownTelemetryEventType) && containsNonFinite(e.payload)) return undefined;
230
+ const payload = KNOWN_TYPES.has(e.type as KnownTelemetryEventType) ? projectTelemetryPayload(e.type, e.payload) : {};
231
+ if (KNOWN_TYPES.has(e.type as KnownTelemetryEventType) && !validKnownPayload(e.type, payload)) return undefined;
232
+ return {
233
+ version: TELEMETRY_VERSION,
234
+ producerId,
235
+ producerVersion,
236
+ id: e.id,
237
+ seq: e.seq,
238
+ ts: e.ts,
239
+ sessionId: e.sessionId,
240
+ workspaceId: e.workspaceId,
241
+ type: e.type,
242
+ payload,
243
+ } as TelemetryEvent;
244
+ }
@@ -0,0 +1,14 @@
1
+ import { createHash } from "node:crypto";
2
+ import { resolve } from "node:path";
3
+
4
+ /** Stable workspace scope shared by every telemetry producer, independent of plugin storage. */
5
+ export function telemetryWorkspaceId(cwd: string): string {
6
+ const resolved = resolve(cwd);
7
+ const canonical = process.platform === "win32" ? resolved.toLowerCase() : resolved;
8
+ return createHash("sha256").update(canonical).digest("hex").slice(0, 24);
9
+ }
10
+
11
+ /** Opaque filename component; the original session id remains only inside validated envelopes. */
12
+ export function telemetrySessionFileKey(sessionId: string): string {
13
+ return createHash("sha256").update(sessionId).digest("hex").slice(0, 16);
14
+ }