@astrosheep/square 0.3.24 → 0.3.26

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "square",
3
- "version": "0.3.24",
3
+ "version": "0.3.26",
4
4
  "description": "Native Claude Code turn-boundary delivery for Square participants",
5
5
  "author": {
6
6
  "name": "Square"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "square",
3
- "version": "0.3.24",
3
+ "version": "0.3.26",
4
4
  "description": "Shared Square activity with reliable participant attention at Codex boundaries.",
5
5
  "author": {
6
6
  "name": "Square"
@@ -5,10 +5,10 @@ export interface ActivityFeedFilter {
5
5
  }
6
6
  export declare function actDelta(acts: StoredAct[], cursor: number): StoredAct[];
7
7
  /** Visible activities after the participant's derived continuous-seen prefix. */
8
- export declare function deliveryDelta(squareState: SquareState, name: string): StoredAct[];
8
+ export declare function deliveryDelta(squareState: SquareState, name: string, delivery?: import("./delivery.js").DeliveryModel): StoredAct[];
9
9
  export declare function peerRoomChanges(delta: StoredAct[], name: string): RoomChangeAct[];
10
10
  export declare function peerPublicActs(delta: StoredAct[], name: string): PublicAct[];
11
- export declare function directedPeerSays(squareState: SquareState, delta: StoredAct[], name: string): Extract<StoredAct, {
11
+ export declare function directedPeerSays(squareState: SquareState, delta: StoredAct[], name: string, delivery?: import("./delivery.js").DeliveryModel): Extract<StoredAct, {
12
12
  kind: 'say';
13
13
  }>[];
14
14
  export declare function matchesFeedFilter(act: StoredAct, filter: ActivityFeedFilter, recipients?: readonly string[]): boolean;
@@ -1,13 +1,11 @@
1
1
  import { sameName } from './model.js';
2
- import { landedAudienceIncludes } from './square-core.js';
3
- import { readCursor } from './runtime.js';
4
- import { matchesCatchFilter } from './delivery.js';
2
+ import { deriveDeliveryModel, matchesCatchFilter } from './delivery.js';
5
3
  export function actDelta(acts, cursor) {
6
4
  return acts.filter((act) => act.index > cursor);
7
5
  }
8
6
  /** Visible activities after the participant's derived continuous-seen prefix. */
9
- export function deliveryDelta(squareState, name) {
10
- return actDelta(squareState.acts, readCursor(squareState, name));
7
+ export function deliveryDelta(squareState, name, delivery = deriveDeliveryModel(squareState)) {
8
+ return actDelta(squareState.acts, delivery.cursorFor(name));
11
9
  }
12
10
  export function peerRoomChanges(delta, name) {
13
11
  return delta.filter((act) => act.actor !== undefined && !sameName(act.actor, name) && act.kind !== 'say' && act.kind !== 'read');
@@ -15,8 +13,8 @@ export function peerRoomChanges(delta, name) {
15
13
  export function peerPublicActs(delta, name) {
16
14
  return delta.filter((act) => act.actor !== undefined && !sameName(act.actor, name) && (act.kind === 'say' || act.kind === 'done'));
17
15
  }
18
- export function directedPeerSays(squareState, delta, name) {
19
- return delta.filter((act) => landedAudienceIncludes(squareState.acts, act, name));
16
+ export function directedPeerSays(squareState, delta, name, delivery = deriveDeliveryModel(squareState)) {
17
+ return delta.filter((act) => delivery.directedTo(act, name));
20
18
  }
21
19
  function matchesParticipants(act, participants) {
22
20
  return participants === undefined || (act.actor !== undefined && participants.some((participant) => sameName(participant, act.actor)));
@@ -13,6 +13,7 @@ import { openSquare } from '../square-file-adapter.js';
13
13
  import { closeOpenSquare } from '../open-square.js';
14
14
  import { historyPresentation, participantsPresentation, statusPresentation } from '../views.js';
15
15
  import { fail, parseDurationMs, parseNameList, parseNonNegativeInteger, readStdinSync, requireParticipant, requireSquarePath, requireValue, usage, } from './context.js';
16
+ const STATUS_PARTICIPANT_PREVIEW_LIMIT = 10;
16
17
  export const listCommand = {
17
18
  parse: (argv) => argv,
18
19
  async execute(argv, context) {
@@ -95,8 +96,9 @@ export const catchCommand = {
95
96
  },
96
97
  async execute(intent, context) {
97
98
  const squarePath = requireSquarePath(context);
98
- await cmdWatch(squarePath, requireParticipant(context.name), intent);
99
- await sweepPendingNotifications(squarePath);
99
+ const caught = await cmdWatch(squarePath, requireParticipant(context.name), intent);
100
+ if (caught !== false)
101
+ await sweepPendingNotifications(squarePath);
100
102
  },
101
103
  present: () => { },
102
104
  };
@@ -382,7 +384,7 @@ export const statusCommand = {
382
384
  return aViewer ? -1 : 1;
383
385
  return (b.lastActiveAt ?? -Infinity) - (a.lastActiveAt ?? -Infinity) || a.name.localeCompare(b.name);
384
386
  });
385
- const people = active.length === 0 ? [' ○ nobody in the square'] : active.map((participant) => {
387
+ const people = active.length === 0 ? [' ○ nobody in the square'] : active.slice(0, STATUS_PARTICIPANT_PREVIEW_LIMIT).map((participant) => {
386
388
  const glyph = participant.presence === 'watching'
387
389
  ? '◎'
388
390
  : participant.activityCount > 0 ? '●' : '○';
@@ -401,6 +403,10 @@ export const statusCommand = {
401
403
  : 'caught up';
402
404
  return ` ${glyph} ${participantIdentity(participant.name)} · ${summary}${attention === '' ? '' : ` · ${attention}`}`;
403
405
  });
406
+ if (active.length > STATUS_PARTICIPANT_PREVIEW_LIMIT) {
407
+ people.push(` ○ … ${active.length - STATUS_PARTICIPANT_PREVIEW_LIMIT} more participants`);
408
+ people.push(`» ${commandPrefix(squarePath)} participants`);
409
+ }
404
410
  const cap = result.hardCap === null ? 'unlimited' : String(result.hardCap);
405
411
  const hold = result.holdActive
406
412
  ? `· ${result.holdActor === undefined ? 'someone' : participantIdentity(result.holdActor)} raised a hand${result.holdReason ? ` — ${result.holdReason}` : ''} · ${result.holdAt === undefined
@@ -1,6 +1,7 @@
1
1
  import { type ActivitiesOptions, type Act, type StoredAct, type SquareState, type Reach, type HardCap } from './model.js';
2
2
  import { directedPeerSays, peerRoomChanges } from './activity-feed.js';
3
3
  import { type Perception } from './square-core.js';
4
+ import { type DeliveryModel } from './delivery.js';
4
5
  export interface UnreadActivitySummary {
5
6
  name: string;
6
7
  count: number;
@@ -106,6 +107,6 @@ export interface StatusResult {
106
107
  latestAct: StoredAct | undefined;
107
108
  now: number;
108
109
  }
109
- export declare function coreStatus(squareState: SquareState, now: number): StatusResult;
110
- export declare function coreParticipants(squareState: SquareState, now: number): ParticipantStatus[];
111
- export declare function coreActivities(squareState: SquareState, opts: ActivitiesOptions): StoredAct[];
110
+ export declare function coreStatus(squareState: SquareState, now: number, delivery?: DeliveryModel): StatusResult;
111
+ export declare function coreParticipants(squareState: SquareState, now: number, delivery?: DeliveryModel): ParticipantStatus[];
112
+ export declare function coreActivities(squareState: SquareState, opts: ActivitiesOptions, suppliedDelivery?: DeliveryModel): StoredAct[];
package/dist/decisions.js CHANGED
@@ -1,9 +1,9 @@
1
1
  import { SquareError, sameName, validateName, } from './model.js';
2
2
  import { participantIdentity } from './participant-identity.js';
3
- import { UNREAD_BLOCK_GRACE_MS, actId, actStableIndex, foldedState, freshWatchLease, publicActs, readCursor, resolveRosterName, rosterNames, THROTTLE_WINDOW_MS, } from './runtime.js';
3
+ import { UNREAD_BLOCK_GRACE_MS, actId, actStableIndex, foldedState, freshWatchLease, publicActs, resolveRosterName, rosterNames, THROTTLE_WINDOW_MS, } from './runtime.js';
4
4
  import { actDelta, directedPeerSays } from './activity-feed.js';
5
5
  import { formatActivityId, isIgnored, isListening, listeningTo, validate } from './square-core.js';
6
- import { deriveDeliveryModel, perceiveActivity } from './delivery.js';
6
+ import { deriveDeliveryModel } from './delivery.js';
7
7
  import { compileSearchPattern } from './search.js';
8
8
  export function resolveKnownName(squareState, name) {
9
9
  validateName(name);
@@ -100,8 +100,9 @@ export function decideAct(squareState, input) {
100
100
  if (result.reason === 'not_joined')
101
101
  throw new SquareError('not_joined', `${name} has not joined this square`);
102
102
  }
103
- const delta = actDelta(squareState.acts, readCursor(squareState, name));
104
- const unreadPublic = directedPeerSays(squareState, delta, name);
103
+ const delivery = deriveDeliveryModel(squareState);
104
+ const delta = actDelta(squareState.acts, delivery.cursorFor(name));
105
+ const unreadPublic = directedPeerSays(squareState, delta, name, delivery);
105
106
  const unreadRoomChanges = [];
106
107
  const sayCountByActor = new Map();
107
108
  const unreadByParticipant = new Map();
@@ -119,7 +120,7 @@ export function decideAct(squareState, input) {
119
120
  latestAt: currentSummary === undefined ? item.at : Math.max(currentSummary.latestAt, item.at),
120
121
  previews: [
121
122
  ...(currentSummary?.previews ?? []),
122
- { number: sayCountByActor.get(actorKey) ?? 1, act: item, perception: perceiveActivity(squareState, item, name) },
123
+ { number: sayCountByActor.get(actorKey) ?? 1, act: item, perception: delivery.perceive(item, name) },
123
124
  ].slice(-UNREAD_PREVIEW_LIMIT),
124
125
  });
125
126
  }
@@ -189,10 +190,10 @@ export function coreResume(squareState, actor, now) {
189
190
  requireStanding(squareState, act);
190
191
  return act;
191
192
  }
192
- function presenceFor(squareState, snapshot, name, now) {
193
+ function presenceFor(squareState, snapshot, name, now, delivery) {
193
194
  if (snapshot?.done)
194
195
  return { state: 'done', lastAt: snapshot.lastActiveAt };
195
- const cursorAt = squareState.acts.findLast((act) => act.index <= readCursor(squareState, name))?.at;
196
+ const cursorAt = squareState.acts.findLast((act) => act.index <= delivery.cursorFor(name))?.at;
196
197
  const lease = freshWatchLease(squareState, name, now);
197
198
  if (lease !== undefined)
198
199
  return { state: 'watching', lastAt: cursorAt ?? lease.heartbeatAt };
@@ -201,19 +202,19 @@ function presenceFor(squareState, snapshot, name, now) {
201
202
  ? { state: 'never-joined', lastAt: undefined }
202
203
  : { state: 'active', lastAt };
203
204
  }
204
- function buildParticipantStatuses(squareState, now, state = foldedState(squareState)) {
205
- const delivery = deriveDeliveryModel(squareState);
205
+ function buildParticipantStatuses(squareState, now, state = foldedState(squareState), suppliedDelivery) {
206
+ const delivery = suppliedDelivery ?? deriveDeliveryModel(squareState);
206
207
  return state.participants.map((snapshot) => {
207
208
  const participant = snapshot.name;
208
- const presence = presenceFor(squareState, snapshot, participant, now);
209
+ const presence = presenceFor(squareState, snapshot, participant, now, delivery);
209
210
  const participantStatus = snapshot?.done ? 'done' : snapshot?.joined ? 'active' : 'not joined';
210
- const consumedThrough = readCursor(squareState, participant);
211
+ const consumedThrough = delivery.cursorFor(participant);
211
212
  let unreadActivityCount = 0;
212
213
  if (snapshot?.joined) {
213
214
  for (const act of squareState.acts) {
214
215
  if (actStableIndex(act) <= consumedThrough)
215
216
  continue;
216
- if (directedPeerSays(squareState, [act], participant).length > 0)
217
+ if (directedPeerSays(squareState, [act], participant, delivery).length > 0)
217
218
  unreadActivityCount++;
218
219
  }
219
220
  }
@@ -230,7 +231,7 @@ function buildParticipantStatuses(squareState, now, state = foldedState(squareSt
230
231
  };
231
232
  });
232
233
  }
233
- export function coreStatus(squareState, now) {
234
+ export function coreStatus(squareState, now, delivery) {
234
235
  const state = foldedState(squareState);
235
236
  const latestAct = publicActs(squareState.acts).at(-1);
236
237
  return {
@@ -242,19 +243,21 @@ export function coreStatus(squareState, now) {
242
243
  holdReason: state.hold.reason,
243
244
  holdActor: state.hold.actor,
244
245
  holdAt: state.hold.at,
245
- participants: buildParticipantStatuses(squareState, now, state),
246
+ participants: buildParticipantStatuses(squareState, now, state, delivery),
246
247
  latestAct,
247
248
  now,
248
249
  };
249
250
  }
250
- export function coreParticipants(squareState, now) {
251
- return buildParticipantStatuses(squareState, now);
251
+ export function coreParticipants(squareState, now, delivery) {
252
+ return buildParticipantStatuses(squareState, now, undefined, delivery);
252
253
  }
253
- export function coreActivities(squareState, opts) {
254
+ export function coreActivities(squareState, opts, suppliedDelivery) {
254
255
  const participants = opts.participants ?? [];
255
256
  const canonicalParticipants = participants.map((participant) => resolveKnownName(squareState, participant));
256
257
  const viewer = opts.viewer !== undefined ? resolveKnownName(squareState, opts.viewer) : undefined;
257
258
  let acts = [...squareState.acts];
259
+ let delivery = suppliedDelivery;
260
+ const projected = () => delivery ??= deriveDeliveryModel(squareState);
258
261
  // --at establishes one or more context windows first; other filters AND inside their union.
259
262
  if (opts.atIndexes !== undefined && opts.atIndexes.length > 0) {
260
263
  const before = opts.beforeContext ?? 0;
@@ -280,13 +283,12 @@ export function coreActivities(squareState, opts) {
280
283
  acts = acts.filter((act) => act.at > opts.after);
281
284
  if (opts.mention != null) {
282
285
  const mention = resolveKnownName(squareState, opts.mention);
283
- const delivery = deriveDeliveryModel(squareState);
284
- acts = acts.filter((act) => act.kind === 'say' && delivery.plan(act).some((item) => sameName(item.recipient, mention)));
286
+ acts = acts.filter((act) => act.kind === 'say' && projected().plan(act).some((item) => sameName(item.recipient, mention)));
285
287
  }
286
288
  if (opts.pending) {
287
289
  if (viewer === undefined)
288
290
  return [];
289
- const pendingIndexes = new Set(deriveDeliveryModel(squareState).pendingFor(viewer).map((notification) => notification.item.index));
291
+ const pendingIndexes = new Set(projected().pendingFor(viewer).map((notification) => notification.item.index));
290
292
  acts = acts.filter((act) => pendingIndexes.has(act.index));
291
293
  }
292
294
  const search = opts.grep !== undefined ? { pattern: opts.grep, fixed: false } : opts.fixed !== undefined ? { pattern: opts.fixed, fixed: true } : undefined;
@@ -57,6 +57,14 @@ export interface CatchFilterShape {
57
57
  export interface DeliveryModel {
58
58
  plan(item: StoredAct): PlannedNotification[];
59
59
  pendingFor(recipient: string): PlannedNotification[];
60
+ directedTo(item: StoredAct, recipient: string): boolean;
61
+ perceive(item: StoredAct, viewer: string): Perception;
62
+ cursorFor(recipient: string): number;
63
+ isSeen(recipient: string, actOrIndex: StoredAct | number): boolean;
64
+ knownParticipant(name: string): string | undefined;
65
+ participants(): readonly string[];
66
+ joinedRecipients(): readonly string[];
67
+ readonly replayedActivityCount: number;
60
68
  }
61
69
  export declare function isActivitySeen(squareState: SquareState, name: string, actOrIndex: StoredAct | number): boolean;
62
70
  /**
@@ -64,10 +72,10 @@ export declare function isActivitySeen(squareState: SquareState, name: string, a
64
72
  * All consumers share these targets instead of reinterpreting artifact text or cursor state.
65
73
  */
66
74
  export declare function deriveDeliveryModel(squareState: SquareState): DeliveryModel;
67
- export declare function planActNotifications(squareState: SquareState, item: StoredAct): PlannedNotification[];
68
- export declare function perceiveActivity(squareState: SquareState, item: StoredAct, viewer: string): Perception;
75
+ export declare function planActNotifications(squareState: SquareState, item: StoredAct, delivery?: DeliveryModel): PlannedNotification[];
76
+ export declare function perceiveActivity(squareState: SquareState, item: StoredAct, viewer: string, delivery?: DeliveryModel): Perception;
69
77
  /** Mark only the notifications selected by the canonical catch projection as fully seen. */
70
- export declare function markSeenNotifications(squareState: SquareState, recipient: string, delivered: StoredAct[], at?: number): boolean;
78
+ export declare function markSeenNotifications(squareState: SquareState, recipient: string, delivered: StoredAct[], at?: number, delivery?: DeliveryModel): boolean;
71
79
  /** Canonical say-activity filter shared by catch selection and hook ownership. */
72
80
  export declare function matchesCatchFilter(activity: CatchFilterShape, filter: WatchLeaseFilter): boolean;
73
81
  /** True only when the live catch's own filters would deliver this notification. */
package/dist/delivery.js CHANGED
@@ -1,24 +1,29 @@
1
1
  import { findParticipantName, sameName, } from './model.js';
2
- import { audienceBefore, audienceOf, formatActivityId } from './square-core.js';
3
- import { isCurrentlyJoined, lastJoinIndex, matchesMentionTarget, observationFor, recordObservation, resolveRosterName, rosterNames } from './runtime.js';
2
+ import { audienceOf, formatActivityId, replayLandedAudiences } from './square-core.js';
3
+ import { matchesMentionTarget, readCursor, recordObservation } from './runtime.js';
4
4
  export function notificationMessageId(squarePath, actIndex) {
5
5
  return `square:${squarePath}#${formatActivityId(actIndex)}`;
6
6
  }
7
- function canonicalRecipient(squareState, name) {
8
- return resolveRosterName(squareState, name) ?? name;
9
- }
10
7
  export function isActivitySeen(squareState, name, actOrIndex) {
11
- const index = typeof actOrIndex === 'number' ? actOrIndex : actOrIndex.index;
12
- return observationFor(squareState, canonicalRecipient(squareState, name), index)?.state === 'seen';
8
+ return deriveDeliveryModel(squareState).isSeen(name, actOrIndex);
13
9
  }
14
10
  /**
15
11
  * Derive delivery behavior once from the decoded Square state.
16
12
  * All consumers share these targets instead of reinterpreting artifact text or cursor state.
17
13
  */
18
14
  export function deriveDeliveryModel(squareState) {
19
- const roster = rosterNames(squareState).filter((name) => isCurrentlyJoined(squareState.acts, name));
15
+ const landed = replayLandedAudiences(squareState.acts);
16
+ const roster = [...landed.joined];
20
17
  const plannedByIndex = new Map();
21
18
  let pendingByRecipient;
19
+ function canonicalRecipient(name) {
20
+ return landed.resolveParticipant(name) ?? name;
21
+ }
22
+ function isSeen(requestedRecipient, actOrIndex) {
23
+ const recipient = canonicalRecipient(requestedRecipient);
24
+ const index = typeof actOrIndex === 'number' ? actOrIndex : actOrIndex.index;
25
+ return squareState.runtime.observations?.[recipient]?.[formatActivityId(index)]?.state === 'seen';
26
+ }
22
27
  function plan(item) {
23
28
  if (item.kind !== 'say')
24
29
  return [];
@@ -27,7 +32,7 @@ export function deriveDeliveryModel(squareState) {
27
32
  return [...cached];
28
33
  const sayItem = item;
29
34
  const audience = audienceOf(sayItem);
30
- const recipients = audienceBefore(squareState.acts, sayItem);
35
+ const recipients = landed.recipientsFor(sayItem);
31
36
  const planned = recipients.map((recipient) => {
32
37
  const route = audience.kind === 'bell'
33
38
  ? 'bell'
@@ -43,7 +48,7 @@ export function deriveDeliveryModel(squareState) {
43
48
  return [];
44
49
  if (pendingByRecipient === undefined) {
45
50
  pendingByRecipient = new Map(roster.map((name) => [name, []]));
46
- const joinedAfter = new Map(roster.map((name) => [name, lastJoinIndex(squareState.acts, name)]));
51
+ const joinedAfter = new Map(roster.map((name) => [name, landed.lastJoinIndex(name)]));
47
52
  for (const act of squareState.acts) {
48
53
  if (act.kind !== 'say')
49
54
  continue;
@@ -51,7 +56,7 @@ export function deriveDeliveryModel(squareState) {
51
56
  const joinedAt = joinedAfter.get(planned.recipient);
52
57
  if (joinedAt === undefined || act.index <= joinedAt)
53
58
  continue;
54
- if (isActivitySeen(squareState, planned.recipient, act.index))
59
+ if (isSeen(planned.recipient, act.index))
55
60
  continue;
56
61
  pendingByRecipient.get(planned.recipient)?.push(planned);
57
62
  }
@@ -59,23 +64,38 @@ export function deriveDeliveryModel(squareState) {
59
64
  }
60
65
  return [...(pendingByRecipient.get(recipient) ?? [])];
61
66
  }
62
- return { plan, pendingFor };
67
+ function directedTo(item, recipient) {
68
+ return item.kind === 'say' && landed.includes(item, recipient);
69
+ }
70
+ function perceive(item, viewer) {
71
+ if (item.kind !== 'say' || sameName(item.actor, viewer))
72
+ return 'full';
73
+ return directedTo(item, viewer) ? 'full' : 'presence';
74
+ }
75
+ return {
76
+ plan,
77
+ pendingFor,
78
+ directedTo,
79
+ perceive,
80
+ cursorFor: (recipient) => readCursor(squareState, recipient, landed),
81
+ isSeen,
82
+ knownParticipant: (name) => landed.resolveParticipant(name),
83
+ participants: () => landed.participants,
84
+ joinedRecipients: () => roster,
85
+ replayedActivityCount: landed.replayedActivityCount,
86
+ };
63
87
  }
64
- export function planActNotifications(squareState, item) {
65
- return deriveDeliveryModel(squareState).plan(item);
88
+ export function planActNotifications(squareState, item, delivery = deriveDeliveryModel(squareState)) {
89
+ return delivery.plan(item);
66
90
  }
67
- export function perceiveActivity(squareState, item, viewer) {
68
- if (item.kind !== 'say' || sameName(item.actor, viewer))
69
- return 'full';
70
- return deriveDeliveryModel(squareState).plan(item).some((planned) => sameName(planned.recipient, viewer))
71
- ? 'full'
72
- : 'presence';
91
+ export function perceiveActivity(squareState, item, viewer, delivery = deriveDeliveryModel(squareState)) {
92
+ return delivery.perceive(item, viewer);
73
93
  }
74
94
  /** Mark only the notifications selected by the canonical catch projection as fully seen. */
75
- export function markSeenNotifications(squareState, recipient, delivered, at = Date.now()) {
95
+ export function markSeenNotifications(squareState, recipient, delivered, at = Date.now(), delivery = deriveDeliveryModel(squareState)) {
76
96
  const deliveredIndexes = new Set(delivered.map((item) => item.index));
77
97
  let changed = false;
78
- for (const notification of deriveDeliveryModel(squareState).pendingFor(recipient)) {
98
+ for (const notification of delivery.pendingFor(recipient)) {
79
99
  if (!deliveredIndexes.has(notification.item.index))
80
100
  continue;
81
101
  changed = recordObservation(squareState, notification.recipient, notification.item.index, 'seen', at) || changed;
@@ -1,4 +1,5 @@
1
- import { planActNotifications, type WakeAdapter } from './delivery.js';
1
+ import { planActNotifications, type DeliveryModel, type WakeAdapter } from './delivery.js';
2
+ import { type SquareState } from './model.js';
2
3
  import { matchesMentionTarget } from './runtime.js';
3
4
  import { type ActivityId } from './square-core.js';
4
5
  import type { WakeNotifier } from './square-facade.js';
@@ -26,5 +27,7 @@ export interface SweepPendingNotificationsOptions extends WorkerLaunchOptions {
26
27
  now?: number;
27
28
  limit?: number;
28
29
  }
30
+ /** Select sweep candidates from one frozen snapshot and one delivery replay. */
31
+ export declare function pendingNotificationSweepFromState(squarePath: string, state: SquareState, now: number, env: NodeJS.ProcessEnv, limit: number, deriveDelivery?: (snapshot: SquareState) => DeliveryModel): number[];
29
32
  /** Reconsider old pending attention at a bounded action boundary using the existing worker. */
30
33
  export declare function sweepPendingNotifications(squarePath: string, opts?: SweepPendingNotificationsOptions): Promise<number[]>;
@@ -2,7 +2,7 @@ import { spawn } from 'node:child_process';
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { setTimeout as sleep } from 'node:timers/promises';
4
4
  import { fileURLToPath } from 'node:url';
5
- import { leaseOwnsNotification, planActNotifications, } from './delivery.js';
5
+ import { deriveDeliveryModel, leaseOwnsNotification, planActNotifications, } from './delivery.js';
6
6
  import { sessionInbox } from './inbox.js';
7
7
  import { hasPresentedAttention } from './presented.js';
8
8
  import { SquareError } from './model.js';
@@ -256,6 +256,23 @@ export function wakeNotifierForSquare(squarePath, env = process.env) {
256
256
  },
257
257
  };
258
258
  }
259
+ /** Select sweep candidates from one frozen snapshot and one delivery replay. */
260
+ export function pendingNotificationSweepFromState(squarePath, state, now, env, limit, deriveDelivery = deriveDeliveryModel) {
261
+ const delivery = deriveDelivery(state);
262
+ const pending = pendingDeliveriesFromState(state, delivery);
263
+ const evidence = wakeEvidenceProjectionFromState(squarePath, state, now, env, delivery);
264
+ const indexes = new Set();
265
+ for (const recipient of pending) {
266
+ for (const note of recipient.notifications) {
267
+ if (now - note.item.at <= wakeGraceMs(env))
268
+ continue;
269
+ if (!wakeIsEligible(evidence.evidence(recipient.recipient, note.item.index)))
270
+ continue;
271
+ indexes.add(note.item.index);
272
+ }
273
+ }
274
+ return [...indexes].sort((left, right) => left - right).slice(0, Math.max(0, limit));
275
+ }
259
276
  /** Reconsider old pending attention at a bounded action boundary using the existing worker. */
260
277
  export async function sweepPendingNotifications(squarePath, opts = {}) {
261
278
  const env = opts.env ?? process.env;
@@ -271,19 +288,7 @@ export async function sweepPendingNotifications(squarePath, opts = {}) {
271
288
  finally {
272
289
  await closeOpenSquare(square);
273
290
  }
274
- const pending = pendingDeliveriesFromState(state);
275
- const evidence = wakeEvidenceProjectionFromState(squarePath, state, now, env);
276
- const indexes = new Set();
277
- for (const delivery of pending) {
278
- for (const note of delivery.notifications) {
279
- if (now - note.item.at <= wakeGraceMs(env))
280
- continue;
281
- if (!wakeIsEligible(evidence.evidence(delivery.recipient, note.item.index)))
282
- continue;
283
- indexes.add(note.item.index);
284
- }
285
- }
286
- const selected = [...indexes].sort((a, b) => a - b).slice(0, Math.max(0, limit));
291
+ const selected = pendingNotificationSweepFromState(squarePath, state, now, env, limit);
287
292
  const workerPath = fileURLToPath(new URL('./cmd/notify-once.js', import.meta.url));
288
293
  for (const actIndex of selected) {
289
294
  (opts.launchWorker ?? launchWorker)(workerPath, ['--location', squarePath, '--act-index', String(actIndex)]);
@@ -1,6 +1,7 @@
1
+ import { type DeliveryModel } from './delivery.js';
1
2
  import type { OpenSquare } from './open-square.js';
2
3
  import type { CatchOptions, CatchResult } from './square-facade.js';
3
- export declare function catchUp(square: OpenSquare, name: string, options?: CatchOptions): Promise<CatchResult>;
4
+ export declare function catchUp(square: OpenSquare, name: string, options?: CatchOptions, deriveDelivery?: (state: import('./model.js').SquareState) => DeliveryModel): Promise<CatchResult>;
4
5
  /** Commit seen only for complete, actually rendered boundary bodies. */
5
6
  export declare function markBoundarySeen(squarePath: string, name: string, ownerId: string | undefined, actIndexes: readonly number[], at?: number): Promise<void>;
6
7
  export declare function markNotificationNotified(square: OpenSquare, name: string, actIndex: number, ownerId: string | undefined, at?: number): Promise<void>;
package/dist/presence.js CHANGED
@@ -1,23 +1,22 @@
1
1
  import { extractMentions, formatActivityId } from './square-core.js';
2
2
  import { deliveryDelta, directedPeerSays, matchesFeedFilter } from './activity-feed.js';
3
- import { markSeenNotifications, perceiveActivity } from './delivery.js';
3
+ import { deriveDeliveryModel, markSeenNotifications } from './delivery.js';
4
4
  import { SquareError } from './model.js';
5
5
  import { openSquare } from './square-file-adapter.js';
6
6
  import { closeOpenSquare } from './open-square.js';
7
7
  import { resolveKnownName } from './decisions.js';
8
- import { readCursor } from './runtime.js';
9
8
  import { recordObservation } from './runtime.js';
10
- function expose(squareState, activity, viewer) {
9
+ function expose(squareState, activity, viewer, delivery) {
11
10
  if (activity.kind === 'read' || activity.actor === undefined)
12
11
  throw new Error(`Cannot expose stored activity ${formatActivityId(activity.index)}`);
13
- const perception = perceiveActivity(squareState, activity, viewer);
12
+ const perception = delivery.perceive(activity, viewer);
14
13
  const result = { id: formatActivityId(activity.index), at: activity.at, kind: activity.kind, actor: activity.actor, mentions: activity.kind === 'say' ? extractMentions(activity.body) : [], ...('body' in activity && activity.body !== undefined ? { body: activity.body } : {}), ...('target' in activity ? { target: activity.target } : {}), ...(activity.kind === 'say' && activity.reply !== undefined ? { reply: formatActivityId(activity.reply) } : {}) };
15
14
  if (perception === 'full' || !('body' in result))
16
15
  return { ...result, perception };
17
16
  const { body: _body, ...withoutBody } = result;
18
17
  return { ...withoutBody, perception };
19
18
  }
20
- export async function catchUp(square, name, options = {}) {
19
+ export async function catchUp(square, name, options = {}, deriveDelivery = deriveDeliveryModel) {
21
20
  const idle = options.idle ?? 0;
22
21
  if (!Number.isFinite(idle) || idle < 0)
23
22
  throw new SquareError('invalid_args', 'Catch idle duration must be a non-negative number');
@@ -26,15 +25,15 @@ export async function catchUp(square, name, options = {}) {
26
25
  const attempt = await square.cell.transact((state, version) => {
27
26
  const at = square.clock();
28
27
  const viewer = resolveKnownName(state, name);
29
- const delta = deliveryDelta(state, viewer);
28
+ const delivery = deriveDelivery(state);
29
+ const delta = deliveryDelta(state, viewer, delivery);
30
30
  const filter = { ...(options.from === undefined ? {} : { participants: [...options.from] }), ...(options.mention === true ? { mention: viewer } : {}) };
31
- const delivered = directedPeerSays(state, delta, viewer).filter((activity) => matchesFeedFilter(activity, filter))
31
+ const delivered = directedPeerSays(state, delta, viewer, delivery).filter((activity) => matchesFeedFilter(activity, filter))
32
32
  .filter((activity, index, activities) => activities.findIndex((candidate) => candidate.index === activity.index) === index)
33
33
  .sort((left, right) => left.index - right.index);
34
- const seenChanged = delivered.reduce((changed, activity) => recordObservation(state, viewer, activity.index, 'seen', at) || changed, false)
35
- || markSeenNotifications(state, viewer, delivered, at);
36
- const cursor = readCursor(state, viewer);
37
- return { ...(seenChanged ? { state } : {}), result: { version, caught: { activities: delivered.map((activity) => expose(state, activity, viewer)), consumedThrough: cursor < 0 ? null : formatActivityId(cursor), idleExpired: false } } };
34
+ const seenChanged = delivered.length === 0 ? false : markSeenNotifications(state, viewer, delivered, at, delivery);
35
+ const cursor = delivery.cursorFor(viewer);
36
+ return { ...(seenChanged ? { state } : {}), result: { version, caught: { activities: delivered.map((activity) => expose(state, activity, viewer, delivery)), consumedThrough: cursor < 0 ? null : formatActivityId(cursor), idleExpired: false } } };
38
37
  });
39
38
  if (attempt.caught.activities.length > 0 || idle === 0)
40
39
  return attempt.caught;
@@ -92,5 +92,6 @@ export declare function renderWatchOutput(history: StoredAct[], publicItems: Pub
92
92
  mention?: string;
93
93
  viewer: string;
94
94
  showCatchHint?: boolean;
95
- squareState: SquareState;
95
+ squareState?: SquareState;
96
+ perceptions?: ReadonlyMap<number, Perception>;
96
97
  }): string;
@@ -456,7 +456,8 @@ export function renderWatchOutput(history, publicItems, roomChanges, opts) {
456
456
  .map((act) => renderAmbientEvent(act, opts.viewer, {
457
457
  actNumber: act.kind === 'say' ? sayNumberFor(history, act) : undefined,
458
458
  mention: opts.mention,
459
- squareState: opts.squareState,
459
+ ...(opts.perceptions?.has(act.index) ? { perception: opts.perceptions.get(act.index) } : {}),
460
+ ...(opts.squareState === undefined ? {} : { squareState: opts.squareState }),
460
461
  }))
461
462
  .filter(Boolean)
462
463
  .join('\n\n');
package/dist/runtime.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { type ActivityId, type Reach } from './square-core.js';
1
+ import { type ActivityId, type LandedAudienceReplay, type Reach } from './square-core.js';
2
2
  import { type StoredAct, type SquareState, type HoldState, type ActivityObservation, type ObservationState, type WatchLease } from './model.js';
3
3
  export declare const WATCH_HEARTBEAT_MS: number;
4
4
  export declare const SLEEP_MS: number;
@@ -36,7 +36,7 @@ export declare function publicActs(acts: StoredAct[]): Array<Extract<StoredAct,
36
36
  }>>;
37
37
  export declare function observationFor(squareState: SquareState, name: string, index: number): ActivityObservation | undefined;
38
38
  export declare function recordObservation(squareState: SquareState, name: string, index: number, state: ObservationState, at?: number, ownerId?: string): boolean;
39
- export declare function readCursor(squareState: SquareState, name: string): number;
39
+ export declare function readCursor(squareState: SquareState, name: string, landed?: LandedAudienceReplay): number;
40
40
  export declare function latestActIndex(acts: StoredAct[]): number;
41
41
  export declare function freshWatchLease(squareState: SquareState, name: string, at?: number): WatchLease | undefined;
42
42
  export declare function watchLease(squareState: SquareState, name: string): WatchLease | undefined;
package/dist/runtime.js CHANGED
@@ -1,4 +1,4 @@
1
- import { audienceIncludes, audienceOf, fold, formatActivityId, landedAudienceIncludes } from './square-core.js';
1
+ import { audienceIncludes, audienceOf, fold, formatActivityId, replayLandedAudiences } from './square-core.js';
2
2
  import { SquareError, findParticipantName, nameKey, sameName, } from './model.js';
3
3
  function parseIntegerEnvValue(name, raw, fallback) {
4
4
  if (raw === undefined)
@@ -162,21 +162,22 @@ export function recordObservation(squareState, name, index, state, at = Date.now
162
162
  ((squareState.runtime.observations ??= {})[key] ??= {})[id] = next;
163
163
  return true;
164
164
  }
165
- export function readCursor(squareState, name) {
166
- const boundary = lastJoinIndex(squareState.acts, name) ?? -1;
165
+ export function readCursor(squareState, name, landed = replayLandedAudiences(squareState.acts)) {
166
+ const recipient = landed.resolveParticipant(name) ?? name;
167
+ const boundary = landed.lastJoinIndex(recipient) ?? -1;
167
168
  let cursor = boundary;
168
169
  for (const act of squareState.acts) {
169
170
  if (act.index <= boundary || act.kind === 'read' || act.actor === undefined)
170
171
  continue;
171
- if (sameName(act.actor, name)) {
172
+ if (sameName(act.actor, recipient)) {
172
173
  cursor = act.index;
173
174
  continue;
174
175
  }
175
- if (!landedAudienceIncludes(squareState.acts, act, name)) {
176
+ if (!landed.includes(act, recipient)) {
176
177
  cursor = act.index;
177
178
  continue;
178
179
  }
179
- if (observationFor(squareState, name, act.index)?.state !== 'seen')
180
+ if (squareState.runtime.observations?.[recipient]?.[formatActivityId(act.index)]?.state !== 'seen')
180
181
  break;
181
182
  cursor = act.index;
182
183
  }
@@ -82,6 +82,15 @@ export interface FoldedSquareState {
82
82
  /** Derived sender blocks; kept outside the artifact schema. */
83
83
  ignored: Map<string, Participant[]>;
84
84
  }
85
+ export interface LandedAudienceReplay {
86
+ readonly participants: readonly Participant[];
87
+ readonly joined: readonly Participant[];
88
+ readonly replayedActivityCount: number;
89
+ recipientsFor(activity: Act): readonly Participant[];
90
+ includes(activity: Act, participant: Participant): boolean;
91
+ lastJoinIndex(participant: Participant): number | undefined;
92
+ resolveParticipant(name: Participant): Participant | undefined;
93
+ }
85
94
  export type ValidationResult = {
86
95
  ok: true;
87
96
  } | {
@@ -120,14 +129,11 @@ export declare function audienceIncludes(audience: Audience, name: string): bool
120
129
  export declare function resolveAudience(audience: Audience, candidateNames: readonly string[]): string[];
121
130
  export declare function activeListeners(state: FoldedSquareState, sender: string): string[];
122
131
  export declare function isIgnored(state: FoldedSquareState, listener: string, sender: string): boolean;
123
- export declare function audienceBefore(acts: readonly Act[], say: Extract<Act, {
124
- kind: 'say';
125
- }>): string[];
126
- /** Whether a peer say was directed to this participant when it landed. */
127
- export declare function landedAudienceIncludes(acts: readonly Act[], activity: Act, viewer: string): boolean;
128
132
  export declare function listeningTo(state: FoldedSquareState, listener: string): string[];
129
133
  export declare function isListening(state: FoldedSquareState, listener: string, sender: string): boolean;
130
134
  export declare function fold(acts: readonly Act[]): FoldedSquareState;
135
+ /** Replay the activity stream once to fix every say's audience at landing. */
136
+ export declare function replayLandedAudiences(acts: readonly Act[]): LandedAudienceReplay;
131
137
  export declare function validate(state: FoldedSquareState, act: Act, options?: SquareValidationOptions): ValidationResult;
132
138
  export declare function perceive(act: Act, viewer: Participant | string): Perception;
133
139
  export {};
@@ -67,11 +67,9 @@ export function activeListeners(state, sender) {
67
67
  export function isIgnored(state, listener, sender) {
68
68
  return (state.ignored.get(nameKey(listener)) ?? []).some((target) => sameName(target, sender));
69
69
  }
70
- export function audienceBefore(acts, say) {
71
- const position = acts.findIndex((act) => act === say || ('index' in act && 'index' in say && act.index === say.index));
72
- const before = fold(position < 0 ? acts : acts.slice(0, position));
70
+ function recipientsAtLanding(before, say) {
73
71
  const audience = audienceOf(say);
74
- const mentionTargets = resolveAudience(audience, before.joined);
72
+ const mentionTargets = resolveAudience(audience, before.participants.filter((participant) => participant.joined).map((participant) => participant.name));
75
73
  const listeners = activeListeners(before, say.actor);
76
74
  const recipients = [];
77
75
  for (const name of [...mentionTargets, ...listeners]) {
@@ -83,12 +81,6 @@ export function audienceBefore(acts, say) {
83
81
  }
84
82
  return recipients;
85
83
  }
86
- /** Whether a peer say was directed to this participant when it landed. */
87
- export function landedAudienceIncludes(acts, activity, viewer) {
88
- if (activity.kind !== 'say' || sameName(activity.actor, viewer))
89
- return false;
90
- return audienceBefore(acts, activity).some((recipient) => sameName(recipient, viewer));
91
- }
92
84
  export function listeningTo(state, listener) {
93
85
  return [...(state.listening.get(nameKey(listener)) ?? [])];
94
86
  }
@@ -142,97 +134,154 @@ function bellRecentAt(state, actor, at, windowMs) {
142
134
  }
143
135
  return latest;
144
136
  }
145
- export function fold(acts) {
137
+ function createFoldAccumulator() {
146
138
  const ordered = [];
147
139
  const byKey = new Map();
148
140
  const hold = { active: false };
149
- const state = {
150
- participants: ordered,
151
- hold,
152
- joined: [],
153
- done: [],
154
- throttleActivityAts: [],
155
- bellSayAtsByActor: new Map(),
156
- listening: new Map(),
157
- ignored: new Map(),
141
+ return {
142
+ byKey,
143
+ ordered,
144
+ state: {
145
+ participants: ordered,
146
+ hold,
147
+ joined: [],
148
+ done: [],
149
+ throttleActivityAts: [],
150
+ bellSayAtsByActor: new Map(),
151
+ listening: new Map(),
152
+ ignored: new Map(),
153
+ },
158
154
  };
159
- for (const act of acts) {
160
- const actor = actorOf(act);
161
- const snapshot = actor === undefined ? undefined : touchParticipant(byKey, ordered, actor);
162
- switch (act.kind) {
163
- case 'join':
164
- if (snapshot !== undefined) {
165
- snapshot.joined = true;
166
- snapshot.done = false;
167
- snapshot.lastActiveAt = act.at ?? snapshot.lastActiveAt;
168
- }
169
- break;
170
- case 'done':
171
- if (snapshot !== undefined) {
172
- snapshot.joined = false;
173
- snapshot.done = true;
174
- snapshot.lastActiveAt = act.at ?? snapshot.lastActiveAt;
175
- state.listening.delete(nameKey(snapshot.name));
176
- }
177
- break;
178
- case 'listen': {
179
- const key = nameKey(act.actor);
180
- const targets = state.listening.get(key) ?? [];
181
- if (!targets.some((target) => sameName(target, act.target)))
182
- targets.push(act.target);
183
- state.listening.set(key, targets);
184
- const ignored = (state.ignored.get(key) ?? []).filter((target) => !sameName(target, act.target));
185
- if (ignored.length === 0)
186
- state.ignored.delete(key);
187
- else
188
- state.ignored.set(key, ignored);
189
- break;
155
+ }
156
+ function applyActivity(accumulator, act) {
157
+ const { state, byKey, ordered } = accumulator;
158
+ const actor = actorOf(act);
159
+ const snapshot = actor === undefined ? undefined : touchParticipant(byKey, ordered, actor);
160
+ switch (act.kind) {
161
+ case 'join':
162
+ if (snapshot !== undefined) {
163
+ snapshot.joined = true;
164
+ snapshot.done = false;
165
+ snapshot.lastActiveAt = act.at ?? snapshot.lastActiveAt;
190
166
  }
191
- case 'ignore': {
192
- const key = nameKey(act.actor);
193
- const targets = (state.listening.get(key) ?? []).filter((target) => !sameName(target, act.target));
194
- if (targets.length === 0)
195
- state.listening.delete(key);
196
- else
197
- state.listening.set(key, targets);
198
- const ignored = state.ignored.get(key) ?? [];
199
- if (!ignored.some((target) => sameName(target, act.target)))
200
- ignored.push(act.target);
201
- state.ignored.set(key, ignored);
202
- break;
167
+ break;
168
+ case 'done':
169
+ if (snapshot !== undefined) {
170
+ snapshot.joined = false;
171
+ snapshot.done = true;
172
+ snapshot.lastActiveAt = act.at ?? snapshot.lastActiveAt;
173
+ state.listening.delete(nameKey(snapshot.name));
203
174
  }
204
- case 'say':
205
- if (snapshot !== undefined) {
206
- snapshot.activityCount += 1;
207
- snapshot.lastActiveAt = act.at ?? snapshot.lastActiveAt;
208
- }
209
- pushThrottleAt(state, act.at);
210
- if (act.reach === 'bell')
211
- pushBellAt(state, act.actor, act.at);
212
- break;
213
- case 'hold':
214
- hold.active = true;
215
- hold.at = act.at;
216
- hold.reason = act.body;
217
- hold.actor = act.actor;
218
- break;
219
- case 'resume':
220
- hold.active = false;
221
- delete hold.at;
222
- delete hold.reason;
223
- delete hold.actor;
224
- break;
225
- case 'read':
226
- if (snapshot !== undefined) {
227
- snapshot.lastReadThrough = Math.max(snapshot.lastReadThrough, act.through);
228
- }
229
- break;
175
+ break;
176
+ case 'listen': {
177
+ const key = nameKey(act.actor);
178
+ const targets = state.listening.get(key) ?? [];
179
+ if (!targets.some((target) => sameName(target, act.target)))
180
+ targets.push(act.target);
181
+ state.listening.set(key, targets);
182
+ const ignored = (state.ignored.get(key) ?? []).filter((target) => !sameName(target, act.target));
183
+ if (ignored.length === 0)
184
+ state.ignored.delete(key);
185
+ else
186
+ state.ignored.set(key, ignored);
187
+ break;
188
+ }
189
+ case 'ignore': {
190
+ const key = nameKey(act.actor);
191
+ const targets = (state.listening.get(key) ?? []).filter((target) => !sameName(target, act.target));
192
+ if (targets.length === 0)
193
+ state.listening.delete(key);
194
+ else
195
+ state.listening.set(key, targets);
196
+ const ignored = state.ignored.get(key) ?? [];
197
+ if (!ignored.some((target) => sameName(target, act.target)))
198
+ ignored.push(act.target);
199
+ state.ignored.set(key, ignored);
200
+ break;
230
201
  }
202
+ case 'say':
203
+ if (snapshot !== undefined) {
204
+ snapshot.activityCount += 1;
205
+ snapshot.lastActiveAt = act.at ?? snapshot.lastActiveAt;
206
+ }
207
+ pushThrottleAt(state, act.at);
208
+ if (act.reach === 'bell')
209
+ pushBellAt(state, act.actor, act.at);
210
+ break;
211
+ case 'hold':
212
+ state.hold.active = true;
213
+ state.hold.at = act.at;
214
+ state.hold.reason = act.body;
215
+ state.hold.actor = act.actor;
216
+ break;
217
+ case 'resume':
218
+ state.hold.active = false;
219
+ delete state.hold.at;
220
+ delete state.hold.reason;
221
+ delete state.hold.actor;
222
+ break;
223
+ case 'read':
224
+ if (snapshot !== undefined) {
225
+ snapshot.lastReadThrough = Math.max(snapshot.lastReadThrough, act.through);
226
+ }
227
+ break;
231
228
  }
229
+ }
230
+ function finishFold(accumulator) {
231
+ const { state, ordered } = accumulator;
232
232
  state.joined = ordered.filter((item) => item.joined).map((item) => item.name);
233
233
  state.done = ordered.filter((item) => item.done).map((item) => item.name);
234
234
  return state;
235
235
  }
236
+ export function fold(acts) {
237
+ const accumulator = createFoldAccumulator();
238
+ for (const act of acts)
239
+ applyActivity(accumulator, act);
240
+ return finishFold(accumulator);
241
+ }
242
+ /** Replay the activity stream once to fix every say's audience at landing. */
243
+ export function replayLandedAudiences(acts) {
244
+ const accumulator = createFoldAccumulator();
245
+ const byActivity = new Map();
246
+ const byIndex = new Map();
247
+ const lastJoinByKey = new Map();
248
+ for (const act of acts) {
249
+ if (act.kind === 'say') {
250
+ const recipients = recipientsAtLanding(accumulator.state, act);
251
+ byActivity.set(act, recipients);
252
+ const index = 'index' in act ? act.index : undefined;
253
+ if (typeof index === 'number')
254
+ byIndex.set(index, recipients);
255
+ }
256
+ if (act.kind === 'join' && 'index' in act && typeof act.index === 'number') {
257
+ lastJoinByKey.set(nameKey(act.actor), act.index);
258
+ }
259
+ applyActivity(accumulator, act);
260
+ }
261
+ const state = finishFold(accumulator);
262
+ const participants = state.participants.map((participant) => participant.name);
263
+ function recipientsFor(activity) {
264
+ const direct = byActivity.get(activity);
265
+ if (direct !== undefined)
266
+ return direct;
267
+ const index = 'index' in activity ? activity.index : undefined;
268
+ return typeof index === 'number' ? byIndex.get(index) ?? [] : [];
269
+ }
270
+ function resolveParticipant(name) {
271
+ return participants.find((participant) => sameName(participant, name));
272
+ }
273
+ return {
274
+ participants,
275
+ joined: state.joined,
276
+ replayedActivityCount: acts.length,
277
+ recipientsFor,
278
+ includes: (activity, participant) => activity.kind === 'say'
279
+ && !sameName(activity.actor, participant)
280
+ && recipientsFor(activity).some((recipient) => sameName(recipient, participant)),
281
+ lastJoinIndex: (participant) => lastJoinByKey.get(nameKey(participant)),
282
+ resolveParticipant,
283
+ };
284
+ }
236
285
  export function validate(state, act, options = {}) {
237
286
  const actor = actorOf(act);
238
287
  const current = actor === undefined ? undefined : currentParticipant(state, actor);
package/dist/views.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { type ActivityId } from './square-core.js';
2
2
  import { coreParticipants, coreStatus } from './decisions.js';
3
- import { type PlannedNotification } from './delivery.js';
3
+ import { type DeliveryModel, type PlannedNotification } from './delivery.js';
4
4
  import { type ActivitiesOptions, type ActivityObservation, type InboxNotification, type PublicAct, type RoomChangeAct, type SquareState, type StoredAct } from './model.js';
5
5
  import type { OpenSquare } from './open-square.js';
6
6
  import type { Activity, HistoryQuery, ParticipantStatus, PerceivedActivity, SquareSnapshot } from './square-facade.js';
@@ -96,7 +96,7 @@ export declare function watchPresentation(square: OpenSquare, name: string): Pro
96
96
  export declare function inboxProjection(square: OpenSquare, name: string, ownerId: string): Promise<InboxProjection>;
97
97
  export declare function streamProjection(square: OpenSquare, cursor: number, recipient?: string): Promise<StreamProjection>;
98
98
  export declare function notificationForAct(square: OpenSquare, actIndex: number): Promise<readonly PlannedNotification[]>;
99
- export declare function pendingDeliveriesFromState(state: SquareState): readonly PendingDeliveryProjection[];
99
+ export declare function pendingDeliveriesFromState(state: SquareState, delivery?: DeliveryModel): readonly PendingDeliveryProjection[];
100
100
  export declare function pendingDeliveries(square: OpenSquare): Promise<readonly PendingDeliveryProjection[]>;
101
101
  export declare function notificationEvidence(square: OpenSquare, recipient: string, actIndex: number): Promise<{
102
102
  readonly delivered: boolean;
package/dist/views.js CHANGED
@@ -1,16 +1,16 @@
1
1
  import { extractMentions, formatActivityId, parseActivityId } from './square-core.js';
2
2
  import { deliveryDelta, directedPeerSays } from './activity-feed.js';
3
3
  import { coreActivities, coreParticipants, coreStatus, resolveKnownName } from './decisions.js';
4
- import { deriveDeliveryModel, isActivitySeen, perceiveActivity, planActNotifications } from './delivery.js';
4
+ import { deriveDeliveryModel, isActivitySeen } from './delivery.js';
5
5
  import { SquareError, nameKey } from './model.js';
6
- import { countSays, currentHold, foldedState, freshWatchLease, inSquareCount, isCurrentlyJoined, observationFor, readCursor, resolveRosterName, rosterNames, watchTerminalStatus } from './runtime.js';
6
+ import { countSays, currentHold, foldedState, freshWatchLease, inSquareCount, isCurrentlyJoined, resolveRosterName, rosterNames, watchTerminalStatus } from './runtime.js';
7
7
  function expose(stored) {
8
8
  if (stored.kind === 'read' || stored.actor === undefined)
9
9
  throw new Error(`Cannot expose stored activity ${formatActivityId(stored.index)}`);
10
10
  return { id: formatActivityId(stored.index), at: stored.at, kind: stored.kind, actor: stored.actor, ...('body' in stored && stored.body !== undefined ? { body: stored.body } : {}), mentions: stored.kind === 'say' ? extractMentions(stored.body) : [], ...('target' in stored ? { target: stored.target } : {}), ...(stored.kind === 'say' && stored.reply !== undefined ? { reply: formatActivityId(stored.reply) } : {}) };
11
11
  }
12
- function exposePerceived(state, stored, viewer) {
13
- const perception = perceiveActivity(state, stored, viewer);
12
+ function exposePerceived(stored, viewer, delivery) {
13
+ const perception = delivery.perceive(stored, viewer);
14
14
  const activity = expose(stored);
15
15
  if (perception === 'full' || activity.body === undefined)
16
16
  return { ...activity, perception };
@@ -36,12 +36,16 @@ function selectHistory(stored, query) {
36
36
  return query.order === 'desc' ? selected.reverse() : selected;
37
37
  }
38
38
  function statuses(square, state) {
39
- return coreStatus(state, square.clock()).participants.filter((participant) => participant.state !== 'not joined').map((participant) => ({ name: participant.name, state: participant.state === 'done' ? 'done' : 'joined', consumedThrough: readCursor(state, participant.name) < 0 ? null : formatActivityId(readCursor(state, participant.name)), watching: participant.presence === 'watching', listening: participant.listening }));
39
+ const delivery = deriveDeliveryModel(state);
40
+ return coreStatus(state, square.clock(), delivery).participants.filter((participant) => participant.state !== 'not joined').map((participant) => {
41
+ const cursor = delivery.cursorFor(participant.name);
42
+ return { name: participant.name, state: participant.state === 'done' ? 'done' : 'joined', consumedThrough: cursor < 0 ? null : formatActivityId(cursor), watching: participant.presence === 'watching', listening: participant.listening };
43
+ });
40
44
  }
41
- function anchors(state) {
45
+ function anchors(state, delivery) {
42
46
  const result = {};
43
- for (const name of rosterNames(state)) {
44
- const activity = state.acts.findLast((candidate) => candidate.index <= readCursor(state, name) && (candidate.kind === 'say' || candidate.kind === 'done'));
47
+ for (const name of delivery.participants()) {
48
+ const activity = state.acts.findLast((candidate) => candidate.index <= delivery.cursorFor(name) && (candidate.kind === 'say' || candidate.kind === 'done'));
45
49
  if (activity !== undefined)
46
50
  result[activity.index] = [...(result[activity.index] ?? []), name];
47
51
  }
@@ -59,30 +63,26 @@ function sayNumbers(state) {
59
63
  return result;
60
64
  }
61
65
  export async function history(square, query = {}) { const { state } = await square.cell.read(); return selectHistory(coreActivities(state, historyOptions(query)), query).map(expose); }
62
- export async function participantHistory(square, name, query = {}) { const { state } = await square.cell.read(); const viewer = resolveKnownName(state, name); const effective = query.all === true || query.limit !== undefined ? query : { ...query, limit: 10 }; return selectHistory(coreActivities(state, historyOptions(effective, viewer)), effective).map((activity) => exposePerceived(state, activity, viewer)); }
66
+ export async function participantHistory(square, name, query = {}) { const { state } = await square.cell.read(); const viewer = resolveKnownName(state, name); const delivery = deriveDeliveryModel(state); const effective = query.all === true || query.limit !== undefined ? query : { ...query, limit: 10 }; return selectHistory(coreActivities(state, historyOptions(effective, viewer), delivery), effective).map((activity) => exposePerceived(activity, viewer, delivery)); }
63
67
  export async function resolveParticipant(square, name) { const { state } = await square.cell.read(); return { name: resolveKnownName(state, name), roster: rosterNames(state) }; }
64
68
  export async function currentParticipant(square, name) { const { state } = await square.cell.read(); const known = resolveRosterName(state, name); return known !== undefined && isCurrentlyJoined(state.acts, known) ? known : undefined; }
65
69
  export async function participants(square) { const { state } = await square.cell.read(); return statuses(square, state); }
66
70
  export async function snapshot(square) { const { state } = await square.cell.read(); const folded = foldedState(state); return { context: [...state.preamble, ...state.warmup].join('\n'), actCount: state.acts.filter((activity) => activity.kind !== 'read').length, hardCap: state.hardCap, ...(state.throttlePerMinute === undefined ? {} : { throttlePerMinute: state.throttlePerMinute }), held: folded.hold.active && folded.hold.actor !== undefined ? { by: folded.hold.actor, ...(folded.hold.reason === undefined ? {} : { reason: folded.hold.reason }) } : null, participants: statuses(square, state), delivered(name, id) { return isActivitySeen(state, name, parseRequiredActivityId(id)); } }; }
67
- export async function activityPresentation(square, name) { const { state } = await square.cell.read(); const known = resolveKnownName(state, name); const delta = deliveryDelta(state, known); const hold = currentHold(state.acts); return { name: known, roster: rosterNames(state), pendingPublic: directedPeerSays(state, delta, known), pendingRoomChanges: [], activities: state.acts, state, participantCount: inSquareCount(state), held: hold.active, ...(hold.reason === undefined ? {} : { holdReason: hold.reason }), ownActivityCount: countSays(state.acts, known), hardCap: state.hardCap }; }
71
+ export async function activityPresentation(square, name) { const { state } = await square.cell.read(); const known = resolveKnownName(state, name); const delivery = deriveDeliveryModel(state); const delta = deliveryDelta(state, known, delivery); const hold = currentHold(state.acts); return { name: known, roster: rosterNames(state), pendingPublic: directedPeerSays(state, delta, known, delivery), pendingRoomChanges: [], activities: state.acts, state, participantCount: inSquareCount(state), held: hold.active, ...(hold.reason === undefined ? {} : { holdReason: hold.reason }), ownActivityCount: countSays(state.acts, known), hardCap: state.hardCap }; }
68
72
  export async function entryPresentation(square, name, lastN = 10) { const { state } = await square.cell.read(); const known = resolveRosterName(state, name) ?? name; const publicActivities = state.acts.filter((activity) => activity.kind === 'say' || activity.kind === 'done'); return { joined: isCurrentlyJoined(state.acts, known), scene: state.warmup.join('\n').trim(), context: state.preamble.join('\n').trim(), joinContext: (state.preamble.at(-1) === '---' ? state.preamble.slice(0, -1) : state.preamble).join('\n').trim(), recentActivities: lastN === null ? publicActivities : publicActivities.slice(-lastN), state, sayNumbers: sayNumbers(state), participantCount: inSquareCount(state) }; }
69
- export async function historyPresentation(square, options) { const { state } = await square.cell.read(); return { activities: coreActivities(state, options).map((activity) => ({ ...activity, perception: options.viewer === undefined ? 'full' : perceiveActivity(state, activity, options.viewer) })), sayNumbers: sayNumbers(state), presenceAnchors: anchors(state), participantCount: inSquareCount(state) }; }
70
- export async function participantsPresentation(square) { const { state } = await square.cell.read(); return coreParticipants(state, square.clock()); }
73
+ export async function historyPresentation(square, options) { const { state } = await square.cell.read(); const delivery = deriveDeliveryModel(state); return { activities: coreActivities(state, options, delivery).map((activity) => ({ ...activity, perception: options.viewer === undefined ? 'full' : delivery.perceive(activity, options.viewer) })), sayNumbers: sayNumbers(state), presenceAnchors: anchors(state, delivery), participantCount: inSquareCount(state) }; }
74
+ export async function participantsPresentation(square) { const { state } = await square.cell.read(); const delivery = deriveDeliveryModel(state); return coreParticipants(state, square.clock(), delivery); }
71
75
  export async function listPresentation(square) { const { state } = await square.cell.read(); return { context: state.preamble, participants: foldedState(state).participants.filter((participant) => participant.joined).sort((left, right) => (right.lastActiveAt ?? -Infinity) - (left.lastActiveAt ?? -Infinity) || left.name.localeCompare(right.name)).map((participant) => participant.name), activities: state.acts.filter((activity) => activity.kind === 'say').length }; }
72
- export async function statusPresentation(square) { const { state } = await square.cell.read(); const status = coreStatus(state, square.clock()); return { state, status, ...(status.latestAct?.kind === 'say' ? { latestActNumber: countSays(state.acts, status.latestAct.actor) } : {}) }; }
76
+ export async function statusPresentation(square) { const { state } = await square.cell.read(); const delivery = deriveDeliveryModel(state); const status = coreStatus(state, square.clock(), delivery); return { state, status, ...(status.latestAct?.kind === 'say' ? { latestActNumber: countSays(state.acts, status.latestAct.actor) } : {}) }; }
73
77
  export async function eventPresentation(square, id) { const { state } = await square.cell.read(); const activity = state.acts.find((candidate) => candidate.index === parseRequiredActivityId(id)); if (activity === undefined)
74
78
  throw new SquareError('invalid_args', `Unknown activity id: ${id}`); return { activity, participantCount: inSquareCount(state), held: currentHold(state.acts).active }; }
75
- export async function watchPresentation(square, name) { const { state } = await square.cell.read(); const known = resolveKnownName(state, name); const now = square.clock(); const terminal = watchTerminalStatus(state, known); return { activities: state.acts, state, participantCount: inSquareCount(state), presence: { participants: coreParticipants(state, now), now }, ...(terminal === undefined ? {} : { terminalStatus: terminal }) }; }
76
- export async function inboxProjection(square, name, ownerId) { const { state } = await square.cell.read(); const known = resolveRosterName(state, name); if (known === undefined || !isCurrentlyJoined(state.acts, known))
77
- return { name, joined: false, notifications: [] }; const lease = freshWatchLease(state, known, square.clock()); return { name: known, joined: true, notifications: deriveDeliveryModel(state).pendingFor(known).map(({ item, route }) => ({ actIndex: item.index, actor: item.actor, at: item.at, route, body: item.body })), ...(lease?.ownerId === ownerId ? { catchLease: lease } : {}) }; }
78
- export async function streamProjection(square, cursor, recipient) { const { state } = await square.cell.read(); return { activities: state.acts.filter((activity) => activity.index > cursor).flatMap((activity) => { if (recipient === undefined)
79
- return [{ activity }]; const notification = planActNotifications(state, activity).find((candidate) => nameKey(candidate.recipient) === nameKey(recipient)); return notification === undefined ? [] : [{ activity, route: notification.route }]; }), cursor: Math.max(cursor, ...state.acts.map((activity) => activity.index)) }; }
80
- export async function notificationForAct(square, actIndex) { const { state } = await square.cell.read(); const activity = state.acts.find((candidate) => candidate.index === actIndex); return activity === undefined ? [] : planActNotifications(state, activity); }
81
- export function pendingDeliveriesFromState(state) {
82
- return [...new Set(state.acts.filter((activity) => activity.kind === 'join').map((activity) => activity.actor))]
83
- .filter((name) => isCurrentlyJoined(state.acts, name))
84
- .map((recipient) => ({ recipient, notifications: deriveDeliveryModel(state).pendingFor(recipient) }));
85
- }
79
+ export async function watchPresentation(square, name) { const { state } = await square.cell.read(); const known = resolveKnownName(state, name); const now = square.clock(); const terminal = watchTerminalStatus(state, known); const delivery = deriveDeliveryModel(state); return { activities: state.acts, state, participantCount: inSquareCount(state), presence: { participants: coreParticipants(state, now, delivery), now }, ...(terminal === undefined ? {} : { terminalStatus: terminal }) }; }
80
+ export async function inboxProjection(square, name, ownerId) { const { state } = await square.cell.read(); const delivery = deriveDeliveryModel(state); const known = delivery.knownParticipant(name); if (known === undefined || !delivery.joinedRecipients().some((recipient) => nameKey(recipient) === nameKey(known)))
81
+ return { name, joined: false, notifications: [] }; const lease = freshWatchLease(state, known, square.clock()); return { name: known, joined: true, notifications: delivery.pendingFor(known).map(({ item, route }) => ({ actIndex: item.index, actor: item.actor, at: item.at, route, body: item.body })), ...(lease?.ownerId === ownerId ? { catchLease: lease } : {}) }; }
82
+ export async function streamProjection(square, cursor, recipient) { const { state } = await square.cell.read(); const delivery = recipient === undefined ? undefined : deriveDeliveryModel(state); return { activities: state.acts.filter((activity) => activity.index > cursor).flatMap((activity) => { if (delivery === undefined || recipient === undefined)
83
+ return [{ activity }]; const notification = delivery.plan(activity).find((candidate) => nameKey(candidate.recipient) === nameKey(recipient)); return notification === undefined ? [] : [{ activity, route: notification.route }]; }), cursor: Math.max(cursor, ...state.acts.map((activity) => activity.index)) }; }
84
+ export async function notificationForAct(square, actIndex) { const { state } = await square.cell.read(); const activity = state.acts.find((candidate) => candidate.index === actIndex); return activity === undefined ? [] : deriveDeliveryModel(state).plan(activity); }
85
+ export function pendingDeliveriesFromState(state, delivery = deriveDeliveryModel(state)) { return delivery.joinedRecipients().map((recipient) => ({ recipient, notifications: delivery.pendingFor(recipient) })); }
86
86
  export async function pendingDeliveries(square) { const { state } = await square.cell.read(); return pendingDeliveriesFromState(state); }
87
- export async function notificationEvidence(square, recipient, actIndex) { const { state } = await square.cell.read(); return { delivered: isActivitySeen(state, recipient, actIndex), observation: observationFor(state, recipient, actIndex) }; }
88
- export async function notificationDelivered(square, recipient, actIndex) { const { state } = await square.cell.read(); return isActivitySeen(state, recipient, actIndex); }
87
+ export async function notificationEvidence(square, recipient, actIndex) { const { state } = await square.cell.read(); const delivery = deriveDeliveryModel(state); const known = delivery.knownParticipant(recipient) ?? recipient; return { delivered: delivery.isSeen(known, actIndex), observation: state.runtime.observations?.[known]?.[formatActivityId(actIndex)] }; }
88
+ export async function notificationDelivered(square, recipient, actIndex) { const { state } = await square.cell.read(); return deriveDeliveryModel(state).isSeen(recipient, actIndex); }
@@ -1,4 +1,5 @@
1
1
  import { type WakeRoute } from './model.js';
2
+ import { type DeliveryModel } from './delivery.js';
2
3
  import { type SquareState } from './model.js';
3
4
  import { type WakeAttempt } from './wake-attempts.js';
4
5
  export interface WakeEvidence {
@@ -14,7 +15,7 @@ export interface WakeEvidenceProjection {
14
15
  }
15
16
  /** Capture the primary wake facts once and derive any number of eligibility decisions from them. */
16
17
  export declare function wakeEvidenceProjection(squarePath: string, now: number, env: NodeJS.ProcessEnv): Promise<WakeEvidenceProjection>;
17
- export declare function wakeEvidenceProjectionFromState(squarePath: string, state: SquareState, now: number, env: NodeJS.ProcessEnv): WakeEvidenceProjection;
18
+ export declare function wakeEvidenceProjectionFromState(squarePath: string, state: SquareState, now: number, env: NodeJS.ProcessEnv, delivery?: DeliveryModel): WakeEvidenceProjection;
18
19
  /** Project every wake decision from the same primary evidence. */
19
20
  export declare function wakeEvidence(squarePath: string, recipient: string, actIndex: number, now: number, env: NodeJS.ProcessEnv): Promise<WakeEvidence>;
20
21
  export declare function wakeIsEligible(evidence: WakeEvidence): boolean;
@@ -1,4 +1,4 @@
1
- import { isActivitySeen } from './delivery.js';
1
+ import { deriveDeliveryModel } from './delivery.js';
2
2
  import { nameKey } from './model.js';
3
3
  import { readPresentedAttentions } from './presented.js';
4
4
  import { canonicalSquarePath, readActiveBindings } from './registry.js';
@@ -11,7 +11,7 @@ import { entryPresentation } from './views.js';
11
11
  function attentionKey(squarePath, recipient, actIndex) {
12
12
  return JSON.stringify([canonicalSquarePath(squarePath), nameKey(recipient), actIndex]);
13
13
  }
14
- function projectionFromState(squarePath, state, now, env) {
14
+ function projectionFromState(squarePath, state, now, env, delivery = deriveDeliveryModel(state)) {
15
15
  const canonicalPath = canonicalSquarePath(squarePath);
16
16
  const owners = new Map();
17
17
  for (const binding of readActiveBindings(now)) {
@@ -52,7 +52,7 @@ function projectionFromState(squarePath, state, now, env) {
52
52
  const presented = [...(presentedByAttention.get(key) ?? [])]
53
53
  .some((ownerId) => recipientOwners.has(ownerId));
54
54
  return {
55
- delivered: isActivitySeen(state, recipient, actIndex),
55
+ delivered: delivery.isSeen(recipient, actIndex),
56
56
  notified: (() => {
57
57
  const observation = observationFor(state, recipient, actIndex);
58
58
  return observation?.state === 'notified'
@@ -80,8 +80,8 @@ export async function wakeEvidenceProjection(squarePath, now, env) {
80
80
  await closeOpenSquare(square);
81
81
  }
82
82
  }
83
- export function wakeEvidenceProjectionFromState(squarePath, state, now, env) {
84
- return projectionFromState(squarePath, state, now, env);
83
+ export function wakeEvidenceProjectionFromState(squarePath, state, now, env, delivery) {
84
+ return projectionFromState(squarePath, state, now, env, delivery);
85
85
  }
86
86
  /** Project every wake decision from the same primary evidence. */
87
87
  export async function wakeEvidence(squarePath, recipient, actIndex, now, env) {
package/dist/watch.d.ts CHANGED
@@ -1,2 +1,3 @@
1
1
  import { type WatchOptions } from './model.js';
2
- export declare function cmdWatch(squarePath: string, name: string, opts: WatchOptions): Promise<void>;
2
+ /** `false` is reserved for a quiet --now; idle completion preserves its existing sweep boundary. */
3
+ export declare function cmdWatch(squarePath: string, name: string, opts: WatchOptions): Promise<boolean | undefined>;
package/dist/watch.js CHANGED
@@ -19,10 +19,15 @@ function watchOutputResult(squarePath, presentation, name, caught, opts = {}) {
19
19
  const delivered = caught.activities.flatMap((activity) => {
20
20
  const index = parseActivityId(activity.id);
21
21
  const stored = index === undefined ? undefined : presentation.activities.find((item) => item.index === index);
22
- return stored === undefined ? [] : [stored];
22
+ return stored === undefined ? [] : [{ activity: stored, perception: activity.perception }];
23
23
  });
24
- const publicItems = delivered.filter((item) => item.kind === 'say' || item.kind === 'done');
25
- const roomChanges = delivered.filter((item) => item.kind === 'join' || item.kind === 'done' || item.kind === 'hold' || item.kind === 'resume');
24
+ const publicItems = delivered
25
+ .map(({ activity }) => activity)
26
+ .filter((item) => item.kind === 'say' || item.kind === 'done');
27
+ const roomChanges = delivered
28
+ .map(({ activity }) => activity)
29
+ .filter((item) => item.kind === 'join' || item.kind === 'done' || item.kind === 'hold' || item.kind === 'resume');
30
+ const perceptions = new Map(delivered.map(({ activity, perception }) => [activity.index, perception]));
26
31
  return {
27
32
  type: 'output',
28
33
  stdout: renderWatchOutput([...presentation.activities], publicItems, roomChanges, {
@@ -30,7 +35,7 @@ function watchOutputResult(squarePath, presentation, name, caught, opts = {}) {
30
35
  squarePath,
31
36
  viewer: name,
32
37
  showCatchHint: !hasAutomaticDeliveryIdentity(),
33
- squareState: presentation.state,
38
+ perceptions,
34
39
  }),
35
40
  ...(opts.status ? { status: opts.status } : {}),
36
41
  };
@@ -118,12 +123,14 @@ async function cmdWatchNow(squarePath, name, opts) {
118
123
  ? watchOutputResult(squarePath, presentation, name, caught, { mention: opts.mention, ...(status ? { status } : {}) })
119
124
  : { type: 'terminal', status: status ?? 'empty-now' };
120
125
  await finishWatchResult(square, squarePath, name, result, undefined);
126
+ return caught.activities.length > 0;
121
127
  }
122
128
  finally {
123
129
  await facade.close();
124
130
  await closeOpenSquare(square);
125
131
  }
126
132
  }
133
+ /** `false` is reserved for a quiet --now; idle completion preserves its existing sweep boundary. */
127
134
  export async function cmdWatch(squarePath, name, opts) {
128
135
  let square;
129
136
  try {
@@ -144,8 +151,7 @@ export async function cmdWatch(squarePath, name, opts) {
144
151
  }
145
152
  if (opts.now) {
146
153
  await closeOpenSquare(square);
147
- await cmdWatchNow(squarePath, name, opts);
148
- return;
154
+ return cmdWatchNow(squarePath, name, opts);
149
155
  }
150
156
  const start = await beginWatch(square, squarePath, name, opts);
151
157
  if (start.type === 'active') {
@@ -44,6 +44,14 @@ export default function squarePiExtension(pi) {
44
44
  });
45
45
  };
46
46
 
47
+ // A transport may keep its promise pending while Pi is shutting down or
48
+ // replacing a session. Never make a lifecycle hook wait for that transport.
49
+ const stopWatcher = () => {
50
+ watcherAbort?.abort();
51
+ watcher = undefined;
52
+ watcherAbort = undefined;
53
+ };
54
+
47
55
  const wake = async (piContext, token, signal) => {
48
56
  while (sessionId !== undefined && token === generation && !signal.aborted) {
49
57
  const deferredRetry = retryAfterChange;
@@ -91,10 +99,7 @@ export default function squarePiExtension(pi) {
91
99
 
92
100
  pi.on('session_start', async (_event, ctx) => {
93
101
  generation += 1;
94
- watcherAbort?.abort();
95
- if (watcher) await watcher;
96
- watcher = undefined;
97
- watcherAbort = undefined;
102
+ stopWatcher();
98
103
  handledPending.clear();
99
104
  retryAfterChange = false;
100
105
  sessionId = ctx.sessionManager.getSessionId();
@@ -130,10 +135,7 @@ export default function squarePiExtension(pi) {
130
135
 
131
136
  pi.on('session_shutdown', async () => {
132
137
  generation += 1;
133
- watcherAbort?.abort();
134
- if (watcher) await watcher;
135
- watcher = undefined;
136
- watcherAbort = undefined;
138
+ stopWatcher();
137
139
  for (const waiter of settledWaiters) waiter.resolve();
138
140
  settledWaiters = [];
139
141
  if (sessionId && sessionCwd) await automaticSessionEnd('pi', sessionId, sessionCwd);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrosheep/square",
3
- "version": "0.3.24",
3
+ "version": "0.3.26",
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": {