@astrosheep/square 0.3.102 → 0.3.104

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.
@@ -8,7 +8,9 @@ export declare function renderPendingAtBoundary(inbox: PendingMembership[]): str
8
8
  * `lookup` returns the session's memberships for this boundary. It may reuse an observation it has
9
9
  * verified against the current presence binding and artifact revision; the boundary still projects
10
10
  * presentation evidence live. Every item is claimed and revalidated before the batch sink;
11
- * after success, captured-seat commits record only its included items without an eligibility veto.
11
+ * after success, captured-seat commits record the winning batch, including budget omissions as
12
+ * clipped (no consumption receipt), without an eligibility veto. Coverage is conservative if a
13
+ * competing claim's removal lets a previously omitted body fit in the final preview.
12
14
  * A source that cannot verify its reuse must read the inbox again.
13
15
  */
14
- export declare function presentPendingAtBoundary<T>(identity: Owner, present: (context: string) => T | Promise<T>, lookup?: (identity: Owner, env?: NodeJS.ProcessEnv, signal?: AbortSignal) => Promise<PendingMembership[]> | PendingMembership[], env?: NodeJS.ProcessEnv, signal?: AbortSignal): Promise<T | undefined>;
16
+ export declare function presentPendingAtBoundary<T>(identity: Owner, present: (context: string, delivered: PendingMembership[]) => T | Promise<T>, lookup?: (identity: Owner, env?: NodeJS.ProcessEnv, signal?: AbortSignal) => Promise<PendingMembership[]> | PendingMembership[], env?: NodeJS.ProcessEnv, signal?: AbortSignal): Promise<T | undefined>;
@@ -17,11 +17,14 @@ function renderBoundary(inbox) {
17
17
  const agoraBlocks = [];
18
18
  const deliveries = [];
19
19
  let remainingAgora = queued.filter(({ notification }) => notification.delivery?.mode !== 'direct').length;
20
- const complete = [];
20
+ const entries = [];
21
21
  let omitted = 0;
22
22
  const omission = (count) => `${count} more ${count === 1 ? 'activity' : 'activities'} not shown.`;
23
23
  for (const entry of queued) {
24
24
  const { membership, notification } = entry;
25
+ // The omission line represents the rest of this batch too, but never consumes its bodies.
26
+ const coverage = { membership, actIndex: notification.actIndex, markSeen: false };
27
+ entries.push(coverage);
25
28
  const block = renderAttentionPreview({
26
29
  squarePath: membership.location,
27
30
  actIndex: notification.actIndex,
@@ -43,15 +46,11 @@ function renderBoundary(inbox) {
43
46
  }
44
47
  blocks.push(block);
45
48
  deliveries.push(notification.delivery);
46
- complete.push({
47
- membership,
48
- actIndex: notification.actIndex,
49
- markSeen: !attentionBodyIsClipped(notification.body, notification.delivery),
50
- });
49
+ coverage.markSeen = !attentionBodyIsClipped(notification.body, notification.delivery);
51
50
  }
52
51
  return {
53
52
  context: renderSquareReminder([...blocks, ...(omitted > 0 ? [omission(omitted)] : [])], deliveries),
54
- complete,
53
+ entries,
55
54
  };
56
55
  }
57
56
  async function presentPendingAtBoundaryUnlocked(identity, present, lookup = sessionInbox, env = process.env, signal) {
@@ -66,7 +65,7 @@ async function presentPendingAtBoundaryUnlocked(identity, present, lookup = sess
66
65
  try {
67
66
  try {
68
67
  // Enumeration owns discovery/connections only; returned handles belong to the common runner.
69
- for (const entry of renderBoundary(pending).complete) {
68
+ for (const entry of renderBoundary(pending).entries) {
70
69
  const { location, seat } = entry.membership;
71
70
  let artifact = artifacts.get(location);
72
71
  if (artifact === undefined) {
@@ -87,12 +86,11 @@ async function presentPendingAtBoundaryUnlocked(identity, present, lookup = sess
87
86
  }
88
87
  const report = await presentPreparedBatch(claims.map(({ claim }) => claim), eligibleClaims => {
89
88
  const eligible = new Set(claims.filter(({ claim }) => eligibleClaims.includes(claim)).map(({ key }) => key));
90
- const fresh = renderBoundary(pending.map(membership => ({ ...membership,
89
+ const delivered = pending.map(membership => ({ ...membership,
91
90
  notifications: membership.notifications.filter(note => eligible.has(pendingKey(membership, note.actIndex))),
92
- })));
93
- const included = new Set(fresh.complete.map(entry => pendingKey(entry.membership, entry.actIndex)));
94
- return { payload: fresh.context, included: claims.filter(({ key }) => included.has(key)).map(({ claim }) => claim) };
95
- }, present, { signal, classifyError: () => 'failed' });
91
+ })).filter(membership => membership.notifications.length > 0);
92
+ return { payload: { context: renderBoundary(delivered).context, delivered }, included: eligibleClaims };
93
+ }, payload => present(payload.context, payload.delivered), { signal });
96
94
  if (report.observation.kind === 'threw')
97
95
  throw report.observation.error;
98
96
  for (const { result } of report.settlements)
@@ -110,7 +108,9 @@ async function presentPendingAtBoundaryUnlocked(identity, present, lookup = sess
110
108
  * `lookup` returns the session's memberships for this boundary. It may reuse an observation it has
111
109
  * verified against the current presence binding and artifact revision; the boundary still projects
112
110
  * presentation evidence live. Every item is claimed and revalidated before the batch sink;
113
- * after success, captured-seat commits record only its included items without an eligibility veto.
111
+ * after success, captured-seat commits record the winning batch, including budget omissions as
112
+ * clipped (no consumption receipt), without an eligibility veto. Coverage is conservative if a
113
+ * competing claim's removal lets a previously omitted body fit in the final preview.
114
114
  * A source that cannot verify its reuse must read the inbox again.
115
115
  */
116
116
  export async function presentPendingAtBoundary(identity, present, lookup = sessionInbox, env = process.env, signal) {
@@ -144,7 +144,7 @@ export async function observeClaudeDelivery(sessionId, text, operation, env = pr
144
144
  if (operation === 'stored')
145
145
  await presentPending({ artifact: row.artifact, location: request.location,
146
146
  // Claude's stored event consumes even Agora previews; retain that contract for both modes.
147
- participant: request.participant, activity: request.activity, seat: request.seat, markSeen: true, sink: { present() { } },
147
+ participant: request.participant, activity: request.activity, seat: request.seat, observationOf: request.claimToken, markSeen: true, sink: { present() { } },
148
148
  current: facts => currentPayload(facts, request) === payload });
149
149
  }
150
150
  return { recognized, current: true };
@@ -64,7 +64,7 @@ export function presentationBlocksDispatch(attempts, now) {
64
64
  finite(now);
65
65
  return attempts.some(row => row.purpose === 'presentation'
66
66
  && (row.outcome === 'presented' || row.outcome === 'clipped' || row.outcome === 'unknown'
67
- || row.outcome === 'dispatching' && (row.expiresAt === undefined || row.expiresAt > now)));
67
+ || row.outcome === 'dispatching'));
68
68
  }
69
69
  /** An expired dispatch is uncertain, not permission for automatic replay. */
70
70
  export function automaticDispatchEligible(attempts, now) {
@@ -29,7 +29,7 @@ export async function classifyDeliveryHealth(squarePath, opts) {
29
29
  for (const { note, seat, evidence } of current) {
30
30
  const ageMs = Math.max(0, now - note.at);
31
31
  const presentation = evidence.some((row) => row.kind === 'presentation' && (row.outcome === 'presented' || row.outcome === 'clipped' || row.outcome === 'unknown'
32
- || row.outcome === 'dispatching' && (row.expiresAt === undefined || row.expiresAt > now)));
32
+ || row.outcome === 'dispatching'));
33
33
  const terminal = evidence.findLast((row) => row.kind === 'wake' && (row.outcome === 'accepted' || row.outcome === 'unknown'));
34
34
  const attemptRow = evidence.findLast((row) => row.kind === 'wake' && (row.outcome === 'accepted' || row.outcome === 'unknown' || row.outcome === 'failed'));
35
35
  const attempt = attemptRow === undefined || seat === undefined ? undefined : deliveryAttempt(squarePath, attemptRow, { squarePath, recipient: seat.participant, actIndex: note.index });
package/dist/inbox.js CHANGED
@@ -27,7 +27,7 @@ function eligibleNotifications(facts, membership, now = Date.now()) {
27
27
  const notifications = membership.notifications.filter(notification => {
28
28
  if (cancelledThrough !== undefined && notification.actIndex <= cancelledThrough
29
29
  || facts.deliveryEvidence(seat.participant, seat.generation, formatActivityId(notification.actIndex))
30
- .some(row => row.kind === 'presentation' && (row.outcome === 'presented' || row.outcome === 'clipped')))
30
+ .some(row => row.outcome !== 'failed' && row.outcome !== 'released'))
31
31
  return false;
32
32
  const deferredUntil = deliveryDeferralExpiry(facts, seat, notification.actIndex, now);
33
33
  if (deferredUntil === undefined)
@@ -115,7 +115,7 @@ export async function observeOpenCodeContext(sessionId, messages, env, signal) {
115
115
  if (signal.aborted || evidence.claimToken === undefined)
116
116
  return;
117
117
  await presentPending({ artifact, location, participant: seat.participant,
118
- activity: formatActivityId(index), seat: asSeatRef(seat), sink: { present() { } }, markSeen: !clipped, signal,
118
+ activity: formatActivityId(index), seat: asSeatRef(seat), observationOf: evidence.claimToken, sink: { present() { } }, markSeen: !clipped, signal,
119
119
  current: (facts) => {
120
120
  const current = facts.seat(seat.participant);
121
121
  const prepared = current === undefined || !sameSeat(current, seat) ? undefined : preview(facts, location, seat, index);
package/dist/ports.d.ts CHANGED
@@ -133,6 +133,8 @@ export interface DeliveryClaimInput {
133
133
  readonly seat: SeatRef;
134
134
  readonly activity: ActivityId;
135
135
  readonly kind: 'wake' | 'presentation';
136
+ /** Observe an existing wake's exact token; never authorizes another content injection. */
137
+ readonly observationOf?: string;
136
138
  readonly route?: WakeAddress;
137
139
  readonly at: number;
138
140
  readonly leaseMs: number;
@@ -322,6 +324,8 @@ export interface PresentPendingInput {
322
324
  readonly participant: string;
323
325
  readonly activity: string | number;
324
326
  readonly sink: PresentationSinkPort;
327
+ /** Existing wake observed by a native receiver; the sink must not inject content. */
328
+ readonly observationOf?: string;
325
329
  /** Captured artifact seat; used to fence both presentation and consumption. */
326
330
  readonly seat?: SeatRef;
327
331
  readonly generation?: ActivityId;
@@ -14,5 +14,5 @@ export declare function preparePendingPresentation(input: Omit<PresentPendingInp
14
14
  index: number;
15
15
  at: number;
16
16
  }> | undefined>;
17
- /** Square sinks explicitly classify rejection as failed; the public effect core defaults to unknown. */
17
+ /** A sink error cannot prove that content was never injected. */
18
18
  export declare function presentPending(input: PresentPendingInput): Promise<PresentationResult>;
@@ -36,7 +36,7 @@ export async function preparePendingPresentation(input) {
36
36
  port: {
37
37
  async claim(signal) {
38
38
  const claimed = await input.artifact.claimDelivery({ seat: ref, activity: formatActivityId(index), kind: 'presentation',
39
- at: input.now ?? Date.now(), leaseMs: input.timeoutMs ?? 5000, claimToken }, signal).catch(() => ({ status: 'stale' }));
39
+ observationOf: input.observationOf, at: input.now ?? Date.now(), leaseMs: input.timeoutMs ?? 5000, claimToken }, signal).catch(() => ({ status: 'stale' }));
40
40
  return claimed.status === 'acquired' ? { token: claimToken, ordinal: claimed.attemptN } : undefined;
41
41
  },
42
42
  async current(signal) {
@@ -68,13 +68,13 @@ export async function preparePendingPresentation(input) {
68
68
  },
69
69
  }, { signal: input.signal });
70
70
  }
71
- /** Square sinks explicitly classify rejection as failed; the public effect core defaults to unknown. */
71
+ /** A sink error cannot prove that content was never injected. */
72
72
  export async function presentPending(input) {
73
73
  const prepared = await preparePendingPresentation(input);
74
74
  if (prepared === undefined)
75
75
  return { presented: false };
76
76
  const report = await presentPrepared(prepared, activity => input.sink.present(activity), {
77
- signal: input.signal, classifyError: () => 'failed',
77
+ signal: input.signal,
78
78
  });
79
79
  if (report.observation.kind === 'threw')
80
80
  throw report.observation.error;
@@ -248,6 +248,25 @@ export function wrapSquareWrite(read, writes, authority) {
248
248
  },
249
249
  claimDelivery(input) {
250
250
  assertSeat(input.seat);
251
+ const evidence = read.deliveryEvidence(input.seat.participant, input.seat.generation, input.activity);
252
+ if (input.observationOf !== undefined) {
253
+ // Receiver adapters validate native identity/content before entering this transaction.
254
+ // This path records that existing message, rather than acquiring a second send.
255
+ if (input.kind !== 'presentation' || !evidence.some(row => row.kind === 'wake'
256
+ && row.claimToken === input.observationOf && ['dispatching', 'accepted', 'unknown'].includes(row.outcome)))
257
+ return { status: 'stale' };
258
+ }
259
+ else {
260
+ const blocker = evidence.find(row => row.kind !== input.kind
261
+ && row.outcome !== 'failed' && row.outcome !== 'released');
262
+ if (blocker !== undefined) {
263
+ if (blocker.outcome === 'dispatching' && blocker.expiresAt !== undefined && blocker.expiresAt <= input.at) {
264
+ // Normalize abandoned custody to unknown in the same transaction, never steal it.
265
+ return mutations.claimDelivery({ ...input, kind: blocker.kind });
266
+ }
267
+ return { status: blocker.outcome === 'dispatching' ? 'busy' : 'terminal', evidence: blocker };
268
+ }
269
+ }
251
270
  return mutations.claimDelivery(input);
252
271
  },
253
272
  prepareDelivery(input) {
@@ -52,6 +52,33 @@ currentness policy and the meaning of external proof remain the application's re
52
52
  An `accepted` dispatch is not proof of presentation or consumption. Only the responsible presentation
53
53
  boundary may assert `presented`; consuming an item is a separate application operation.
54
54
 
55
+ ## Square content custody
56
+
57
+ Dispatch and presentation remain separate evidence series, but they are not independent permission
58
+ to inject content. Square's transaction-scoped domain gate checks both series for the same artifact,
59
+ recipient, seat generation and activity before admitting a new injection. A claim in either series
60
+ blocks the other; accepted, presented, clipped and unknown evidence also block it. Only failed or
61
+ released evidence permits another injection. An expired opposing claim is normalized to unknown in
62
+ that transaction rather than stolen. No lock is held over host or network I/O.
63
+
64
+ A native receiver that has verified the exact existing wake may pass its claim token as
65
+ `observationOf` to the presentation adapter. The gate verifies the wake belongs to the same captured
66
+ recipient/generation/activity and remains dispatching, accepted or unknown. This records observation
67
+ of that message; its sink must not inject another message. Claude and OpenCode retain their exact
68
+ native correlation/content checks. A successful wake alone never creates a consumption receipt.
69
+ Paseo currently has no corresponding native Pi observation correlation: its accepted/unknown attempt
70
+ stays unconsumed, available to explicit catch, without automatic native reinjection.
71
+
72
+ Session pending projections used for automatic injection suppress claimed, accepted and uncertain
73
+ items as well as presented ones. Failed/released attempts make them eligible again through delivery
74
+ revision observation. Native payload construction and exact-token observation read their captured
75
+ activity directly, not this injection queue. Cancellation and seat replacement retain their existing
76
+ fences. Uncertainty deliberately favors no automatic duplicate over automatic recovery; no
77
+ exactly-once processing or guaranteed eventual delivery is claimed.
78
+
79
+ These checks use the existing artifact evidence and transactions; no new registry or artifact format
80
+ is introduced. All running injectors must load the updated code before the guarantee applies.
81
+
55
82
  ## Transitions
56
83
 
57
84
  An `Attempt<D>` has `purpose: 'dispatch' | 'presentation'`, `token`, zero-based `ordinal`, `outcome`,
@@ -83,8 +110,8 @@ The caller supplies valid stored attempts and consistently scoped, ordered rows.
83
110
  `purpose`, `outcome`, and optional `expiresAt`; no dummy payload or identity is needed. These predicates
84
111
  may see both purposes for the same logical delivery.
85
112
 
86
- Presentation `presented`, `clipped`, `unknown`, or live `dispatching` blocks dispatch. Expired
87
- presentation dispatching alone does not. Automatic dispatch additionally requires no dispatch
113
+ Presentation `presented`, `clipped`, `unknown`, or `dispatching` blocks dispatch, including an
114
+ expired presentation claim: loss of its lease is uncertainty, not permission to send again. Automatic dispatch additionally requires no dispatch
88
115
  `accepted`, `unknown`, or `dispatching`; even expired dispatch custody blocks automatic replay.
89
116
  These are preselection predicates, not substitutes for a transactional claim or application gates.
90
117
 
@@ -173,8 +200,8 @@ supply isolation for a caller, so this is a mandatory port contract, tested in S
173
200
 
174
201
  Presentation reports distinguish observed sink behavior from durable persistence. A returned sink
175
202
  with `not-recorded` or `error` still returned successfully; that alone proves only the supplied sink's
176
- documented effect, not model consumption or human display. Square explicitly classifies its existing
177
- rejected sinks as failed and rethrows the original exception after draining. Its single result stays
203
+ documented effect, not model consumption or human display. Square retains throwing sinks as unknown and rethrows the original exception after draining.
204
+ A missing native landing event or a canceled wait cannot prove the message never entered the host. Its single result stays
178
205
  `{ presented: true, activity }` only on recorded success, false on ownership refusal; unexpected
179
206
  success-settlement errors propagate. Batch returns its original sink value after all settlements,
180
207
  with the same error propagation. No schema, storage format or public Square result changes are needed.
@@ -4,7 +4,16 @@ The shipped `plugins/pi/square-pi.js` remains the **only Square Pi extension**.
4
4
  Both its activity steer and advisory `nextTurn` send call the SDK-free shared
5
5
  `sendPiMessage` leaf compiled from `packages/agent-delivery/src`. Square keeps
6
6
  framing, custom type, display, preview/UI, observation/presentation evidence,
7
- retry/drop and canceled-batch suppression; the leaf neither schedules nor tracks.
7
+ uncertain-landing and canceled-batch suppression; the leaf neither schedules nor tracks.
8
+
9
+ Pi injection and external wake injection acquire mutually exclusive artifact-owned content custody.
10
+ An accepted or uncertain Paseo wake suppresses native Pi reinjection without asserting presentation
11
+ or consumption. Only proven failed/released attempts can return to the automatic queue. Missing
12
+ landing events, thrown sinks and expired claims do not authorize replay. Renderer details describe
13
+ only the batch that won custody, not the earlier observed inbox. Budget-omitted activities belong to
14
+ that same delivered batch: the omission line settles their presentation as `clipped`, without a
15
+ full-consumption receipt. They stay available to explicit catch, but never get paged into later
16
+ model completions or replayed on extension reload. See `delivery-primitives.md`.
8
17
 
9
18
  For independent existing-session delivery, opt into its local receiver:
10
19
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrosheep/square",
3
- "version": "0.3.102",
3
+ "version": "0.3.104",
4
4
  "description": "A shared public square where agents join, catch activity, express, and step out when done.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "square",
3
- "version": "0.3.102",
3
+ "version": "0.3.104",
4
4
  "description": "Native Claude Code inbox delivery for Square participants",
5
5
  "types": "./types.d.ts",
6
6
  "author": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "square",
3
- "version": "0.3.102",
3
+ "version": "0.3.104",
4
4
  "description": "Shared Square activity with reliable participant attention at Codex boundaries.",
5
5
  "author": {
6
6
  "name": "Square"
@@ -114,7 +114,7 @@ export async function observeOpenCodeContext(sessionId: string, messages: readon
114
114
  for (const { evidence, index, hash, clipped } of matches) {
115
115
  if (signal.aborted || evidence.claimToken === undefined) return;
116
116
  await presentPending({ artifact, location, participant: seat.participant,
117
- activity: formatActivityId(index), seat: asSeatRef(seat), sink: { present() {} }, markSeen: !clipped, signal,
117
+ activity: formatActivityId(index), seat: asSeatRef(seat), observationOf: evidence.claimToken, sink: { present() {} }, markSeen: !clipped, signal,
118
118
  current: (facts) => {
119
119
  const current = facts.seat(seat.participant);
120
120
  const prepared = current === undefined || !sameSeat(current, seat) ? undefined : preview(facts, location, seat, index);
@@ -15,9 +15,9 @@ directory. Square tags up to v0.3.69 do not contain it.
15
15
  Paseo supplies all runtime modules. No Git preparation step is needed. Plugins
16
16
  are trusted, unsandboxed code running inside the Paseo app.
17
17
 
18
- Requires Paseo >=0.11.2 with custom-message tool rows (#6090). Square 0.3.69+
19
- provides `details.items` for the collapsed heading. Messages without those
20
- details still expand to their original content.
18
+ Requires Paseo >=0.11.2 with custom-message tool rows (#6090). Structured notification
19
+ cards use Square's `details.items`, including each item's `actor`, `act`, `body` and
20
+ `route`. Missing or malformed details fall back to the original plain text.
21
21
 
22
22
  ## Display
23
23
 
@@ -25,13 +25,21 @@ Only synthetic `tool_call` items with `metadata.customType === "square"` and
25
25
  plain-text detail are projected into a card. Real tools, other custom messages,
26
26
  and unrecognized rows remain unchanged.
27
27
 
28
- The collapsed heading uses `metadata.details.items`; it never reverse-parses
29
- the content. Expansion shows `metadata.details.fullText` when available: complete
30
- notification bodies, even when the model preview was clipped or omitted. Older
31
- messages fall back to `detail.text`, including clipping notices and catch commands.
32
- Each displayed body is capped at 32,000 UTF-16 units (omission line included), keeping
33
- the beginning and end; collapsed previews are capped at 1,000 units before layout.
34
- Surrogate pairs are not split. Original messages and model input remain unchanged.
28
+ The heading and expanded messages use `metadata.details.items` directly, never
29
+ reverse-parsing notification envelopes or body text for senders, IDs or message
30
+ boundaries. Each expanded item keeps its full source body, author, exact activity ID
31
+ and bell marker, even when the model preview was clipped or omitted. IDs with gaps
32
+ are not compressed into a misleading range; items from different squares remain
33
+ separate. Collapsing shows only the last item's preview. Body text containing square
34
+ tags is ordinary content, not another notification.
35
+
36
+ Expanded bodies and the last collapsed preview share one 32,000 UTF-16-unit display
37
+ budget, including omission lines. The preview gets at most 1,000 units; the remaining
38
+ budget is shared among bodies. Long bodies retain their beginning and end, and
39
+ surrogate pairs are not split. Structured bodies are not also copied into `data.text`.
40
+ Joining context or malformed/missing details instead show bounded `detail.text`
41
+ under a generic heading, without inferring attribution. Original history and model
42
+ input remain unchanged.
35
43
  Long content scrolls inside a bounded panel. The
36
44
  card does not fetch extra activity or mark anything seen. Live events and fetched history use
37
45
  the same transformer.
@@ -63,8 +63,9 @@ function renderBlocks(tokens: Token[], styles: Styles, prefix: string, textStyle
63
63
  case "space":
64
64
  case "checkbox":
65
65
  case "def":
66
- case "html":
67
66
  return null;
67
+ case "html":
68
+ return <Text key={key} selectable style={[styles.text, textStyle]}>{token.raw}</Text>;
68
69
  case "hr":
69
70
  return <View key={key} style={styles.separator} />;
70
71
  case "code":
@@ -127,9 +128,9 @@ function renderBlocks(tokens: Token[], styles: Styles, prefix: string, textStyle
127
128
  });
128
129
  }
129
130
 
130
- /** The speaker run into the start of a line: the seal's blue, then the words. */
131
+ /** A chorus speaker in seal blue, with a fixed half-em breath before the words. */
131
132
  function Lead({ name, styles }: { name: string; styles: Styles }) {
132
- return <Text style={styles.lead}>{name}{" "}</Text>;
133
+ return <Text style={styles.lead}>{name}{"\u2002"}</Text>;
133
134
  }
134
135
 
135
136
  /** One line (or a few) of Markdown, inline only: the collapsed preview on a notification card. */
@@ -1,50 +1,40 @@
1
1
  import type { PluginTheme } from "@getpaseo/plugin";
2
- import { MARBLE_DARK, MARBLE_LIGHT } from "./marble";
3
2
 
4
3
  /**
5
- * Square's own courtyard palette: veinless honed stone (muted grey light, charcoal dark) and a
6
- * slate seal ■ — everything half a step down, the quiet card on the timeline, deliberately no relative of notesoup's warm parchment or
7
- * pi-meantime's neutral carbon. Every token here is opaque and self-contained — the card and
8
- * Markdown never borrow the host's surface ramp or accent, only its appearance
9
- * (read from the host ink) and its accentForeground, which paints text on top
10
- * of an accent fill.
4
+ * Square's message card is a square-edged matte paper slip next to the timeline's
5
+ * frosted glass instruments: near-square corners, a square seal ■, a thin quote
6
+ * guide under the header. No glass sheen, no stone texture, no bottom cut —
7
+ * the card earns its identity from geometry, not decoration.
11
8
  */
12
9
  const DARK = {
13
- surface0: "#15171a", // inset / code
14
- surface1: "#1a1d20", // card (charcoal stone)
15
- surface2: "#15171a", // inset / code
16
- border: "#2b2f34",
17
- foreground: "#dde1e6",
10
+ surface0: "#14161a", // inset / code
11
+ surface1: "#252a2e", // card (matte slate paper)
12
+ surface2: "#14161a", // inset / code
13
+ border: "#3a4249",
14
+ foreground: "#d9dce1",
18
15
  foregroundMuted: "#858b94",
19
- accent: "#8298ad", // plaza ■ / links
16
+ accent: "#94a6b5", // plaza ■ / links
20
17
  } as const;
21
18
 
22
19
  const LIGHT = {
23
- surface0: "#e9ebee",
24
- surface1: "#f4f5f6", // card (grey stone)
25
- surface2: "#e9ebee",
26
- border: "#e0e2e5",
20
+ surface0: "#f4f5f7",
21
+ surface1: "#edf0f2", // card (matte slate paper)
22
+ surface2: "#f4f5f7",
23
+ border: "#ced5db",
27
24
  foreground: "#2b2e33",
28
- foregroundMuted: "#90959c",
29
- accent: "#56687e",
30
- } as const;
31
-
32
- const TILE = 512;
33
-
34
- /** Web-only surface: react-native-web passes these through as inline CSS. */
35
- const STONE = {
36
- // A slab resting on the page: one darker cut along the bottom edge, a tight contact shadow, no sheen.
37
- dark: { image: `url(${MARBLE_DARK})`, edge: "#34393f", boxShadow: "0 1px 0 rgba(0,0,0,0.3), 0 4px 10px -6px rgba(0,0,0,0.55)", inlay: "inset 0 0 0 0.5px rgba(0,0,0,0.4)" },
38
- light: { image: `url(${MARBLE_LIGHT})`, edge: "#c9ccd1", boxShadow: "0 1px 0 rgba(24,26,30,0.04), 0 4px 10px -6px rgba(24,26,30,0.18)", inlay: "inset 0 0 0 0.5px rgba(0,0,0,0.26)" },
25
+ foregroundMuted: "#858b94",
26
+ accent: "#71869a",
39
27
  } as const;
40
28
 
41
29
  export interface SquarePalette {
42
30
  /** Complete theme for the card and its Markdown surfaces. */
43
31
  theme: PluginTheme;
44
- /** Marble card surface for the web host, cut from the tile at `seed`; native keeps the flat surface1. */
45
- stone(seed: string): { backgroundImage: string; backgroundSize: string; backgroundPosition: string; borderBottomColor: string; boxShadow: string };
46
- /** Web-only: the ■ set into the stone. */
47
- inlay: { boxShadow: string };
32
+ /** Flat slip: a hairline ring and no shadow, so the frosted rail beside it can carry the depth. */
33
+ card: { borderRadius: number; borderWidth: number; boxShadow: string };
34
+ /** The square ■ seal, unrounded and flat. */
35
+ seal: { borderRadius: number };
36
+ /** The thin quote guide under the header. */
37
+ quote: { borderLeftWidth: number; borderLeftColor: string; paddingLeft: number };
48
38
  }
49
39
 
50
40
  /** Paseo theme colors are hex (#RGB or #RRGGBB). */
@@ -67,19 +57,8 @@ export function squarePalette(theme: PluginTheme): SquarePalette {
67
57
  statusWarning: theme.colors.statusWarning,
68
58
  statusDanger: theme.colors.statusDanger,
69
59
  } },
70
- stone: seed => {
71
- // Each card shows its own patch of the slab, so neighbours never repeat a vein.
72
- let hash = 2166136261;
73
- for (const char of seed) hash = Math.imul(hash ^ char.charCodeAt(0), 16777619);
74
- const { image, edge, boxShadow } = dark ? STONE.dark : STONE.light;
75
- return {
76
- backgroundImage: image,
77
- backgroundSize: `${TILE}px ${TILE}px`,
78
- backgroundPosition: `${-((hash >>> 0) % TILE)}px ${-((hash >>> 9) % TILE)}px`,
79
- borderBottomColor: edge,
80
- boxShadow,
81
- };
82
- },
83
- inlay: { boxShadow: (dark ? STONE.dark : STONE.light).inlay },
60
+ card: { borderRadius: 2, borderWidth: 0.5, boxShadow: "none" },
61
+ seal: { borderRadius: 0 },
62
+ quote: { borderLeftWidth: 1, borderLeftColor: dark ? "#45535e" : "#c2cdd5", paddingLeft: 9 },
84
63
  };
85
64
  }
@@ -1,38 +1,37 @@
1
1
  import type { PluginTimelineItemProps } from "@getpaseo/plugin/client";
2
2
  import { Icon } from "@getpaseo/plugin/client/react-native";
3
3
  import { useMemo, useState } from "react";
4
- import { Pressable, ScrollView, StyleSheet, Text, View, type ViewStyle } from "react-native";
5
- import { parseSquareBlocks, parseSquareVoice, type SquareMessageData } from "../shared/message";
4
+ import { Pressable, ScrollView, Text, View } from "react-native";
5
+ import type { SquareMessageData } from "../shared/message";
6
6
  import { MONO, SquareCue, SquareInline, SquareMarkdownBody } from "./markdown";
7
7
  import { squarePalette } from "./palette";
8
8
 
9
9
  /**
10
- * The accent ■ marks the square itself, not a route.
10
+ * A square-edged matte slip next to the frosted glass instruments: near-square
11
+ * corners, square seal ■, thin quote guide under the header.
11
12
  * Header (both states): ■ square · who spoke … act ids · how many.
12
13
  * Collapsed: two preview lines. Expanded: the whole scene, at full width.
13
- * One attribution style for one voice or many — each speaker's name runs into
14
- * their first line in the seal's color (not repeated while they keep talking);
15
- * a bell or an *action* is stage direction, a caption on a hairline.
14
+ * Each structured message keeps its own sender, activity id and body.
15
+ * A bell or an *action* is stage direction, a caption on a hairline.
16
16
  */
17
17
  export function SquareMessage({ item, theme, layout }: PluginTimelineItemProps<SquareMessageData>) {
18
18
  const [expanded, setExpanded] = useState(false);
19
19
  const [hovered, setHovered] = useState(false);
20
20
  const palette = useMemo(() => squarePalette(theme), [theme]);
21
- const { text, items } = item.data;
22
- const latest = items?.[items.length - 1];
23
- const preview = latest?.preview?.replace(/\s+/g, " ").trim();
24
- const blocks = useMemo(() => parseSquareBlocks(text), [text]);
25
- const voices = useMemo(() => blocks.map(block => parseSquareVoice(block.actor, block.meta)), [blocks]);
26
- const names = items !== undefined
27
- ? items.map(entry => entry.actor)
28
- : voices.map(voice => voice.speaker);
29
- const speakers = [...new Set(names.filter((name): name is string => name !== undefined))];
30
- const squareName = latest?.square ?? blocks[0].meta?.split("·")[1]?.trim() ?? "Square";
31
- const bell = latest?.route === "bell" || (voices.length === 1 && voices[0].bell);
32
- // Header is the same in both states; act id is always shown when we know it.
33
- const actIds = [...new Set(voices.flatMap(voice => voice.actId ? [voice.actId] : []))];
34
- const trailing = [actSpan(actIds), items && items.length > 1 ? String(items.length) : undefined]
35
- .filter(Boolean).join(" · ");
21
+ const items = "items" in item.data ? item.data.items : undefined;
22
+ const text = "text" in item.data ? item.data.text : undefined;
23
+ const latest = items?.at(-1);
24
+ // Keep the source's own line breaks: collapsed and expanded text must wrap alike.
25
+ const preview = latest?.preview ?? text;
26
+ const speakers = [...new Set(items?.map(entry => entry.actor))];
27
+ const chorus = speakers.length > 1;
28
+ const squares = [...new Set(items?.map(entry => entry.square))];
29
+ const squareName = squares.join(", ") || "Square";
30
+ const bell = latest?.route === "bell";
31
+ // Exact IDs never imply intervening activities, and different squares keep their own IDs.
32
+ const actIds = items?.map(entry => entry.act) ?? [];
33
+ const trailing = [items?.map(entry => squares.length > 1 ? `${entry.square} ${entry.act}` : entry.act).join(", "),
34
+ items && items.length > 1 ? String(items.length) : undefined].filter(Boolean).join(" · ");
36
35
  const { accent, border, foreground, foregroundMuted: muted, surface1 } = palette.theme.colors;
37
36
  // On web the header brightens its details under the cursor; touch keeps them quiet.
38
37
  const detail = layout.platform === "web" && hovered ? foreground : muted;
@@ -41,8 +40,8 @@ export function SquareMessage({ item, theme, layout }: PluginTimelineItemProps<S
41
40
 
42
41
  return (
43
42
  <View style={[
44
- { backgroundColor: surface1, borderWidth: StyleSheet.hairlineWidth, borderColor: border, borderRadius: 4, paddingHorizontal: 12, paddingVertical: 9 },
45
- layout.platform === "web" && palette.stone(`${squareName} ${actIds[0] ?? ""}`) as ViewStyle,
43
+ { backgroundColor: surface1, borderWidth: palette.card.borderWidth, borderColor: border, borderRadius: palette.card.borderRadius, paddingHorizontal: 10, paddingVertical: 7 },
44
+ layout.platform === "web" && { boxShadow: palette.card.boxShadow },
46
45
  ]}>
47
46
  <Pressable
48
47
  accessibilityRole="button"
@@ -54,51 +53,36 @@ export function SquareMessage({ item, theme, layout }: PluginTimelineItemProps<S
54
53
  style={({ pressed }) => ({ opacity: pressed ? 0.85 : 1 })}
55
54
  >
56
55
  <View style={row}>
57
- <View style={[{ width: 9, height: 9, borderRadius: 1.5, backgroundColor: accent }, layout.platform === "web" && palette.inlay as ViewStyle]} />
58
- <Text numberOfLines={1} style={{ color: foreground, fontSize: 13, fontWeight: "600", flex: 1 }}>
56
+ <View style={{ width: 6, height: 6, borderRadius: palette.seal.borderRadius, backgroundColor: accent }} />
57
+ <Text numberOfLines={1} style={{ color: foreground, fontSize: 11.5, lineHeight: 17, fontWeight: "600", flex: 1 }}>
59
58
  {squareName}
60
59
  {(speakers.length > 0 || bell) && (
61
- <Text style={{ color: muted, fontWeight: "400" }}> · {[speakers.join(", "), bell ? "🔔" : ""].filter(Boolean).join(" · ")}</Text>
60
+ <Text style={{ color: muted, fontWeight: "400", fontSize: 10.5 }}> · {[speakers.join(", "), bell ? "🔔" : ""].filter(Boolean).join(" · ")}</Text>
62
61
  )}
63
62
  </Text>
64
- {trailing !== "" && <Text style={{ color: detail, fontSize: 11, fontFamily: MONO, fontVariant: ["tabular-nums"] }}>{trailing}</Text>}
65
- <Icon name={expanded ? "ChevronUp" : "ChevronDown"} size={15} color={detail} />
63
+ {trailing !== "" && <Text numberOfLines={1} style={{ color: detail, fontSize: 9.5, fontFamily: MONO, fontVariant: ["tabular-nums"], flexShrink: 1 }}>{trailing}</Text>}
64
+ <Icon name={expanded ? "ChevronUp" : "ChevronDown"} size={10} color={detail} />
66
65
  </View>
67
66
  {!expanded && preview !== undefined && preview !== "" && (
68
- <View style={{ ...indent, marginTop: 3 }}>
69
- <SquareInline text={preview} theme={palette.theme} numberOfLines={2} lead={latest?.actor} />
67
+ <View style={{ ...indent, marginTop: 5, marginLeft: 2.5, paddingLeft: palette.quote.paddingLeft, borderLeftWidth: palette.quote.borderLeftWidth, borderLeftColor: palette.quote.borderLeftColor }}>
68
+ <SquareInline text={preview} theme={palette.theme} numberOfLines={2} lead={chorus ? latest?.actor : undefined} />
70
69
  </View>
71
70
  )}
72
71
  </Pressable>
73
72
  {expanded && (
74
73
  <ScrollView style={{ maxHeight: layout.compact ? 280 : 400, marginTop: 3 }} nestedScrollEnabled>
75
74
  <View style={{ ...indent, gap: 7 }}>
76
- {blocks.map((block, index) => {
77
- const voice = voices[index];
78
- if (block.body === "" && !voice.bell) return null;
79
- const repeat = index > 0 && voices[index - 1].speaker === voice.speaker;
80
- return (
81
- <View key={voice.actId ?? index} style={{ gap: 6 }}>
82
- {voice.bell && <SquareCue theme={palette.theme} label={`${voice.speaker ?? ""} 🔔`} />}
83
- <SquareMarkdownBody
84
- text={block.body}
85
- theme={palette.theme}
86
- lead={repeat ? undefined : voice.speaker}
87
- actor={voice.speaker}
88
- />
89
- </View>
90
- );
91
- })}
75
+ {items === undefined ? <SquareMarkdownBody text={text ?? ""} theme={palette.theme} /> : items.map((entry, index) => (
76
+ <View key={index} style={{ gap: 6 }}>
77
+ {entry.route === "bell"
78
+ ? <SquareCue theme={palette.theme} label={`${entry.actor} 🔔 · ${entry.square} · ${entry.act}`} />
79
+ : <Text style={{ color: muted, fontSize: 11, fontFamily: MONO }}>{entry.square} · {entry.act}</Text>}
80
+ <SquareMarkdownBody text={entry.body} theme={palette.theme} lead={chorus ? entry.actor : undefined} actor={entry.actor} />
81
+ </View>
82
+ ))}
92
83
  </View>
93
84
  </ScrollView>
94
85
  )}
95
86
  </View>
96
87
  );
97
88
  }
98
-
99
- /** act/18, act/19 → act/18–19; anything else is listed as is. */
100
- function actSpan(ids: string[]): string | undefined {
101
- if (ids.length < 2) return ids[0];
102
- const numbers = ids.map(id => /^act\/(\d+)$/.exec(id)?.[1]).map(Number);
103
- return numbers.every(Number.isInteger) ? `act/${Math.min(...numbers)}–${Math.max(...numbers)}` : ids.join(", ");
104
- }
@@ -1,5 +1,5 @@
1
1
  import type { PluginTimelineTransformerContribution } from "@getpaseo/plugin/client";
2
- import { COLLAPSED_PREVIEW_MAX_CHARS, outputPreview, squareCardDetailsSchema } from "../shared/message.ts";
2
+ import { COLLAPSED_PREVIEW_MAX_CHARS, OUTPUT_DISPLAY_MAX_CHARS, outputPreview, squareCardDetailsSchema, type SquareMessageData } from "../shared/message.ts";
3
3
 
4
4
  // Recognition is metadata-based, not a guess from a tool name or body text.
5
5
  // The same projection applies to live events and fetched history.
@@ -10,19 +10,26 @@ export const transformSquareMessage: PluginTimelineTransformerContribution<"tool
10
10
  typeof item.detail.text !== "string") return;
11
11
 
12
12
  const details = squareCardDetailsSchema.safeParse(item.metadata.details);
13
+ let data: SquareMessageData;
14
+ if (details.success) {
15
+ const items = details.data.items, latest = items.at(-1)!;
16
+ const preview = outputPreview(latest.preview ?? latest.body, COLLAPSED_PREVIEW_MAX_CHARS);
17
+ let remaining = OUTPUT_DISPLAY_MAX_CHARS - preview.length;
18
+ const clipped = items.reduce((sum, entry) => sum + entry.body.length, 0) > remaining;
19
+ data = { items: items.map(({ preview: _preview, ...entry }, index) => {
20
+ const body = clipped ? outputPreview(entry.body, Math.floor(remaining / (items.length - index))) : entry.body;
21
+ remaining -= body.length;
22
+ return { ...entry, body, ...(index === items.length - 1 ? { preview } : {}) };
23
+ }) };
24
+ } else {
25
+ data = { text: outputPreview(item.detail.text) };
26
+ }
13
27
  return {
14
28
  items: [{
15
29
  type: "plugin",
16
30
  kind: "square-message",
17
31
  version: 1,
18
- data: {
19
- text: outputPreview(details.success && details.data.fullText !== undefined ? details.data.fullText : item.detail.text),
20
- ...(details.success ? {
21
- // Only the last item's preview is rendered, so only it takes the visible budget.
22
- items: details.data.items.map((item, index, all) =>
23
- index < all.length - 1 || item.preview === undefined ? item : { ...item, preview: outputPreview(item.preview, COLLAPSED_PREVIEW_MAX_CHARS) }),
24
- } : {}),
25
- },
32
+ data,
26
33
  }],
27
34
  };
28
35
  };
@@ -1,95 +1,36 @@
1
1
  import { z } from "zod";
2
2
 
3
- /** Client display budget, including the omission line; source history stays unchanged. */
3
+ /** Aggregate client display budget, including omission lines; source history stays unchanged. */
4
4
  export const OUTPUT_DISPLAY_MAX_CHARS = 32_000;
5
5
  export const COLLAPSED_PREVIEW_MAX_CHARS = 1_000;
6
6
  export function outputPreview(text: string, maxChars = OUTPUT_DISPLAY_MAX_CHARS): string {
7
+ if (maxChars <= 0) return "";
7
8
  if (text.length <= maxChars) return text;
8
9
  const middle = "\n\n… Showing the beginning and end; the middle is hidden in this preview. …\n\n";
10
+ // Even a small share of a multi-message budget must fit, without splitting a surrogate pair.
11
+ if (maxChars <= middle.length) return text.slice(0, maxChars - 1).replace(/[\uD800-\uDBFF]$/, "") + "…";
9
12
  const headChars = Math.floor((maxChars - middle.length) / 2);
10
- // Budget in UTF-16 units, but never split a surrogate pair.
11
13
  const head = text.slice(0, headChars).replace(/[\uD800-\uDBFF]$/, "");
12
14
  const tail = text.slice(-(maxChars - middle.length - headChars)).replace(/^[\uDC00-\uDFFF]/, "");
13
15
  return head + middle + tail;
14
16
  }
15
17
 
16
- export interface SquareBlock { meta?: string; actor?: string; body: string }
17
-
18
- function parseAttributes(source: string): Record<string, string> {
19
- const attributes: Record<string, string> = {};
20
- for (const match of source.matchAll(/([a-z]+)="([^"]*)"/g)) {
21
- attributes[match[1]!] = match[2]!.replace(/&quot;/g, '"').replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&");
22
- }
23
- return attributes;
24
- }
25
-
26
- // A square notification is one or more <square-activity> blocks. Split them out so the
27
- // envelope becomes styled chrome and only the body goes through markdown: where and which
28
- // activity come from tag attributes, or from the leading `· ...` line when the tag has none.
29
- export function parseSquareBlocks(text: string): SquareBlock[] {
30
- const blocks: SquareBlock[] = [];
31
- const re = /<square-activity([^>]*)>\n([\s\S]*?)\n<\/square-activity>/g;
32
- let last = 0;
33
- for (const match of text.matchAll(re)) {
34
- const index = match.index ?? 0;
35
- const before = text.slice(last, index).trim();
36
- if (before !== "") blocks.push({ body: before });
37
- const attributes = parseAttributes(match[1]!);
38
- const innerLines = match[2]!.split("\n");
39
- const described = [attributes.square, attributes.as, attributes.id].filter((value) => value !== undefined);
40
- const meta = described.length > 0
41
- ? `· ${described.join(" · ")}`
42
- : innerLines[0]?.startsWith("· ") ? innerLines.shift() : undefined;
43
- const actor = innerLines.shift();
44
- blocks.push({ meta, actor, body: innerLines.join("\n").trim() });
45
- last = index + match[0].length;
46
- }
47
- const rest = text.slice(last).trim();
48
- if (rest !== "") blocks.push({ body: rest });
49
- if (blocks.length === 0) blocks.push({ body: text });
50
- return blocks;
51
- }
52
-
53
- export interface SquareVoice { speaker?: string; bell: boolean; actId?: string }
54
-
55
- // A voice line is chrome for the card: `● rei rang the bell` names who, how, and which
56
- // activity. Route prose is parsed into those marks, never rendered as narration.
57
- export function parseSquareVoice(actorLine?: string, metaLine?: string): SquareVoice {
58
- const line = actorLine?.trim() ?? "";
59
- const cleaned = line.replace(/^[●○◎◆■·]\s*/, "").trim();
60
- return {
61
- speaker: /\S+/.exec(cleaned)?.[0],
62
- bell: /rang the bell/i.test(cleaned),
63
- actId: /act\/\d+/.exec(metaLine ?? "")?.[0],
64
- };
65
- }
66
-
18
+ /** Renderer facts emitted by pendingCardDetails, never derived from body or route prose. */
67
19
  export const squareCardDetailsSchema = z.object({
68
- fullText: z.string().optional(),
69
20
  items: z.array(z.object({
70
21
  square: z.string(),
71
- actor: z.string().optional(),
22
+ actor: z.string(),
23
+ act: z.string().regex(/^act\/(0|[1-9]\d*)$/),
24
+ body: z.string(),
72
25
  recipient: z.string().optional(),
73
- route: z.enum(["mention", "reply", "attention", "bell"]).optional(),
26
+ route: z.enum(["mention", "reply", "attention", "bell"]),
74
27
  preview: z.string().optional(),
75
- description: z.string(),
76
28
  })).min(1),
77
29
  });
78
30
 
79
- /** Human-facing summary of a route; the card itself leads with people, not this prose. */
80
- export function squareHeadline(item: z.output<typeof squareCardDetailsSchema>["items"][number]): string {
81
- if (item.actor === undefined || item.route === undefined) return item.description;
82
- const to = item.recipient;
83
- switch (item.route) {
84
- case "mention": return to === undefined ? `${item.actor} spoke your name` : `${item.actor} spoke ${to}'s name`;
85
- case "reply": return to === undefined ? `${item.actor} answered you` : `${item.actor} answered ${to}`;
86
- case "attention": return to === undefined ? `${item.actor} spoke · you're listening` : `${item.actor} spoke · ${to} is listening`;
87
- case "bell": return `${item.actor} rang the bell`;
88
- }
89
- }
90
-
91
- export const squareMessageSchema = z.object({
92
- text: z.string(),
93
- items: squareCardDetailsSchema.shape.items.optional(),
94
- });
31
+ export const squareMessageSchema = z.union([
32
+ squareCardDetailsSchema,
33
+ z.object({ text: z.string() }),
34
+ ]);
95
35
  export type SquareMessageData = z.output<typeof squareMessageSchema>;
36
+ export type SquareNotificationItem = z.output<typeof squareCardDetailsSchema>["items"][number];
@@ -383,7 +383,7 @@ export default function squarePiExtension(pi) {
383
383
  try {
384
384
  await presentPendingAtBoundary(
385
385
  harnessOwner({ channel: 'pi', session: boundSessionId }),
386
- async (content) => {
386
+ async (content, delivered) => {
387
387
  if (signal.aborted || currentRunSignal?.aborted) throw new Error('Pi notification delivery cancelled');
388
388
  // One card for the human: a compact heading, expandable to the full body.
389
389
  // Model context stays bounded; renderer-only details carry the complete text.
@@ -391,7 +391,7 @@ export default function squarePiExtension(pi) {
391
391
  const landing = waitForLanding(messageContent, signal, 'steer');
392
392
  try {
393
393
  sendPiMessage(pi,
394
- { customType: 'square', content: messageContent, display: true, details: pendingCardDetails(pending) },
394
+ { customType: 'square', content: messageContent, display: true, details: pendingCardDetails(delivered) },
395
395
  { deliverAs: 'steer', triggerTurn: true },
396
396
  );
397
397
  } catch (error) {
@@ -411,7 +411,8 @@ export default function squarePiExtension(pi) {
411
411
  signal,
412
412
  );
413
413
  if (signal.aborted || token !== generation) return;
414
- for (const key of keys) handledPending.add(key);
414
+ // Only committed evidence suppresses successful delivery. A competing wake may
415
+ // have won the claim and can still fail before sending, making this item eligible again.
415
416
  } catch (error) {
416
417
  if (signal.aborted || token !== generation) return;
417
418
  if (error instanceof PiDeliveryDroppedError) {
@@ -1,7 +0,0 @@
1
- /**
2
- * Seamless 512px stone tiles, rendered once offline (float canvas noise, dithered, JPEG):
3
- * veinless honed stone — only the milky drift, on muted grey-white (light) or charcoal (dark).
4
- * Kept tiny so they inline as data URIs.
5
- */
6
- export const MARBLE_LIGHT = "data:image/jpeg;base64,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";
7
- export const MARBLE_DARK = "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/4gHYSUNDX1BST0ZJTEUAAQEAAAHIAAAAAAQwAABtbnRyUkdCIFhZWiAH4AABAAEAAAAAAABhY3NwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAA9tYAAQAAAADTLQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAlkZXNjAAAA8AAAACRyWFlaAAABFAAAABRnWFlaAAABKAAAABRiWFlaAAABPAAAABR3dHB0AAABUAAAABRyVFJDAAABZAAAAChnVFJDAAABZAAAAChiVFJDAAABZAAAAChjcHJ0AAABjAAAADxtbHVjAAAAAAAAAAEAAAAMZW5VUwAAAAgAAAAcAHMAUgBHAEJYWVogAAAAAAAAb6IAADj1AAADkFhZWiAAAAAAAABimQAAt4UAABjaWFlaIAAAAAAAACSgAAAPhAAAts9YWVogAAAAAAAA9tYAAQAAAADTLXBhcmEAAAAAAAQAAAACZmYAAPKnAAANWQAAE9AAAApbAAAAAAAAAABtbHVjAAAAAAAAAAEAAAAMZW5VUwAAACAAAAAcAEcAbwBvAGcAbABlACAASQBuAGMALgAgADIAMAAxADb/2wBDAAYEBAUEBAYFBQUGBgYHCQ4JCQgICRINDQoOFRIWFhUSFBQXGiEcFxgfGRQUHScdHyIjJSUlFhwpLCgkKyEkJST/2wBDAQYGBgkICREJCREkGBQYJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCT/wAARCAIAAgADASIAAhEBAxEB/8QAFwABAQEBAAAAAAAAAAAAAAAAAAECCP/EABYQAQEBAAAAAAAAAAAAAAAAAAABEf/EABUBAQEAAAAAAAAAAAAAAAAAAAAB/8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAwDAQACEQMRAD8A54AVFEAUQAAAAAAAAAAAAAAAABVRQWKkAURVBFARQQAAAAQAEABEUBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFiLAVUigKCgAgAgKIKKCIAAJUVAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABUAVWV0GtNTTQaE00FEFFQEABQAQQABFARGkBMFKCAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAumoA1oigoigAgKIoAACKgAAAKCFioDNFqAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAKigoCgaIgoigogAAAAAqAKgAgqUEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABYJFBQAEVAAAU1AFQAAAFRQAUESqKMi1EAAAXDAQXAEFQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACKkagAoCCgIACAAgAKIoCooACgAgYYqKJgogAAAqiIogyKAguIAAAKgAAAAAAAAAAAAAAAAAAAAAAAAAAEaZUGgAAAEUBEVAQVAURQURQAAAAUAABRBRADAAVAQVAQVARQAQAAAAAAAAAAAAAAAAAAAAAAAAAAAUAFEFF0QQUEAABAAAAURQUAAAFEUAFUAEAAAAEFQERUAAARUAAAAAAAAAAAAAAAAAAAAAAAAAAAVAAAFEUAEAAAAAAAVFBQAAAAAVWVBTTUBRFAAARUBBUBBUBAAAAAAAAAAAAAAAAAAAwAAAAAAAAAAAAAAAAAAAAAAAVFBRFAABAAUAFAAABRAFQQFSiACAAAAAAAAAAAAAAAAEBQigIoCCmAiNICAAAAAAAAAAAAAAAAAAAAsEUAAAAFEUFEAVABRAAAEqKgAAAAAAAAAAAAAAAAAALFSAKAAqAKgAgqAgAAAAAAAAAAAAAAAAAAAAAAAKIoKIoCooCKAiKAiKAgAAAAAAAAAAAAAAAAACoAqoAogCgAAAlRagAAAAAAAAAAAAAAAAAAAAAAAAKqRQFAAEARUAQAAAAAAAAAAAAAAAAAAAAAAAXRAFNQBoQAqAAAAAAAAAAAAAAAAAAAAAAAAACxUgDQmgACglVEBAAAAAAAAAAAAAAAAAAAAAAAAAAABRFBAAAAAAAAAAAAAAAAAAAAAAAAAAAAU1DQXTUAXRAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAVAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAVFBBQEFAQAAAAAAAAAAAAAAAFAAABBUAAABQRQAAAAAAARUAAAAAAAAAAAAAAAAAAAAABQAUEFAQVAQVAAAAAAAAAAAAAAAUAAAAAAxTATBQEFQAAAEBRAAAAAAAAAAAAAAAAAAAAAAABUUBUUAUBBQEwxQGcRpAQAAAAAAAAABUAURQBQAFAAABVGRUQRGqgIAAAAAAAAAAAAAAAAAAAAAAAAAChi4AKAAqiAIAAIACIoCCoAAAAAAACgAsAXBYCKKCC4AgoCIoCM1pFGaLYiAAAAAAAAAAAAAACooCCgIKmACgILhgNYYoACggACLUABAAAQAEAAAAAAFAAAFiKCxYiwFCAAAAoohioCJWkBmo0iCIoCCgIAAAAAAAACgAoIooJiY0YDKrgCLgqigoIKAiNIDIqICKAgUARQERQEAAUAFAAUBBQBQAVBRQAFQBUABFQBFQERRBEVAAAAAAAAAFiKCgoAKAAAACKALFZVRQAEBBEVKCAKACACAIqAAoAAKqAKAACgAoIKigoIAgoAIAAAIoJiiCVlpKCAAAAAAAAKigsVAFABRFABFFEEFisqCiCioCCBUAAAAAQKCAAKigAAKigoAKEVQBAURQEAAAAQQFQBUAAAERQERUAAAAAABRFBQAAAFQBRNUARQFRQAABACoACCAuiAKgAAAAAoACgAoAoigCCiiAKIIAAAgCgAAAJVQBABKAAAAAAAAqAKqQBQAAQFEAURQFZAaEAUQARagCAAAAAAAAAAqKAqAKqKACqIoAgCAigIAACAqoAoigIqAgIAAAAAAAAAAAACiAKgAaaALpqACoAqooAACCUAAAAAAAAAAAABUUFVFAUAEVAAFAUQRFAQVAAAAAEABFQAAAAAAAAAAAAAAAAAAAAAAFAAAARUAAAAAAAAAAAAAABqKysBoRQAFEAQAVRBQEMUQQAERUAQAAAAAAAAAAAAAAAAAAAAAAAf/9k=";