@xpufx/paseo-top 0.4.4 → 0.4.7

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 (130) hide show
  1. package/OVERVIEW.md +18 -0
  2. package/client/host-ui.tsx +184 -0
  3. package/client/multi-host-view.tsx +57 -43
  4. package/client/multi-host.ts +36 -13
  5. package/client/pill-labels.ts +94 -1
  6. package/client/pill.tsx +692 -616
  7. package/client/resources-query.ts +1 -1
  8. package/client/settings-events.ts +1 -1
  9. package/client/settings-ui.tsx +86 -9
  10. package/client/surface.tsx +308 -276
  11. package/client/telemetry-copy.ts +8 -2
  12. package/client/telemetry.tsx +372 -321
  13. package/client/turn-panel.tsx +126 -104
  14. package/client/vendor/paseo-plugin-helper/{host.ts → core/host.ts} +9 -3
  15. package/client/vendor/paseo-plugin-helper/core/index.ts +76 -0
  16. package/client/vendor/paseo-plugin-helper/core/plugin-load-count.ts +89 -0
  17. package/client/vendor/paseo-plugin-helper/{query-refresh.ts → core/query-refresh.ts} +2 -2
  18. package/client/vendor/paseo-plugin-helper/{query.ts → core/query.ts} +2 -2
  19. package/client/vendor/paseo-plugin-helper/{settings.ts → core/settings.ts} +1 -1
  20. package/client/vendor/paseo-plugin-helper/{shared-settings.ts → core/shared-settings.ts} +2 -2
  21. package/client/vendor/paseo-plugin-helper/{utils → lifecycle}/clipboard.ts +2 -2
  22. package/client/vendor/paseo-plugin-helper/lifecycle/flair.ts +26 -0
  23. package/client/vendor/paseo-plugin-helper/{theme/color-utils.ts → lifecycle/host-color.ts} +95 -38
  24. package/client/vendor/paseo-plugin-helper/lifecycle/host-theme.tsx +109 -0
  25. package/client/vendor/paseo-plugin-helper/lifecycle/index.ts +23 -0
  26. package/client/vendor/paseo-plugin-helper/lifecycle/multi-host.ts +66 -0
  27. package/client/vendor/paseo-plugin-helper/{panel.tsx → lifecycle/panel.tsx} +7 -7
  28. package/client/vendor/paseo-plugin-helper/{pill.tsx → lifecycle/pill.tsx} +51 -18
  29. package/client/vendor/paseo-plugin-helper/lifecycle/providers.tsx +46 -0
  30. package/client/vendor/paseo-plugin-helper/lifecycle/scroll-owner.ts +26 -0
  31. package/client/vendor/paseo-plugin-helper/{surface.tsx → lifecycle/surface.tsx} +6 -6
  32. package/client/vendor/paseo-plugin-helper/ui/color.ts +1 -0
  33. package/client/vendor/paseo-plugin-helper/ui/content.tsx +1326 -0
  34. package/client/vendor/paseo-plugin-helper/ui/controls.tsx +1018 -0
  35. package/client/vendor/paseo-plugin-helper/{components/DataTable.tsx → ui/data.tsx} +50 -58
  36. package/client/vendor/paseo-plugin-helper/ui/index.ts +44 -0
  37. package/client/vendor/paseo-plugin-helper/ui/layout.tsx +263 -0
  38. package/client/vendor/paseo-plugin-helper/ui/modal.tsx +111 -0
  39. package/client/vendor/paseo-plugin-helper/ui/plugin-reload-banner.tsx +119 -0
  40. package/client/vendor/paseo-plugin-helper/{settings-screen.tsx → ui/settings-screen.tsx} +3 -3
  41. package/client/vendor/paseo-plugin-helper/ui/settings.ts +36 -0
  42. package/client/vendor/paseo-plugin-helper/ui/text.tsx +821 -0
  43. package/client/vendor/paseo-plugin-helper/ui/theme.tsx +1 -0
  44. package/client/vendor/permission-audit/client.tsx +706 -0
  45. package/client/vendor/permission-audit/index.tsx +1 -0
  46. package/index.client.tsx +58 -37
  47. package/index.server.ts +256 -234
  48. package/npm-shrinkwrap.json +44 -0
  49. package/package.json +8 -4
  50. package/paseo-plugin.json +2 -0
  51. package/screenshots/1-paseo-top-modal-system.jpg +0 -0
  52. package/screenshots/2-paseo-top-modal-workspace.jpg +0 -0
  53. package/screenshots/3-paseo-top-modal-settings.jpg +0 -0
  54. package/screenshots/4-paseo-top-modal-about.jpg +0 -0
  55. package/screenshots/paseo-top-dashboard.jpg +0 -0
  56. package/screenshots/paseo-top-general-view.jpg +0 -0
  57. package/server/resources.ts +98 -0
  58. package/server/vendor/paseo-plugin-helper/custom-pills.ts +70 -0
  59. package/server/vendor/paseo-plugin-helper/index.ts +2 -0
  60. package/server/vendor/paseo-plugin-helper/logger.ts +5 -16
  61. package/server/vendor/paseo-plugin-helper/rpc-guard.ts +2 -2
  62. package/server/vendor/paseo-plugin-helper/storage-guard.ts +294 -0
  63. package/server/vendor/paseo-plugin-helper/storage.ts +36 -2
  64. package/server/vendor/paseo-plugin-helper/telemetry.ts +486 -0
  65. package/server/vendor/paseo-plugin-helper/tickets.ts +18 -67
  66. package/server/vendor/permission-audit/index.ts +1 -0
  67. package/server/vendor/permission-audit/server.ts +839 -0
  68. package/shared/helper-version.ts +1 -1
  69. package/shared/multi-host.ts +153 -159
  70. package/shared/resources.ts +145 -0
  71. package/shared/vendor/paseo-plugin-helper/async.ts +14 -7
  72. package/shared/vendor/paseo-plugin-helper/features.ts +244 -0
  73. package/shared/vendor/paseo-plugin-helper/forge.ts +27 -0
  74. package/shared/vendor/paseo-plugin-helper/index.ts +1 -0
  75. package/shared/vendor/paseo-plugin-helper/tickets.ts +122 -28
  76. package/shared/vendor/permission-audit/filter.ts +61 -0
  77. package/shared/vendor/permission-audit/index.ts +2 -0
  78. package/shared/vendor/permission-audit/shared.ts +119 -0
  79. package/shared/version.ts +1 -1
  80. package/client/vendor/paseo-plugin-helper/components/AboutSection.tsx +0 -493
  81. package/client/vendor/paseo-plugin-helper/components/AttentionBeacon.tsx +0 -250
  82. package/client/vendor/paseo-plugin-helper/components/Badge.tsx +0 -168
  83. package/client/vendor/paseo-plugin-helper/components/Button.tsx +0 -204
  84. package/client/vendor/paseo-plugin-helper/components/Card.tsx +0 -257
  85. package/client/vendor/paseo-plugin-helper/components/CodeBlock.tsx +0 -196
  86. package/client/vendor/paseo-plugin-helper/components/Collapsible.tsx +0 -277
  87. package/client/vendor/paseo-plugin-helper/components/CommandBox.tsx +0 -172
  88. package/client/vendor/paseo-plugin-helper/components/CopyButton.tsx +0 -180
  89. package/client/vendor/paseo-plugin-helper/components/EmptyState.tsx +0 -97
  90. package/client/vendor/paseo-plugin-helper/components/HighlightedText.tsx +0 -70
  91. package/client/vendor/paseo-plugin-helper/components/InlineButton.tsx +0 -88
  92. package/client/vendor/paseo-plugin-helper/components/InteractiveRow.tsx +0 -142
  93. package/client/vendor/paseo-plugin-helper/components/KeyValue.tsx +0 -446
  94. package/client/vendor/paseo-plugin-helper/components/MetricGauge.tsx +0 -247
  95. package/client/vendor/paseo-plugin-helper/components/ProgressBar.tsx +0 -117
  96. package/client/vendor/paseo-plugin-helper/components/Responsive.tsx +0 -53
  97. package/client/vendor/paseo-plugin-helper/components/SearchInput.tsx +0 -132
  98. package/client/vendor/paseo-plugin-helper/components/SectionHeader.tsx +0 -87
  99. package/client/vendor/paseo-plugin-helper/components/Select.tsx +0 -283
  100. package/client/vendor/paseo-plugin-helper/components/StatusDot.tsx +0 -80
  101. package/client/vendor/paseo-plugin-helper/components/Tabs.tsx +0 -319
  102. package/client/vendor/paseo-plugin-helper/components/TextInput.tsx +0 -150
  103. package/client/vendor/paseo-plugin-helper/components/Toggle.tsx +0 -163
  104. package/client/vendor/paseo-plugin-helper/components/TruncatedText.tsx +0 -157
  105. package/client/vendor/paseo-plugin-helper/components/index.ts +0 -26
  106. package/client/vendor/paseo-plugin-helper/custom-pills.tsx +0 -225
  107. package/client/vendor/paseo-plugin-helper/forge-icon.tsx +0 -79
  108. package/client/vendor/paseo-plugin-helper/icon.tsx +0 -39
  109. package/client/vendor/paseo-plugin-helper/index.ts +0 -30
  110. package/client/vendor/paseo-plugin-helper/layout/ActionBar.tsx +0 -49
  111. package/client/vendor/paseo-plugin-helper/layout/FormRow.tsx +0 -103
  112. package/client/vendor/paseo-plugin-helper/layout/Grid.tsx +0 -65
  113. package/client/vendor/paseo-plugin-helper/layout/ModalBody.tsx +0 -368
  114. package/client/vendor/paseo-plugin-helper/layout/ModalContent.tsx +0 -51
  115. package/client/vendor/paseo-plugin-helper/layout/Row.tsx +0 -39
  116. package/client/vendor/paseo-plugin-helper/layout/Stack.tsx +0 -39
  117. package/client/vendor/paseo-plugin-helper/layout/index.ts +0 -7
  118. package/client/vendor/paseo-plugin-helper/styles/index.ts +0 -1
  119. package/client/vendor/paseo-plugin-helper/styles/recipes.ts +0 -550
  120. package/client/vendor/paseo-plugin-helper/theme/flair.ts +0 -76
  121. package/client/vendor/paseo-plugin-helper/theme/host-variables.ts +0 -121
  122. package/client/vendor/paseo-plugin-helper/theme/index.ts +0 -7
  123. package/client/vendor/paseo-plugin-helper/theme/provider.tsx +0 -178
  124. package/client/vendor/paseo-plugin-helper/theme/responsive.ts +0 -213
  125. package/client/vendor/paseo-plugin-helper/theme/tokens.ts +0 -200
  126. package/client/vendor/paseo-plugin-helper/theme/useResponsive.ts +0 -57
  127. package/client/vendor/paseo-plugin-helper/tickets.tsx +0 -766
  128. /package/client/vendor/paseo-plugin-helper/{command-center.ts → core/command-center.ts} +0 -0
  129. /package/client/vendor/paseo-plugin-helper/{snapshot.ts → core/snapshot.ts} +0 -0
  130. /package/client/vendor/paseo-plugin-helper/{utils → lifecycle}/haptics.ts +0 -0
@@ -0,0 +1,839 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import type { FeatureModule } from "../../../shared/vendor/paseo-plugin-helper/index";
5
+ import {
6
+ AuditRecordSchema,
7
+ PERMISSION_AUDIT_FILENAME,
8
+ PERMISSION_AUDIT_PLUGIN_ID,
9
+ PermissionAuditEntrySchema,
10
+ ToolCallAuditEntrySchema,
11
+ auditRecordKey,
12
+ isPermissionEntry,
13
+ isToolCallEntry,
14
+ permissionAuditQuery,
15
+ permissionLoggerQuery,
16
+ type AuditRecord,
17
+ type PermissionAuditEntry,
18
+ type PermissionDecision,
19
+ type PermissionLogPaths,
20
+ type PermissionQueryFilter,
21
+ type ToolCallAuditEntry,
22
+ type ToolCallOutcome,
23
+ } from "../../../shared/vendor/permission-audit/shared.js";
24
+
25
+ export type {
26
+ AuditRecord,
27
+ PermissionAuditEntry,
28
+ PermissionDecision,
29
+ PermissionLogPaths,
30
+ PermissionQueryFilter,
31
+ ToolCallAuditEntry,
32
+ ToolCallOutcome,
33
+ } from "../../../shared/vendor/permission-audit/shared.js";
34
+ export {
35
+ AuditRecordSchema,
36
+ PERMISSION_AUDIT_FILENAME,
37
+ PERMISSION_AUDIT_PLUGIN_ID,
38
+ PermissionAuditEntrySchema,
39
+ ToolCallAuditEntrySchema,
40
+ auditRecordKey,
41
+ isPermissionEntry,
42
+ isToolCallEntry,
43
+ permissionAuditQuery,
44
+ permissionLoggerQuery,
45
+ } from "../../../shared/vendor/permission-audit/shared.js";
46
+
47
+ export function resolvePermissionLogPaths(): PermissionLogPaths {
48
+ const override = process.env.PASEO_PERMISSION_LOG_PATH?.trim();
49
+ const legacy = path.join(os.homedir(), ".paseo", "logs", PERMISSION_AUDIT_FILENAME);
50
+ if (override) return { primary: override, legacy };
51
+ const primary = path.join(
52
+ os.homedir(),
53
+ ".paseo",
54
+ "plugin-data",
55
+ "xpufx",
56
+ PERMISSION_AUDIT_PLUGIN_ID,
57
+ PERMISSION_AUDIT_FILENAME,
58
+ );
59
+ return { primary, legacy };
60
+ }
61
+
62
+ export function resolveDefaultLogPath(): string {
63
+ return resolvePermissionLogPaths().primary;
64
+ }
65
+
66
+ export interface PermissionLogStoreOptions {
67
+ filePath?: string;
68
+ legacyFilePath?: string;
69
+ }
70
+
71
+ function readJsonlFile(filePath: string): AuditRecord[] {
72
+ let raw: string;
73
+ try {
74
+ raw = fs.readFileSync(filePath, "utf8");
75
+ } catch {
76
+ return [];
77
+ }
78
+ const entries: AuditRecord[] = [];
79
+ for (const line of raw.split("\n")) {
80
+ const trimmed = line.trim();
81
+ if (!trimmed) continue;
82
+ try {
83
+ entries.push(AuditRecordSchema.parse(JSON.parse(trimmed)));
84
+ } catch {
85
+ continue;
86
+ }
87
+ }
88
+ return entries;
89
+ }
90
+
91
+ function auditRecordSearchText(record: AuditRecord): string {
92
+ const parts = [record.name, record.kind, record.agentId, record.agentModel ?? ""];
93
+ if (isToolCallEntry(record)) {
94
+ parts.push(
95
+ record.outcome,
96
+ record.turnId ?? "",
97
+ JSON.stringify(record.input ?? null),
98
+ JSON.stringify(record.error ?? null),
99
+ JSON.stringify(record.result ?? null),
100
+ );
101
+ } else {
102
+ parts.push(record.decision, JSON.stringify(record.input ?? null));
103
+ }
104
+ return parts.join(" ").toLowerCase();
105
+ }
106
+
107
+ function auditRecordSequence(record: AuditRecord): number {
108
+ return isToolCallEntry(record) ? (record.sequence ?? 0) : 0;
109
+ }
110
+
111
+ /** Newest first; tool calls sharing a turn timestamp break the tie by timeline order. */
112
+ function compareAuditRecordsNewestFirst(a: AuditRecord, b: AuditRecord): number {
113
+ if (a.timestamp !== b.timestamp) return a.timestamp < b.timestamp ? 1 : -1;
114
+ return auditRecordSequence(b) - auditRecordSequence(a);
115
+ }
116
+
117
+ export class PermissionLogStore {
118
+ readonly filePath: string;
119
+ readonly legacyFilePath: string | null;
120
+
121
+ constructor(options: PermissionLogStoreOptions = {}) {
122
+ const paths = resolvePermissionLogPaths();
123
+ this.filePath = options.filePath ?? paths.primary;
124
+ if (options.legacyFilePath !== undefined) {
125
+ this.legacyFilePath = options.legacyFilePath;
126
+ } else if (options.filePath) {
127
+ this.legacyFilePath = null;
128
+ } else {
129
+ this.legacyFilePath = paths.legacy;
130
+ }
131
+ }
132
+
133
+ append<T extends AuditRecord>(entry: T): T {
134
+ const validated = AuditRecordSchema.parse(entry) as T;
135
+ fs.mkdirSync(path.dirname(this.filePath), { recursive: true });
136
+ fs.appendFileSync(this.filePath, `${JSON.stringify(validated)}\n`, "utf8");
137
+ return validated;
138
+ }
139
+
140
+ readAll(): AuditRecord[] {
141
+ const entries: AuditRecord[] = [];
142
+ if (this.legacyFilePath && this.legacyFilePath !== this.filePath) {
143
+ entries.push(...readJsonlFile(this.legacyFilePath));
144
+ }
145
+ entries.push(...readJsonlFile(this.filePath));
146
+ return entries;
147
+ }
148
+
149
+ readLatest(): AuditRecord[] {
150
+ const byKey = new Map<string, AuditRecord>();
151
+ for (const entry of this.readAll()) {
152
+ byKey.set(auditRecordKey(entry), entry);
153
+ }
154
+ return Array.from(byKey.values());
155
+ }
156
+
157
+ query(filter: PermissionQueryFilter): { entries: AuditRecord[]; total: number } {
158
+ const fromMs = filter.from ? Date.parse(filter.from) : NaN;
159
+ const toMs = filter.to ? Date.parse(filter.to) : NaN;
160
+ const search = filter.search?.trim().toLowerCase() ?? "";
161
+ const matched = this.readLatest().filter((entry) => {
162
+ if (filter.agentId && entry.agentId !== filter.agentId) return false;
163
+ if (filter.model && entry.agentModel !== filter.model) return false;
164
+ if (filter.provider && entry.agentProvider !== filter.provider) return false;
165
+ if (
166
+ filter.recordType &&
167
+ (isToolCallEntry(entry) ? "tool_call" : "permission") !== filter.recordType
168
+ ) {
169
+ return false;
170
+ }
171
+ if (filter.decision && !(isPermissionEntry(entry) && entry.decision === filter.decision)) {
172
+ return false;
173
+ }
174
+ if (filter.outcome && !(isToolCallEntry(entry) && entry.outcome === filter.outcome)) {
175
+ return false;
176
+ }
177
+ if (filter.kind && entry.kind !== filter.kind) return false;
178
+ const ts = Date.parse(entry.timestamp);
179
+ if (!Number.isNaN(fromMs) && (Number.isNaN(ts) || ts < fromMs)) return false;
180
+ if (!Number.isNaN(toMs) && (Number.isNaN(ts) || ts > toMs)) return false;
181
+ if (search && !auditRecordSearchText(entry).includes(search)) return false;
182
+ return true;
183
+ });
184
+ matched.sort(compareAuditRecordsNewestFirst);
185
+ const limit = filter.limit ?? 100;
186
+ return { entries: matched.slice(0, limit), total: matched.length };
187
+ }
188
+ }
189
+
190
+ export interface AgentAttribution {
191
+ id?: string;
192
+ title?: string;
193
+ model?: string;
194
+ provider?: string;
195
+ mode?: string;
196
+ cwd?: string;
197
+ }
198
+
199
+ function asRecord(value: unknown): Record<string, unknown> | null {
200
+ return typeof value === "object" && value !== null ? (value as Record<string, unknown>) : null;
201
+ }
202
+
203
+ function asString(value: unknown): string | undefined {
204
+ return typeof value === "string" && value.length > 0 ? value : undefined;
205
+ }
206
+
207
+ function pickString(sources: Array<Record<string, unknown> | null>, keys: string[]): string | undefined {
208
+ for (const source of sources) {
209
+ if (!source) continue;
210
+ for (const key of keys) {
211
+ const found = asString(source[key]);
212
+ if (found) return found;
213
+ }
214
+ }
215
+ return undefined;
216
+ }
217
+
218
+ export function extractAgentAttribution(agent: unknown): AgentAttribution {
219
+ const root = asRecord(agent);
220
+ if (!root) return {};
221
+ const nested = asRecord(root.agent) ?? asRecord(root.session) ?? null;
222
+ const sources = [root, nested];
223
+ return {
224
+ id: pickString(sources, ["id", "agentId"]),
225
+ title: pickString(sources, ["title", "name"]),
226
+ model: pickString(sources, ["model", "modelId"]),
227
+ provider: pickString(sources, ["provider", "providerId"]),
228
+ mode: pickString(sources, ["modeId", "mode"]),
229
+ cwd: pickString(sources, ["cwd", "workingDirectory", "workdir"]),
230
+ };
231
+ }
232
+
233
+ export function normalizeDecision(raw: unknown): PermissionDecision | null {
234
+ const record = asRecord(raw);
235
+ const candidates: unknown[] = [
236
+ record?.behavior,
237
+ record?.decision,
238
+ record?.verdict,
239
+ record?.approved,
240
+ record?.allowed,
241
+ typeof raw === "string" || typeof raw === "boolean" ? raw : undefined,
242
+ ];
243
+ for (const candidate of candidates) {
244
+ if (candidate === true || candidate === "allow" || candidate === "approve" || candidate === "approved") {
245
+ return "allow";
246
+ }
247
+ if (candidate === false || candidate === "deny" || candidate === "denied" || candidate === "reject") {
248
+ return "deny";
249
+ }
250
+ }
251
+ return null;
252
+ }
253
+
254
+ export interface PendingPermissionRequest {
255
+ id: string;
256
+ timestamp: string;
257
+ agentId?: string;
258
+ kind?: string;
259
+ name?: string;
260
+ input?: unknown;
261
+ attribution?: AgentAttribution;
262
+ }
263
+
264
+ interface ResolveParts {
265
+ requestId?: string;
266
+ agentId?: string;
267
+ kind?: string;
268
+ name?: string;
269
+ input?: unknown;
270
+ updatedInput?: unknown;
271
+ denyReason?: string;
272
+ decision?: PermissionDecision | null;
273
+ attribution?: AgentAttribution;
274
+ }
275
+
276
+ function getAt(record: Record<string, unknown> | null, pathParts: string[]): unknown {
277
+ let current: unknown = record;
278
+ for (const key of pathParts) {
279
+ const next = asRecord(current);
280
+ if (!next) return undefined;
281
+ current = next[key];
282
+ }
283
+ return current;
284
+ }
285
+
286
+ function firstDefined(values: unknown[]): unknown {
287
+ for (const value of values) {
288
+ if (value !== undefined) return value;
289
+ }
290
+ return undefined;
291
+ }
292
+
293
+ export function splitResolveEvent(event: unknown, context: unknown): ResolveParts {
294
+ const root = asRecord(event) ?? {};
295
+ const request = asRecord(root.request) ?? asRecord(root.permission) ?? null;
296
+ const response =
297
+ asRecord(root.response) ?? asRecord(root.result) ?? asRecord(root.resolution) ?? null;
298
+ const contextRecord = asRecord(context);
299
+ const contextAgent = contextRecord ? (contextRecord.agent ?? asRecord(contextRecord.paseo)?.agent) : undefined;
300
+
301
+ const requestId =
302
+ asString(root.requestId) ??
303
+ asString(request?.id) ??
304
+ asString(request?.requestId) ??
305
+ asString(root.id);
306
+ const decision =
307
+ normalizeDecision(response) ??
308
+ normalizeDecision(root) ??
309
+ normalizeDecision(request) ??
310
+ normalizeDecision(asRecord(root.response) ?? null);
311
+
312
+ const attribution = extractAgentAttribution(
313
+ firstDefined([root.agent, request?.agent, response?.agent, contextAgent]) ?? null,
314
+ );
315
+
316
+ return {
317
+ requestId,
318
+ agentId:
319
+ asString(root.agentId) ??
320
+ asString(request?.agentId) ??
321
+ asString(response?.agentId) ??
322
+ attribution.id,
323
+ kind: asString(request?.kind) ?? asString(root.kind),
324
+ name:
325
+ asString(request?.name) ??
326
+ asString(request?.title) ??
327
+ asString(request?.tool) ??
328
+ asString(root.name) ??
329
+ asString(root.title),
330
+ input: firstDefined([request?.input, root.input]),
331
+ updatedInput: firstDefined([
332
+ getAt(response, ["updatedInput"]),
333
+ getAt(root, ["updatedInput"]),
334
+ getAt(response, ["updatedPermissions"]),
335
+ ]),
336
+ denyReason:
337
+ asString(response?.message) ??
338
+ asString(response?.denyReason) ??
339
+ asString(response?.reason) ??
340
+ asString(root.message) ??
341
+ asString(root.denyReason),
342
+ decision,
343
+ attribution,
344
+ };
345
+ }
346
+
347
+ export function splitRequestEvent(event: unknown, context: unknown): PendingPermissionRequest | null {
348
+ const root = asRecord(event) ?? {};
349
+ const request = asRecord(root.request) ?? asRecord(root.permission) ?? root;
350
+ const id = asString(request.id) ?? asString(request.requestId) ?? asString(root.requestId);
351
+ if (!id) return null;
352
+ const contextRecord = asRecord(context);
353
+ const contextAgent = contextRecord ? (contextRecord.agent ?? asRecord(contextRecord.paseo)?.agent) : undefined;
354
+ const attribution = extractAgentAttribution(
355
+ firstDefined([root.agent, request.agent, contextAgent]) ?? null,
356
+ );
357
+ return {
358
+ id,
359
+ timestamp: asString(root.timestamp) ?? asString(request.timestamp) ?? new Date().toISOString(),
360
+ agentId: asString(request.agentId) ?? asString(root.agentId) ?? attribution.id,
361
+ kind: asString(request.kind) ?? asString(root.kind),
362
+ name:
363
+ asString(request.name) ??
364
+ asString(request.title) ??
365
+ asString(request.tool) ??
366
+ asString(root.name),
367
+ input: firstDefined([request.input, root.input]),
368
+ attribution,
369
+ };
370
+ }
371
+
372
+ function compact(value: Record<string, unknown>): Record<string, unknown> | undefined {
373
+ const entries = Object.entries(value).filter(([, v]) => v !== undefined);
374
+ return entries.length > 0 ? Object.fromEntries(entries) : undefined;
375
+ }
376
+
377
+ function normalizeToolOutcome(status: unknown): ToolCallOutcome {
378
+ switch (status) {
379
+ case "completed":
380
+ return "success";
381
+ case "failed":
382
+ return "failure";
383
+ default:
384
+ // "canceled", or "running" when the turn ended before the call settled.
385
+ return "canceled";
386
+ }
387
+ }
388
+
389
+ /**
390
+ * The daemon's tool-call timeline item carries the parameters inside its
391
+ * structured `detail`; providers that expose a raw `input` are preferred when
392
+ * present. Output is separated out by `toolCallResult`.
393
+ */
394
+ function toolCallInput(
395
+ item: Record<string, unknown>,
396
+ detail: Record<string, unknown> | null,
397
+ ): unknown {
398
+ if (item.input !== undefined) return item.input;
399
+ if (!detail) return null;
400
+ switch (asString(detail.type)) {
401
+ case "shell":
402
+ return compact({ command: detail.command, cwd: detail.cwd }) ?? null;
403
+ case "read":
404
+ return compact({ filePath: detail.filePath, offset: detail.offset, limit: detail.limit }) ?? null;
405
+ case "edit":
406
+ return compact({
407
+ filePath: detail.filePath,
408
+ oldString: detail.oldString,
409
+ newString: detail.newString,
410
+ }) ?? null;
411
+ case "write":
412
+ return compact({ filePath: detail.filePath, content: detail.content }) ?? null;
413
+ case "search":
414
+ return compact({ query: detail.query, toolName: detail.toolName, mode: detail.mode }) ?? null;
415
+ case "fetch":
416
+ return compact({ url: detail.url, prompt: detail.prompt }) ?? null;
417
+ case "worktree_setup":
418
+ return compact({ worktreePath: detail.worktreePath, branchName: detail.branchName }) ?? null;
419
+ case "sub_agent":
420
+ return compact({
421
+ subAgentType: detail.subAgentType,
422
+ description: detail.description,
423
+ childSessionId: detail.childSessionId,
424
+ }) ?? null;
425
+ case "plain_text":
426
+ return compact({ label: detail.label }) ?? null;
427
+ case "plan":
428
+ return compact({ text: detail.text }) ?? null;
429
+ case "unknown":
430
+ return detail.input ?? null;
431
+ default:
432
+ return detail;
433
+ }
434
+ }
435
+
436
+ function toolCallResult(detail: Record<string, unknown> | null): unknown {
437
+ if (!detail) return undefined;
438
+ switch (asString(detail.type)) {
439
+ case "shell":
440
+ return compact({ output: detail.output, exitCode: detail.exitCode });
441
+ case "read":
442
+ return detail.content ?? undefined;
443
+ case "edit":
444
+ return detail.unifiedDiff ?? undefined;
445
+ case "search":
446
+ return compact({
447
+ content: detail.content,
448
+ filePaths: detail.filePaths,
449
+ webResults: detail.webResults,
450
+ numFiles: detail.numFiles,
451
+ numMatches: detail.numMatches,
452
+ });
453
+ case "fetch":
454
+ return compact({
455
+ result: detail.result,
456
+ code: detail.code,
457
+ codeText: detail.codeText,
458
+ bytes: detail.bytes,
459
+ });
460
+ case "worktree_setup":
461
+ return compact({ log: detail.log, commands: detail.commands });
462
+ case "sub_agent":
463
+ return compact({ log: detail.log, actions: detail.actions });
464
+ case "plain_text":
465
+ return detail.text ?? undefined;
466
+ case "unknown":
467
+ return detail.output ?? undefined;
468
+ default:
469
+ return undefined;
470
+ }
471
+ }
472
+
473
+ export interface ToolCallCapture {
474
+ id: string;
475
+ turnId?: string;
476
+ sequence: number;
477
+ agentId: string;
478
+ attribution: AgentAttribution;
479
+ name: string;
480
+ input: unknown;
481
+ outcome: ToolCallOutcome;
482
+ error?: unknown;
483
+ result?: unknown;
484
+ }
485
+
486
+ /** Extracts one capture per `tool_call` item in an `agent.turn_ended` timeline. */
487
+ export function splitToolCallTimeline(event: unknown, context?: unknown): ToolCallCapture[] {
488
+ const root = asRecord(event);
489
+ if (!root) return [];
490
+ const contextRecord = asRecord(context);
491
+ const agentSource = firstDefined([
492
+ root.agent,
493
+ contextRecord?.agent,
494
+ asRecord(contextRecord?.paseo)?.agent,
495
+ ]);
496
+ const attribution = extractAgentAttribution(agentSource ?? null);
497
+ const agentId = asString(root.agentId) ?? attribution.id;
498
+ if (!agentId) return [];
499
+
500
+ const turnId = asString(root.turnId);
501
+ const timeline = Array.isArray(root.timeline) ? root.timeline : [];
502
+ const captures: ToolCallCapture[] = [];
503
+ timeline.forEach((raw, index) => {
504
+ const item = asRecord(raw);
505
+ if (!item || item.type !== "tool_call") return;
506
+ const detail = asRecord(item.detail);
507
+ const capture: ToolCallCapture = {
508
+ id: asString(item.callId) ?? `${turnId ?? "turn"}:${index}`,
509
+ sequence: index,
510
+ agentId,
511
+ attribution,
512
+ name: asString(item.name) ?? asString(detail?.type) ?? "tool",
513
+ input: toolCallInput(item, detail),
514
+ outcome: normalizeToolOutcome(item.status),
515
+ };
516
+ if (turnId) capture.turnId = turnId;
517
+ if (item.error !== undefined && item.error !== null) capture.error = item.error;
518
+ const result = toolCallResult(detail);
519
+ if (result !== undefined) capture.result = result;
520
+ captures.push(capture);
521
+ });
522
+ return captures;
523
+ }
524
+
525
+ function stableJson(value: unknown): string {
526
+ return JSON.stringify(value ?? null) ?? "null";
527
+ }
528
+
529
+ /** Identity of a captured call's content; excludes capture time and ordering. */
530
+ function toolCallFingerprint(entry: ToolCallAuditEntry): string {
531
+ return [
532
+ entry.turnId ?? "",
533
+ entry.agentId,
534
+ entry.name,
535
+ entry.outcome,
536
+ stableJson(entry.input),
537
+ stableJson(entry.error),
538
+ stableJson(entry.result),
539
+ ].join("|");
540
+ }
541
+
542
+ export interface PermissionLoggerOptions {
543
+ store: PermissionLogStore;
544
+ now?: () => string;
545
+ onRecord?: (entry: AuditRecord) => void;
546
+ }
547
+
548
+ export function createPermissionLogger(options: PermissionLoggerOptions) {
549
+ const { store, onRecord } = options;
550
+ const now = options.now ?? (() => new Date().toISOString());
551
+ const pending = new Map<string, PendingPermissionRequest>();
552
+
553
+ function handleRequested(event: unknown, context?: unknown): PermissionAuditEntry | null {
554
+ const parsed = splitRequestEvent(event, context);
555
+ if (!parsed) return null;
556
+ pending.set(parsed.id, parsed);
557
+
558
+ const attribution = parsed.attribution ?? {};
559
+ const agentId = parsed.agentId ?? attribution.id;
560
+ if (!parsed.id || !agentId) return null;
561
+
562
+ const candidate = {
563
+ id: parsed.id,
564
+ timestamp: parsed.timestamp ?? now(),
565
+ agentId,
566
+ agentTitle: attribution.title,
567
+ agentModel: attribution.model,
568
+ agentProvider: attribution.provider,
569
+ agentMode: attribution.mode,
570
+ agentCwd: attribution.cwd,
571
+ kind: parsed.kind ?? "tool",
572
+ name: parsed.name ?? "permission",
573
+ input: parsed.input ?? null,
574
+ decision: "pending" as const,
575
+ };
576
+ const validated = PermissionAuditEntrySchema.parse(candidate);
577
+ const stored = store.append(validated);
578
+ onRecord?.(stored);
579
+ return stored;
580
+ }
581
+
582
+ function handleResolved(event: unknown, context?: unknown): PermissionAuditEntry | null {
583
+ const parts = splitResolveEvent(event, context);
584
+ const prior = parts.requestId ? pending.get(parts.requestId) : undefined;
585
+ if (parts.requestId) pending.delete(parts.requestId);
586
+
587
+ const attribution = {
588
+ ...(prior?.attribution ?? {}),
589
+ ...(parts.attribution ?? {}),
590
+ };
591
+ const agentId = parts.agentId ?? prior?.agentId ?? attribution.id;
592
+ const decision = parts.decision;
593
+ const id = parts.requestId ?? prior?.id;
594
+ const kind = parts.kind ?? prior?.kind ?? "tool";
595
+ const name = parts.name ?? prior?.name ?? "permission";
596
+ if (!id || !agentId || !decision) return null;
597
+
598
+ const candidate = {
599
+ id,
600
+ timestamp: now(),
601
+ agentId,
602
+ agentTitle: attribution.title,
603
+ agentModel: attribution.model,
604
+ agentProvider: attribution.provider,
605
+ agentMode: attribution.mode,
606
+ agentCwd: attribution.cwd,
607
+ kind,
608
+ name,
609
+ input: firstDefined([parts.input, prior?.input]) ?? null,
610
+ decision,
611
+ updatedInput: parts.updatedInput,
612
+ denyReason: parts.denyReason,
613
+ };
614
+ const parsed = PermissionAuditEntrySchema.parse(candidate);
615
+ const stored = store.append(parsed);
616
+ onRecord?.(stored);
617
+ return stored;
618
+ }
619
+
620
+ function handleTurnActivity(event: unknown): PermissionAuditEntry[] {
621
+ const root = asRecord(event);
622
+ const agentId = asString(root?.agentId) ?? asString(asRecord(root?.agent)?.id);
623
+ if (!agentId) return [];
624
+ const resolved: PermissionAuditEntry[] = [];
625
+ for (const [id, req] of pending.entries()) {
626
+ if (req.agentId === agentId) {
627
+ pending.delete(id);
628
+ const attribution = req.attribution ?? {};
629
+ const candidate = {
630
+ id: req.id,
631
+ timestamp: now(),
632
+ agentId,
633
+ agentTitle: attribution.title,
634
+ agentModel: attribution.model,
635
+ agentProvider: attribution.provider,
636
+ agentMode: attribution.mode,
637
+ agentCwd: attribution.cwd,
638
+ kind: req.kind ?? "tool",
639
+ name: req.name ?? "permission",
640
+ input: req.input ?? null,
641
+ decision: "allow" as const,
642
+ };
643
+ const parsed = PermissionAuditEntrySchema.parse(candidate);
644
+ const stored = store.append(parsed);
645
+ onRecord?.(stored);
646
+ resolved.push(stored);
647
+ }
648
+ }
649
+ return resolved;
650
+ }
651
+
652
+ const capturedToolCalls = new Map<string, string>();
653
+ let toolCallsSeeded = false;
654
+
655
+ // Seed from the persisted log so a daemon reload does not re-append calls the
656
+ // turn-ended timeline replays on every subsequent turn.
657
+ function seedCapturedToolCalls(): void {
658
+ if (toolCallsSeeded) return;
659
+ toolCallsSeeded = true;
660
+ for (const existing of store.readLatest()) {
661
+ if (isToolCallEntry(existing)) {
662
+ capturedToolCalls.set(
663
+ `${existing.agentId}:${existing.id}`,
664
+ toolCallFingerprint(existing),
665
+ );
666
+ }
667
+ }
668
+ }
669
+
670
+ function recordToolCall(capture: ToolCallCapture): ToolCallAuditEntry | null {
671
+ seedCapturedToolCalls();
672
+ const attribution = capture.attribution ?? {};
673
+ const candidate: ToolCallAuditEntry = {
674
+ recordType: "tool_call",
675
+ id: capture.id,
676
+ timestamp: now(),
677
+ ...(capture.turnId ? { turnId: capture.turnId } : {}),
678
+ sequence: capture.sequence,
679
+ agentId: capture.agentId,
680
+ ...(attribution.title ? { agentTitle: attribution.title } : {}),
681
+ ...(attribution.model ? { agentModel: attribution.model } : {}),
682
+ ...(attribution.provider ? { agentProvider: attribution.provider } : {}),
683
+ ...(attribution.mode ? { agentMode: attribution.mode } : {}),
684
+ ...(attribution.cwd ? { agentCwd: attribution.cwd } : {}),
685
+ kind: "tool_call",
686
+ name: capture.name,
687
+ input: capture.input ?? null,
688
+ outcome: capture.outcome,
689
+ ...(capture.error !== undefined ? { error: capture.error } : {}),
690
+ ...(capture.result !== undefined ? { result: capture.result } : {}),
691
+ };
692
+ const validated = ToolCallAuditEntrySchema.parse(candidate);
693
+ const key = `${capture.agentId}:${capture.id}`;
694
+ const fingerprint = toolCallFingerprint(validated);
695
+ if (capturedToolCalls.get(key) === fingerprint) return null;
696
+ capturedToolCalls.set(key, fingerprint);
697
+ const stored = store.append(validated);
698
+ onRecord?.(stored);
699
+ return stored;
700
+ }
701
+
702
+ function handleTurnEnded(event: unknown, context?: unknown): AuditRecord[] {
703
+ const stored: AuditRecord[] = [...handleTurnActivity(event)];
704
+ for (const capture of splitToolCallTimeline(event, context)) {
705
+ const record = recordToolCall(capture);
706
+ if (record) stored.push(record);
707
+ }
708
+ return stored;
709
+ }
710
+
711
+ return { handleRequested, handleResolved, handleTurnActivity, handleTurnEnded, recordToolCall, pending };
712
+ }
713
+
714
+ const REQUESTED_EVENTS = ["agent.permission_requested", "permission.requested"];
715
+ const RESOLVED_EVENTS = ["agent.permission_resolved", "permission.resolved"];
716
+ const TURN_START_EVENTS = ["agent.turn_started"];
717
+ const TURN_ENDED_EVENTS = ["agent.turn_ended"];
718
+
719
+ interface EventedServer {
720
+ on?: (event: string, handler: (event: unknown, context: unknown) => void) => () => void;
721
+ }
722
+
723
+ export function subscribePermissionEvents(
724
+ server: EventedServer,
725
+ logger: Pick<
726
+ ReturnType<typeof createPermissionLogger>,
727
+ "handleRequested" | "handleResolved" | "handleTurnActivity" | "handleTurnEnded"
728
+ >,
729
+ ): () => void {
730
+ if (typeof server.on !== "function") return () => {};
731
+ const offs: Array<() => void> = [];
732
+ for (const name of REQUESTED_EVENTS) {
733
+ try {
734
+ offs.push(server.on(name, (event, context) => logger.handleRequested(event, context)));
735
+ } catch {
736
+ continue;
737
+ }
738
+ }
739
+ for (const name of RESOLVED_EVENTS) {
740
+ try {
741
+ offs.push(server.on(name, (event, context) => logger.handleResolved(event, context)));
742
+ } catch {
743
+ continue;
744
+ }
745
+ }
746
+ for (const name of TURN_START_EVENTS) {
747
+ try {
748
+ offs.push(server.on(name, (event) => logger.handleTurnActivity(event)));
749
+ } catch {
750
+ continue;
751
+ }
752
+ }
753
+ for (const name of TURN_ENDED_EVENTS) {
754
+ try {
755
+ offs.push(server.on(name, (event, context) => logger.handleTurnEnded(event, context)));
756
+ } catch {
757
+ continue;
758
+ }
759
+ }
760
+ return () => {
761
+ for (const off of offs) {
762
+ try {
763
+ off();
764
+ } catch {
765
+ continue;
766
+ }
767
+ }
768
+ };
769
+ }
770
+
771
+ export interface PermissionAuditServerOptions {
772
+ filePath?: string;
773
+ now?: () => string;
774
+ onRecord?: (entry: AuditRecord) => void;
775
+ logger?: { info: (message: string, details?: Record<string, unknown>) => void };
776
+ }
777
+
778
+ interface RpcServer {
779
+ on?: (event: string, handler: (event: unknown, context: unknown) => void) => () => void;
780
+ handle: (contract: { name: string }, handler: (input: any) => unknown) => void;
781
+ }
782
+
783
+ export function registerPermissionAuditServer(server: RpcServer, options: PermissionAuditServerOptions = {}) {
784
+ const store = new PermissionLogStore({ filePath: options.filePath });
785
+ const logger = createPermissionLogger({
786
+ store,
787
+ now: options.now,
788
+ onRecord: options.onRecord ?? ((entry) => {
789
+ if (isToolCallEntry(entry)) {
790
+ options.logger?.info("tool call logged", {
791
+ id: entry.id,
792
+ agentId: entry.agentId,
793
+ name: entry.name,
794
+ outcome: entry.outcome,
795
+ turnId: entry.turnId,
796
+ });
797
+ return;
798
+ }
799
+ options.logger?.info("permission decision logged", {
800
+ id: entry.id,
801
+ agentId: entry.agentId,
802
+ name: entry.name,
803
+ decision: entry.decision,
804
+ });
805
+ }),
806
+ });
807
+
808
+ const unsubscribe = subscribePermissionEvents(server, logger);
809
+ const queryHandler = (input: PermissionQueryFilter) => store.query(input);
810
+ server.handle(permissionAuditQuery, queryHandler);
811
+ server.handle(permissionLoggerQuery, queryHandler);
812
+
813
+ return { store, logger, unsubscribe };
814
+ }
815
+
816
+ export interface PermissionAuditFeatureOptions extends PermissionAuditServerOptions {
817
+ id?: string;
818
+ onMount?: (result: { store: PermissionLogStore; logger: ReturnType<typeof createPermissionLogger> }) => void;
819
+ }
820
+
821
+ /**
822
+ * Creates a reusable FeatureModule for permission auditing (#933).
823
+ */
824
+ export function createPermissionAuditFeature<TServerContext = any, TClientContext = any>(
825
+ options: PermissionAuditFeatureOptions = {},
826
+ ): FeatureModule<TServerContext, TClientContext> {
827
+ return {
828
+ id: options.id || "permission-audit",
829
+ contracts: {
830
+ query: permissionAuditQuery as any,
831
+ legacyQuery: permissionLoggerQuery as any,
832
+ },
833
+ contributeServer(server: any) {
834
+ const result = registerPermissionAuditServer(server, options);
835
+ options.onMount?.(result);
836
+ return result.unsubscribe;
837
+ },
838
+ };
839
+ }