@fitchmultz/pi-subagents 0.50.0 → 0.52.0

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 (180) hide show
  1. package/CHANGELOG.md +63 -2
  2. package/README.md +36 -5
  3. package/dist/agent/child-runtime.js +80 -0
  4. package/dist/agent/contracts.js +15 -24
  5. package/dist/agent/host.js +3 -16
  6. package/dist/agent/index.js +212 -192
  7. package/dist/agent/journal-tail.js +115 -156
  8. package/dist/agent/launch.js +20 -8
  9. package/dist/agent/legacy.js +65 -23
  10. package/dist/agent/notifications.js +138 -47
  11. package/dist/agent/observation-state.js +246 -0
  12. package/dist/agent/observation-view.js +258 -0
  13. package/dist/agent/observation.js +350 -0
  14. package/dist/agent/observe-card.js +243 -0
  15. package/dist/agent/observe-chrome.js +85 -0
  16. package/dist/agent/observe-client.js +285 -0
  17. package/dist/agent/observe-layout.js +65 -0
  18. package/dist/agent/observe-reading.js +84 -0
  19. package/dist/agent/observe-screen.js +400 -96
  20. package/dist/agent/observe-transcript.js +382 -0
  21. package/dist/agent/observe.js +250 -43
  22. package/dist/agent/parking.js +253 -0
  23. package/dist/agent/process.js +228 -28
  24. package/dist/agent/receipts.js +92 -20
  25. package/dist/agent/runner.js +229 -126
  26. package/dist/agent/shell.js +2 -1
  27. package/dist/agent/storage.js +47 -12
  28. package/dist/agent/tool-schemas.js +20 -0
  29. package/dist/agent/typebox.js +10 -5
  30. package/dist/agent/usage.js +0 -115
  31. package/dist/agent/view.js +46 -16
  32. package/dist/agent/wake.js +99 -0
  33. package/dist/build-info.js +1 -1
  34. package/dist/intercom-client.d.ts +26 -0
  35. package/dist/intercom-client.js +45 -0
  36. package/dist/pi-intercom/agent-dir.d.ts +1 -0
  37. package/dist/pi-intercom/bridge-session.js +2 -1
  38. package/dist/pi-intercom/broker/broker.js +62 -43
  39. package/dist/pi-intercom/broker/client-last.d.ts +24 -0
  40. package/dist/pi-intercom/broker/client-last.js +42 -5
  41. package/dist/pi-intercom/broker/client-park.d.ts +9 -0
  42. package/dist/pi-intercom/broker/client-park.js +21 -0
  43. package/dist/pi-intercom/broker/client.d.ts +59 -0
  44. package/dist/pi-intercom/broker/client.js +55 -29
  45. package/dist/pi-intercom/broker/connection.d.ts +29 -0
  46. package/dist/pi-intercom/broker/framing.d.ts +17 -0
  47. package/dist/pi-intercom/broker/framing.js +10 -7
  48. package/dist/pi-intercom/broker/guard.js +23 -0
  49. package/dist/pi-intercom/broker/last.d.ts +30 -0
  50. package/dist/pi-intercom/broker/last.js +87 -40
  51. package/dist/pi-intercom/broker/parked.js +107 -0
  52. package/dist/pi-intercom/broker/paths.d.ts +13 -0
  53. package/dist/pi-intercom/broker/paths.js +35 -12
  54. package/dist/pi-intercom/broker/pid.js +5 -1
  55. package/dist/pi-intercom/broker/server-connection.js +20 -1
  56. package/dist/pi-intercom/broker/snapshot-reader.d.ts +9 -0
  57. package/dist/pi-intercom/broker/snapshots.d.ts +29 -0
  58. package/dist/pi-intercom/broker/targets.d.ts +9 -0
  59. package/dist/pi-intercom/connection.js +71 -20
  60. package/dist/pi-intercom/identity.js +6 -5
  61. package/dist/pi-intercom/inbound-delivery.js +16 -60
  62. package/dist/pi-intercom/inbound-journal.js +83 -6
  63. package/dist/pi-intercom/inbound-record.js +18 -1
  64. package/dist/pi-intercom/inbound.js +22 -37
  65. package/dist/pi-intercom/index.js +41 -0
  66. package/dist/pi-intercom/last-message.js +4 -16
  67. package/dist/pi-intercom/lifecycle.js +88 -3
  68. package/dist/pi-intercom/outbound.js +5 -15
  69. package/dist/pi-intercom/reply-tracker.js +7 -6
  70. package/dist/pi-intercom/reply-wait.js +2 -2
  71. package/dist/pi-intercom/session-targets.d.ts +24 -0
  72. package/dist/pi-intercom/tool-registration.js +4 -1
  73. package/dist/pi-intercom/tool-results.js +5 -7
  74. package/dist/pi-intercom/tool-schemas.js +12 -26
  75. package/dist/pi-intercom/types.d.ts +212 -0
  76. package/dist/pi-intercom/types.js +54 -3
  77. package/dist/pi-intercom/validation.d.ts +12 -0
  78. package/dist/pi-intercom/validation.js +3 -0
  79. package/dist/shared/bounded-json.js +151 -0
  80. package/dist/shared/compact-cards.js +66 -0
  81. package/dist/shared/journal-frames.js +3 -6
  82. package/dist/shared/json-projection.js +267 -129
  83. package/dist/shared/native-journal.js +123 -56
  84. package/dist/shared/parking.js +12 -0
  85. package/dist/shared/post-exit-stdio-guard.js +120 -7
  86. package/dist/shared/tui-action.js +29 -0
  87. package/dist/shared/unknown.d.ts +9 -0
  88. package/docs/code-quality.md +5 -3
  89. package/docs/development.md +42 -3
  90. package/docs/intercom-bridge.md +9 -4
  91. package/docs/intercom.md +228 -7
  92. package/docs/performance.md +118 -14
  93. package/docs/reference.md +168 -20
  94. package/docs/retired-history-maintenance.md +252 -0
  95. package/package.json +28 -15
  96. package/scripts/archive-retired-history.mjs +464 -0
  97. package/scripts/build.mjs +48 -28
  98. package/scripts/measure-size.mjs +1 -1
  99. package/scripts/native-package-smoke.mjs +153 -12
  100. package/scripts/package-smoke.mjs +80 -11
  101. package/scripts/prepare.mjs +2 -2
  102. package/scripts/real-pi-live.mjs +377 -0
  103. package/scripts/real-pi-processes.mjs +6 -1
  104. package/scripts/real-pi-smoke.mjs +6 -282
  105. package/scripts/run-tests.mjs +4 -2
  106. package/scripts/typescript.mjs +35 -0
  107. package/skills/pi-intercom/SKILL.md +2 -1
  108. package/skills/pi-subagents/SKILL.md +23 -5
  109. package/src/agent/child-runtime.ts +105 -0
  110. package/src/agent/contracts.ts +18 -34
  111. package/src/agent/host.ts +39 -20
  112. package/src/agent/index.ts +234 -207
  113. package/src/agent/journal-tail.ts +141 -153
  114. package/src/agent/launch.ts +33 -9
  115. package/src/agent/legacy.ts +85 -31
  116. package/src/agent/notifications.ts +192 -67
  117. package/src/agent/observation-state.ts +277 -0
  118. package/src/agent/observation-view.ts +267 -0
  119. package/src/agent/observation.ts +409 -0
  120. package/src/agent/observe-card.ts +304 -0
  121. package/src/agent/observe-chrome.ts +170 -0
  122. package/src/agent/observe-client.ts +318 -0
  123. package/src/agent/observe-layout.ts +79 -0
  124. package/src/agent/observe-reading.ts +97 -0
  125. package/src/agent/observe-screen.ts +443 -129
  126. package/src/agent/observe-transcript.ts +434 -0
  127. package/src/agent/observe.ts +304 -71
  128. package/src/agent/parking.ts +341 -0
  129. package/src/agent/process.ts +280 -37
  130. package/src/agent/receipts.ts +114 -24
  131. package/src/agent/runner.ts +227 -149
  132. package/src/agent/shell.ts +2 -1
  133. package/src/agent/storage.ts +44 -11
  134. package/src/agent/tool-schemas.ts +33 -0
  135. package/src/agent/typebox.ts +12 -7
  136. package/src/agent/usage.ts +0 -130
  137. package/src/agent/view.ts +70 -20
  138. package/src/agent/wake.ts +107 -0
  139. package/src/intercom-client.ts +108 -0
  140. package/src/pi-intercom/bridge-session.ts +2 -2
  141. package/src/pi-intercom/broker/broker.ts +64 -53
  142. package/src/pi-intercom/broker/client-last.ts +65 -12
  143. package/src/pi-intercom/broker/client-park.ts +23 -0
  144. package/src/pi-intercom/broker/client.ts +63 -45
  145. package/src/pi-intercom/broker/framing.ts +11 -8
  146. package/src/pi-intercom/broker/guard.ts +33 -0
  147. package/src/pi-intercom/broker/last.ts +131 -43
  148. package/src/pi-intercom/broker/parked.ts +130 -0
  149. package/src/pi-intercom/broker/paths.ts +39 -12
  150. package/src/pi-intercom/broker/pid.ts +6 -1
  151. package/src/pi-intercom/broker/server-connection.ts +27 -2
  152. package/src/pi-intercom/connection.ts +103 -39
  153. package/src/pi-intercom/identity.ts +8 -5
  154. package/src/pi-intercom/inbound-delivery.ts +20 -66
  155. package/src/pi-intercom/inbound-journal.ts +103 -7
  156. package/src/pi-intercom/inbound-record.ts +21 -4
  157. package/src/pi-intercom/inbound.ts +32 -49
  158. package/src/pi-intercom/index.ts +44 -0
  159. package/src/pi-intercom/last-message.ts +11 -20
  160. package/src/pi-intercom/lifecycle.ts +117 -14
  161. package/src/pi-intercom/outbound.ts +7 -16
  162. package/src/pi-intercom/reply-tracker.ts +8 -6
  163. package/src/pi-intercom/reply-wait.ts +3 -2
  164. package/src/pi-intercom/runtime-types.ts +0 -1
  165. package/src/pi-intercom/tool-registration.ts +4 -1
  166. package/src/pi-intercom/tool-results.ts +10 -9
  167. package/src/pi-intercom/tool-schemas.ts +12 -44
  168. package/src/pi-intercom/types.ts +115 -4
  169. package/src/pi-intercom/validation.ts +4 -0
  170. package/src/shared/bounded-json.ts +162 -0
  171. package/src/shared/compact-cards.ts +118 -0
  172. package/src/shared/journal-frames.ts +3 -19
  173. package/src/shared/json-projection.ts +267 -147
  174. package/src/shared/native-journal.ts +157 -75
  175. package/src/shared/parking.ts +22 -0
  176. package/src/shared/post-exit-stdio-guard.ts +165 -7
  177. package/src/shared/tui-action.ts +39 -0
  178. package/tsconfig.client.json +8 -0
  179. package/tsconfig.json +1 -0
  180. package/src/shared/stream-json.d.ts +0 -10
@@ -1,26 +1,14 @@
1
1
  import { StringEnum } from "@earendil-works/pi-ai";
2
2
  import { Type } from "../agent/typebox.ts";
3
3
  export const intercomSchema = Type.Object({
4
- action: StringEnum(["list", "send", "ask", "reply", "pending", "status", "last"] as const, {
5
- description:
6
- "List, send, ask, reply, pending asks, connection status, or last completed assistant response.",
7
- }),
4
+ action: StringEnum(["list", "send", "ask", "reply", "pending", "status", "last"] as const),
8
5
  scope: Type.Optional(
9
6
  StringEnum(["project", "all"] as const, {
10
- description:
11
- "For list/status: 'all' (default) includes every reachable session; 'project' further filters this repository and its worktrees.",
12
- }),
13
- ),
14
- to: Type.Optional(
15
- Type.String({
16
- description: "Target session name or ID (for 'send', 'ask', or disambiguating 'reply')",
17
- }),
18
- ),
19
- message: Type.Optional(
20
- Type.String({
21
- description: "Message for send/ask/reply",
7
+ description: "all (default) or project (repo and worktrees)",
22
8
  }),
23
9
  ),
10
+ to: Type.Optional(Type.String({ description: "Session name or ID" })),
11
+ message: Type.Optional(Type.String()),
24
12
  attachments: Type.Optional(
25
13
  Type.Array(
26
14
  Type.Object({
@@ -31,41 +19,21 @@ export const intercomSchema = Type.Object({
31
19
  }),
32
20
  ),
33
21
  ),
34
- replyTo: Type.Optional(
35
- Type.String({
36
- description: "Message ID to reply to (for threading or responding to an 'ask')",
37
- }),
38
- ),
22
+ replyTo: Type.Optional(Type.String({ description: "Message ID to answer or thread" })),
39
23
  delivery: Type.Optional(
40
24
  StringEnum(["queue", "steer", "passive"] as const, {
41
25
  description:
42
- "Delivery mode. Omitted send delivery defaults to 'steer', which injects after the current tool call. Use 'queue' only to intentionally wait behind active work; 'passive' does not wake the recipient model.",
26
+ "steer (default) at next tool boundary; queue after active work; passive never wakes the model",
43
27
  }),
44
28
  ),
45
29
  queueMode: Type.Optional(
46
30
  StringEnum(["stack", "replace"] as const, {
47
- description:
48
- "For delivery='queue': 'stack' keeps all messages; 'replace' keeps only the latest undelivered message for the same threadId.",
49
- }),
50
- ),
51
- threadId: Type.Optional(
52
- Type.String({ description: "Stable topic key for queueMode='replace'." }),
53
- ),
54
- entryId: Type.Optional(Type.String({ description: "Last response entry ID for stable paging." })),
55
- offset: Type.Optional(
56
- Type.Integer({ minimum: 0, description: "Last response character offset (UTF-16)." }),
57
- ),
58
- limit: Type.Optional(
59
- Type.Integer({
60
- minimum: 1,
61
- maximum: 16384,
62
- description: "Last response page size; default/max 16384 characters.",
63
- }),
64
- ),
65
- passive: Type.Optional(
66
- Type.Boolean({
67
- description:
68
- "For action='send' only: legacy alias for delivery='passive'. Discouraged for agent-to-agent messages.",
31
+ description: "stack keeps all; replace keeps only the latest per threadId",
69
32
  }),
70
33
  ),
34
+ threadId: Type.Optional(Type.String()),
35
+ entryId: Type.Optional(Type.String()),
36
+ offset: Type.Optional(Type.Integer({ minimum: 0, description: "UTF-16 characters" })),
37
+ limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 16384 })),
38
+ passive: Type.Optional(Type.Boolean({ description: "Legacy alias for delivery=passive" })),
71
39
  });
@@ -2,6 +2,7 @@ import {
2
2
  finiteNumber,
3
3
  isRecord,
4
4
  isUnknownArray,
5
+ nonNegativeInteger,
5
6
  optionalBoolean,
6
7
  optionalFiniteNumber,
7
8
  optionalLabel,
@@ -18,6 +19,9 @@ export interface SessionInfo {
18
19
  readonly projectId?: string;
19
20
  readonly rootSessionId?: string;
20
21
  readonly parentSessionId?: string;
22
+ readonly incarnation?: string;
23
+ readonly guardedDelivery?: 1;
24
+ readonly applicationGuard?: string;
21
25
  readonly status?: string;
22
26
  readonly lastSeen?: number;
23
27
  readonly lastIntercomActivity?: number;
@@ -83,6 +87,42 @@ export function isTopicUpdate(value: unknown): value is TopicUpdate {
83
87
 
84
88
  export type MessageDelivery = "queue" | "steer" | "passive";
85
89
  export type QueueMode = "stack" | "replace";
90
+ export interface ContextTarget {
91
+ readonly rootSessionId: string;
92
+ readonly peerId: string;
93
+ readonly incarnation: string;
94
+ }
95
+ export interface DeliveryGuard extends ContextTarget {
96
+ readonly applicationGuard: string;
97
+ }
98
+ export interface PublicationReplyHint {
99
+ readonly tool: string;
100
+ readonly commandId: string;
101
+ readonly commandFingerprint?: string;
102
+ }
103
+ export type SendOptions = Omit<Message, "id" | "timestamp" | "content"> &
104
+ Message["content"] & { readonly messageId?: string };
105
+ export function isContextTarget(value: unknown): value is ContextTarget {
106
+ return (
107
+ isRecord(value) &&
108
+ ["rootSessionId", "peerId", "incarnation"].every((key) => safeLabel(value[key]))
109
+ );
110
+ }
111
+ export function isDeliveryGuard(value: unknown): value is DeliveryGuard {
112
+ return isContextTarget(value) && "applicationGuard" in value && safeLabel(value.applicationGuard);
113
+ }
114
+ export function isPublicationReplyHint(value: unknown): value is PublicationReplyHint {
115
+ return (
116
+ isRecord(value) &&
117
+ typeof value.tool === "string" &&
118
+ /^[a-zA-Z_][a-zA-Z0-9_]{0,79}$/.test(value.tool) &&
119
+ typeof value.commandId === "string" &&
120
+ /^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/.test(value.commandId) &&
121
+ (value.commandFingerprint === undefined ||
122
+ (typeof value.commandFingerprint === "string" &&
123
+ /^[a-f0-9]{64}$/.test(value.commandFingerprint)))
124
+ );
125
+ }
86
126
  export interface HumanMessageOrigin {
87
127
  readonly ownerSessionId: string;
88
128
  readonly runId: string;
@@ -115,6 +155,8 @@ export interface Message {
115
155
  readonly queueMode?: QueueMode;
116
156
  readonly threadId?: string;
117
157
  readonly passive?: boolean;
158
+ readonly guard?: DeliveryGuard;
159
+ readonly replyHint?: PublicationReplyHint;
118
160
  readonly content: {
119
161
  readonly text: string;
120
162
  readonly attachments?: readonly Attachment[];
@@ -137,6 +179,9 @@ export function isAttachment(value: unknown): value is Attachment {
137
179
  }
138
180
 
139
181
  function validMessageOrigin(message: Readonly<Record<string, unknown>>): boolean {
182
+ if (!validMessageGuard(message)) {
183
+ return false;
184
+ }
140
185
  if (message.human !== undefined && !isHumanMessageOrigin(message.human)) {
141
186
  return false;
142
187
  }
@@ -151,6 +196,20 @@ function validMessageOrigin(message: Readonly<Record<string, unknown>>): boolean
151
196
  );
152
197
  }
153
198
 
199
+ function validMessageGuard(message: Readonly<Record<string, unknown>>): boolean {
200
+ if (message.guard === undefined) {
201
+ return message.replyHint === undefined;
202
+ }
203
+ return (
204
+ isDeliveryGuard(message.guard) &&
205
+ message.expectsReply === false &&
206
+ message.replyTo === undefined &&
207
+ message.human === undefined &&
208
+ message.topic === undefined &&
209
+ (message.replyHint === undefined || isPublicationReplyHint(message.replyHint))
210
+ );
211
+ }
212
+
154
213
  function validDelivery(message: Readonly<Record<string, unknown>>): boolean {
155
214
  if (
156
215
  message.delivery !== undefined &&
@@ -254,8 +313,13 @@ function validSessionTopics(session: Readonly<Record<string, unknown>>): boolean
254
313
 
255
314
  function validSessionLineage(value: Readonly<Record<string, unknown>>): boolean {
256
315
  return (
257
- optionalLabel(value.rootSessionId) &&
258
- optionalLabel(value.parentSessionId) &&
316
+ ["rootSessionId", "parentSessionId", "incarnation", "applicationGuard"].every((key) =>
317
+ optionalLabel(value[key]),
318
+ ) &&
319
+ (value.guardedDelivery === undefined ||
320
+ (value.guardedDelivery === 1 &&
321
+ safeLabel(value.incarnation) &&
322
+ safeLabel(value.rootSessionId))) &&
259
323
  (value.parentSessionId === undefined || value.rootSessionId === value.parentSessionId)
260
324
  );
261
325
  }
@@ -361,6 +425,23 @@ export interface LastMessageQuery {
361
425
  readonly limit?: number;
362
426
  }
363
427
 
428
+ export const LAST_MESSAGE_PAGE_LIMIT = 16384;
429
+ function optionalEntryId(value: unknown): boolean {
430
+ return value === undefined || (typeof value === "string" && value.length > 0);
431
+ }
432
+ export function isLastMessageQuery(value: unknown): value is LastMessageQuery {
433
+ return (
434
+ isRecord(value) &&
435
+ value.messageId === undefined &&
436
+ optionalEntryId(value.entryId) &&
437
+ (value.offset === undefined || nonNegativeInteger(value.offset)) &&
438
+ (value.limit === undefined ||
439
+ (nonNegativeInteger(value.limit) &&
440
+ value.limit > 0 &&
441
+ value.limit <= LAST_MESSAGE_PAGE_LIMIT))
442
+ );
443
+ }
444
+
364
445
  export type LastMessageResult =
365
446
  | {
366
447
  readonly session: SessionInfo;
@@ -372,6 +453,34 @@ export type LastMessageResult =
372
453
  }
373
454
  | { readonly error: string };
374
455
 
456
+ export interface ReceiptQuery {
457
+ readonly rootSessionId: string;
458
+ readonly messageId: string;
459
+ }
460
+ export function isReceiptQuery(value: unknown): value is ReceiptQuery {
461
+ return isRecord(value) && [value.rootSessionId, value.messageId].every(safeLabel);
462
+ }
463
+ export interface NativeReceipt extends ReceiptQuery {
464
+ readonly version: 1;
465
+ readonly sender: {
466
+ readonly peerId: string;
467
+ readonly rootSessionId?: string;
468
+ readonly incarnation?: string;
469
+ };
470
+ readonly textSha256: string;
471
+ readonly delivery: MessageDelivery;
472
+ readonly guard?: DeliveryGuard;
473
+ readonly replyHint?: PublicationReplyHint;
474
+ readonly checkpointEntryId: string;
475
+ readonly receivedAt: number;
476
+ /** Historical observations, not a promise that an input is still queued. */
477
+ readonly queued: boolean;
478
+ readonly nativeHandoff: boolean;
479
+ readonly placementEntryId: string | null;
480
+ readonly discarded: boolean;
481
+ }
482
+ export type ReceiptResult = { readonly receipt: NativeReceipt } | { readonly error: string };
483
+
375
484
  export type BrokerMessage =
376
485
  | {
377
486
  readonly type: "registered";
@@ -379,6 +488,8 @@ export type BrokerMessage =
379
488
  readonly topicsSupported?: true;
380
489
  readonly topicFrames?: true;
381
490
  readonly lastMessages?: true;
491
+ readonly receiptReads?: 1;
492
+ readonly guardedDelivery?: 1;
382
493
  }
383
494
  | {
384
495
  readonly type: "sessions";
@@ -397,12 +508,12 @@ export type BrokerMessage =
397
508
  readonly type: "last_request";
398
509
  readonly requestId: string;
399
510
  readonly from: SessionInfo;
400
- readonly query: LastMessageQuery;
511
+ readonly query: LastMessageQuery | ReceiptQuery;
401
512
  }
402
513
  | {
403
514
  readonly type: "last_response";
404
515
  readonly requestId: string;
405
- readonly result: LastMessageResult;
516
+ readonly result: LastMessageResult | ReceiptResult;
406
517
  }
407
518
  | { readonly type: "session_left"; readonly sessionId: string }
408
519
  | { readonly type: "delivered"; readonly messageId: string }
@@ -64,6 +64,10 @@ export function finiteNumber(value: unknown): value is number {
64
64
  return typeof value === "number" && Number.isFinite(value);
65
65
  }
66
66
 
67
+ export function nonNegativeInteger(value: unknown): value is number {
68
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
69
+ }
70
+
67
71
  export function optionalFiniteNumber(value: unknown): boolean {
68
72
  return value === undefined || finiteNumber(value);
69
73
  }
@@ -0,0 +1,162 @@
1
+ import { types } from "node:util";
2
+
3
+ /** Live previews of arbitrary native values; durable journal records remain authoritative. */
4
+ export type Unavailable = "oversize" | "cyclic" | "unreadable" | "unsupported";
5
+ export type Bounded = { readonly json: string } | { readonly unavailable: Unavailable };
6
+ export interface JsonBudget {
7
+ readonly bytes: number;
8
+ readonly nodes: number;
9
+ readonly depth: number;
10
+ }
11
+ class Limit extends Error {
12
+ readonly reason: Unavailable;
13
+ constructor(reason: Unavailable) {
14
+ super(reason);
15
+ this.reason = reason;
16
+ }
17
+ }
18
+
19
+ /**
20
+ * Proxies, accessors and custom serializers would run arbitrary code inside the actor; binary
21
+ * values allocate their full representation. None of them is read.
22
+ */
23
+ function opaque(value: object): boolean {
24
+ return (
25
+ types.isProxy(value) ||
26
+ ArrayBuffer.isView(value) ||
27
+ types.isAnyArrayBuffer(value) ||
28
+ "toJSON" in value
29
+ );
30
+ }
31
+ /** An own data property's value, without invoking a getter. */
32
+ function data(value: object, key: string): unknown {
33
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
34
+ if (descriptor !== undefined && !("value" in descriptor)) {
35
+ throw new Limit("unsupported");
36
+ }
37
+ return descriptor?.value;
38
+ }
39
+
40
+ /** Serialize like JSON.stringify within byte, node and depth limits, without first cloning. */
41
+ class Writer {
42
+ private readonly parts: string[] = [];
43
+ private bytes = 0;
44
+ private nodes = 0;
45
+ private readonly ancestors = new Set<object>();
46
+ private readonly budget: JsonBudget;
47
+ constructor(budget: JsonBudget) {
48
+ this.budget = budget;
49
+ }
50
+ text(): string {
51
+ return this.parts.join("");
52
+ }
53
+ private add(text: string, bytes = text.length): void {
54
+ this.bytes += bytes;
55
+ if (this.bytes > this.budget.bytes) {
56
+ throw new Limit("oversize");
57
+ }
58
+ this.parts.push(text);
59
+ }
60
+ private string(value: string): void {
61
+ // A string never encodes to fewer bytes than code units; refuse before copying it.
62
+ if (value.length > this.budget.bytes - this.bytes) {
63
+ throw new Limit("oversize");
64
+ }
65
+ const json = JSON.stringify(value);
66
+ this.add(json, Buffer.byteLength(json));
67
+ }
68
+ /** Returns false for values JSON omits from objects and nulls in arrays. */
69
+ value(value: unknown, depth: number): boolean {
70
+ if (++this.nodes > this.budget.nodes) {
71
+ throw new Limit("oversize");
72
+ }
73
+ switch (typeof value) {
74
+ case "string":
75
+ this.string(value);
76
+ return true;
77
+ case "number":
78
+ this.add(Number.isFinite(value) ? String(value) : "null");
79
+ return true;
80
+ case "boolean":
81
+ this.add(String(value));
82
+ return true;
83
+ case "bigint":
84
+ throw new Limit("unsupported");
85
+ case "object":
86
+ this.object(value, depth);
87
+ return true;
88
+ case "undefined":
89
+ case "function":
90
+ case "symbol":
91
+ return false;
92
+ }
93
+ }
94
+ private object(value: object | null, depth: number): void {
95
+ if (value === null) {
96
+ this.add("null");
97
+ return;
98
+ }
99
+ if (depth >= this.budget.depth) {
100
+ throw new Limit("oversize");
101
+ }
102
+ if (this.ancestors.has(value)) {
103
+ throw new Limit("cyclic");
104
+ }
105
+ if (types.isDate(value) && !types.isProxy(value)) {
106
+ const time = Date.prototype.getTime.call(value);
107
+ this.add(Number.isNaN(time) ? "null" : JSON.stringify(new Date(time).toISOString()));
108
+ return;
109
+ }
110
+ if (opaque(value)) {
111
+ throw new Limit("unsupported");
112
+ }
113
+ this.ancestors.add(value);
114
+ if (Array.isArray(value)) {
115
+ this.array(value, depth + 1);
116
+ } else {
117
+ this.record(value, depth + 1);
118
+ }
119
+ this.ancestors.delete(value);
120
+ }
121
+ private array(value: readonly unknown[], depth: number): void {
122
+ this.add("[");
123
+ for (let index = 0; index < value.length; index++) {
124
+ if (index > 0) {
125
+ this.add(",");
126
+ }
127
+ if (!this.value(data(value, String(index)), depth)) {
128
+ this.add("null");
129
+ }
130
+ }
131
+ this.add("]");
132
+ }
133
+ private record(value: object, depth: number): void {
134
+ this.add("{");
135
+ let first = true;
136
+ for (const key of Object.keys(value)) {
137
+ const mark = { parts: this.parts.length, bytes: this.bytes };
138
+ if (!first) {
139
+ this.add(",");
140
+ }
141
+ this.string(key);
142
+ this.add(":");
143
+ if (this.value(data(value, key), depth)) {
144
+ first = false;
145
+ } else {
146
+ this.parts.length = mark.parts;
147
+ this.bytes = mark.bytes;
148
+ }
149
+ }
150
+ this.add("}");
151
+ }
152
+ }
153
+
154
+ export function boundedJson(value: unknown, budget: JsonBudget): Bounded {
155
+ const writer = new Writer(budget);
156
+ try {
157
+ return writer.value(value, 0) ? { json: writer.text() } : { unavailable: "unsupported" };
158
+ } catch (error) {
159
+ // Getters, proxies and revoked proxies are untrusted tool data, not observer failures.
160
+ return { unavailable: error instanceof Limit ? error.reason : "unreadable" };
161
+ }
162
+ }
@@ -0,0 +1,118 @@
1
+ import {
2
+ getMarkdownTheme,
3
+ keyHint,
4
+ truncateToVisualLines,
5
+ type ExtensionAPI,
6
+ type ExtensionContext,
7
+ type Theme,
8
+ type ToolDefinition,
9
+ } from "@earendil-works/pi-coding-agent";
10
+ import {
11
+ Box,
12
+ Container,
13
+ Markdown,
14
+ Spacer,
15
+ Text,
16
+ truncateToWidth,
17
+ type Component,
18
+ } from "@earendil-works/pi-tui";
19
+ import { actionText } from "./tui-action.ts";
20
+
21
+ function preview(content: Component, expanded: boolean, theme: Theme, showHint = true): Component {
22
+ if (expanded) {
23
+ return content;
24
+ }
25
+ let cache: { width: number; lines: string[] } | undefined;
26
+ return {
27
+ render(width) {
28
+ if (cache?.width !== width) {
29
+ const { visualLines, skippedCount } = truncateToVisualLines(
30
+ content.render(width).join("\n"),
31
+ 4,
32
+ width,
33
+ 0,
34
+ "start",
35
+ );
36
+ const hint = theme.fg(
37
+ "dim",
38
+ `… ${skippedCount} more lines${showHint ? ` · ${keyHint("app.tools.expand", "to expand")}` : ""}`,
39
+ );
40
+ cache = {
41
+ width,
42
+ lines: skippedCount > 0 ? [...visualLines, truncateToWidth(hint, width)] : visualLines,
43
+ };
44
+ }
45
+ return cache.lines;
46
+ },
47
+ invalidate() {
48
+ cache = undefined;
49
+ content.invalidate();
50
+ },
51
+ };
52
+ }
53
+
54
+ export const renderCompactResult: NonNullable<ToolDefinition["renderResult"]> = (
55
+ result,
56
+ { expanded },
57
+ theme,
58
+ ) =>
59
+ preview(
60
+ new Text(
61
+ theme.fg(
62
+ "toolOutput",
63
+ result.content
64
+ .filter((block) => block.type === "text")
65
+ .map((block) => block.text)
66
+ .join("\n"),
67
+ ),
68
+ 0,
69
+ 0,
70
+ ),
71
+ expanded,
72
+ theme,
73
+ );
74
+
75
+ export function registerCompactMessages(pi: ExtensionAPI, types: readonly string[]): void {
76
+ let ui: ExtensionContext["ui"] | undefined;
77
+ pi.on("session_start", (_event, ctx) => {
78
+ ui = ctx.mode === "tui" ? ctx.ui : undefined;
79
+ });
80
+ pi.on("session_shutdown", () => {
81
+ ui = undefined;
82
+ });
83
+ for (const type of types) {
84
+ pi.registerMessageRenderer(type, (message, { expanded, outputPad }, theme) => {
85
+ const text =
86
+ typeof message.content === "string"
87
+ ? message.content
88
+ : message.content
89
+ .filter((block) => block.type === "text")
90
+ .map((block) => block.text)
91
+ .join("\n");
92
+ const box = new Box(outputPad, 1, (line) => theme.bg("customMessageBg", line));
93
+ const markdown = new Markdown(text, 0, 0, getMarkdownTheme(), {
94
+ color: (line) => theme.fg("customMessageText", line),
95
+ });
96
+ box.addChild(
97
+ new (class extends Container {
98
+ override render(width: number): string[] {
99
+ this.clear();
100
+ const label = theme.fg("customMessageLabel", theme.bold(`[${type}]`));
101
+ this.addChild(
102
+ markdown.render(width).length > 4
103
+ ? actionText(
104
+ `${label} · ${keyHint("app.tools.expand", expanded ? "to collapse" : "to expand")}`,
105
+ () => ui?.setToolsExpanded(!expanded),
106
+ )
107
+ : new Text(label, 0, 0),
108
+ );
109
+ return super.render(width);
110
+ }
111
+ })(),
112
+ );
113
+ box.addChild(new Spacer(1));
114
+ box.addChild(preview(markdown, expanded, theme, false));
115
+ return box;
116
+ });
117
+ }
118
+ }
@@ -1,11 +1,5 @@
1
1
  import * as fs from "node:fs";
2
- import {
3
- JsonProjection,
4
- type Projection,
5
- type ProjectionLimits,
6
- type StringChunk,
7
- type KeyLimit,
8
- } from "./json-projection.ts";
2
+ import { JsonProjection, type Projection, type StringChunk } from "./json-projection.ts";
9
3
  import { errorDescription, isRecord, type UnknownRecord } from "./unknown.ts";
10
4
 
11
5
  export interface JournalRecord {
@@ -19,10 +13,7 @@ export interface JournalOptions {
19
13
  readonly requireNewline?: boolean;
20
14
  readonly start?: number;
21
15
  readonly end?: number;
22
- readonly malformed?: (start: number, end: number, error: unknown) => void;
23
16
  readonly stringChunk?: StringChunk;
24
- readonly keys?: KeyLimit;
25
- readonly limits?: ProjectionLimits;
26
17
  }
27
18
 
28
19
  /** Byte-framed JSONL. Publication framing is independent of malformed-record tolerance. */
@@ -54,12 +45,7 @@ export class JournalFrames {
54
45
  this.projection = this.newProjection();
55
46
  }
56
47
  private newProjection(): JsonProjection {
57
- return new JsonProjection(
58
- this.select,
59
- this.options.stringChunk,
60
- this.options.keys,
61
- this.options.limits,
62
- );
48
+ return new JsonProjection(this.select, this.options.stringChunk);
63
49
  }
64
50
  private part(bytes: Buffer): void {
65
51
  if (!this.nonblank && bytes.some((byte) => ![9, 10, 13, 32].includes(byte))) {
@@ -107,9 +93,7 @@ export class JournalFrames {
107
93
  this.error = undefined;
108
94
  this.failed = false;
109
95
  this.nonblank = false;
110
- if (failed) {
111
- this.options.malformed?.(start, end, error);
112
- } else if (record) {
96
+ if (!failed && record) {
113
97
  this.record(record);
114
98
  }
115
99
  }