@astrosheep/square 0.3.88 → 0.3.89

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/dist/artifact.js +11 -9
  2. package/dist/boundary-presentation.d.ts +3 -2
  3. package/dist/boundary-presentation.js +50 -77
  4. package/dist/cli/context.js +1 -3
  5. package/dist/delivery-deferral.d.ts +28 -0
  6. package/dist/delivery-deferral.js +83 -0
  7. package/dist/delivery-operations.js +8 -6
  8. package/dist/delivery.js +4 -1
  9. package/dist/inbox.js +21 -10
  10. package/dist/mcp.js +0 -3
  11. package/dist/model.d.ts +1 -0
  12. package/dist/operation-presentation.js +0 -4
  13. package/dist/ports.d.ts +11 -3
  14. package/dist/presentation-operations.d.ts +20 -1
  15. package/dist/presentation-operations.js +68 -44
  16. package/dist/sqlite-square.js +35 -13
  17. package/dist/square-domain.d.ts +1 -1
  18. package/dist/square-domain.js +12 -0
  19. package/dist/square-facade.d.ts +8 -2
  20. package/dist/square-migration.d.ts +2 -1
  21. package/dist/square-migration.js +25 -26
  22. package/dist/square-sqlite.d.ts +5 -2
  23. package/dist/square-sqlite.js +79 -6
  24. package/dist/square-stdio.d.ts +1 -1
  25. package/dist/square-stdio.js +30 -17
  26. package/dist/square-tools.d.ts +1 -1
  27. package/dist/square-tools.js +25 -51
  28. package/dist/stdio-server.js +23 -3
  29. package/dist/views.js +0 -2
  30. package/guides/backends.md +26 -13
  31. package/guides/change-waits.md +40 -11
  32. package/guides/claude-native.md +9 -9
  33. package/guides/mcp.md +1 -1
  34. package/guides/migration.md +30 -12
  35. package/guides/operation-cancellation.md +16 -1
  36. package/guides/participant-ownership.md +60 -186
  37. package/guides/pi-tools.md +1 -1
  38. package/guides/square-api.md +72 -9
  39. package/package.json +1 -1
  40. package/plugins/claude/.claude-plugin/plugin.json +1 -1
  41. package/plugins/claude/skills/square/SKILL.md +3 -3
  42. package/plugins/codex/.codex-plugin/plugin.json +1 -1
  43. package/plugins/paseo/README.md +7 -6
  44. package/plugins/paseo/client/markdown.tsx +125 -185
  45. package/plugins/paseo/client/square-message.tsx +9 -25
  46. package/plugins/paseo/client/transform.ts +5 -4
  47. package/plugins/paseo/index.client.tsx +14 -12
  48. package/plugins/paseo/package.json +3 -0
  49. package/plugins/paseo/shared/message.ts +50 -1
  50. package/plugins/pi/square-pi.js +1 -1
  51. package/skills/square/SKILL.md +3 -3
package/dist/artifact.js CHANGED
@@ -121,9 +121,9 @@ const readMethods = [
121
121
  'activity', 'activityHeads', 'activities', 'activityCount', 'contextIndexes', 'sayOrdinals', 'recentSayTimes',
122
122
  'audience', 'directed', 'directedAt', 'directedCount', 'pendingAudience',
123
123
  'receipt', 'receiptFacts', 'receiptAssignments', 'lease', 'participantCounts', 'seat', 'seatsForBody', 'completedSeatsForBody',
124
- 'deliveryEvidence', 'deliveryByToken', 'cancelledThrough',
124
+ 'deliveryEvidence', 'deliveryByToken', 'cancelledThrough', 'deliveryDeferrals',
125
125
  ];
126
- const writeMethods = ['applyLanding', 'markSeen', 'putLease', 'removeLease', 'storeSeat', 'removeSeat', 'updateWakeAddress', 'cancelThrough', 'claimDelivery', 'prepareDelivery', 'completeDelivery', 'observeNativeDelivery'];
126
+ const writeMethods = ['applyLanding', 'markSeen', 'putLease', 'removeLease', 'storeSeat', 'removeSeat', 'updateWakeAddress', 'cancelThrough', 'claimDelivery', 'prepareDelivery', 'completeDelivery', 'observeNativeDelivery', 'putDeliveryDeferral', 'renewDeliveryDeferral', 'releaseDeliveryDeferral'];
127
127
  /** One connection, ordered operations, guarded synchronous transaction capabilities. */
128
128
  export function createArtifactPort(location, initial, externalSignal) {
129
129
  let database;
@@ -161,12 +161,11 @@ export function createArtifactPort(location, initial, externalSignal) {
161
161
  }
162
162
  return database;
163
163
  }
164
- function queue(operation, signal) {
164
+ function queue(operation, operationSignal) {
165
165
  if (inCallback)
166
166
  throw new TypeError('Square artifact operations cannot nest inside a callback.');
167
167
  if (closed)
168
168
  return Promise.reject(new Error('Square artifact is closed'));
169
- const operationSignal = combined(signal);
170
169
  const result = tail.then(() => {
171
170
  abort(operationSignal);
172
171
  return operation(operationSignal);
@@ -229,19 +228,22 @@ export function createArtifactPort(location, initial, externalSignal) {
229
228
  const revision = signal => queue(async (s) => {
230
229
  const db = await connection(s);
231
230
  return inSqliteTransaction(db, false, () => validate(db), (header) => header.revision, s);
232
- }, signal);
231
+ }, combined(signal));
233
232
  return {
234
233
  read: (select, signal) => queue(async (s) => {
235
234
  const { value, version } = await transaction(select, false, s);
236
235
  return { value, version };
237
- }, signal),
238
- transactAs: (authority, change, signal) => queue(s => transaction(change, true, s, authority), signal),
236
+ }, combined(signal)),
237
+ transactAs: (authority, change, signal) => queue(s => transaction(change, true, s, authority), combined(signal)),
238
+ // Keep the acquired connection and authority, not the cancelled work lifetime. A
239
+ // blocked evidence commit/cleanup gets five seconds, and close still cancels it.
240
+ settleAs: (authority, change) => queue(s => transaction(change, true, s, authority), AbortSignal.any([cancel.signal, AbortSignal.timeout(5000)])),
239
241
  revision,
240
242
  async deliveryRevision(signal) {
241
243
  return queue(async (s) => {
242
244
  const db = await connection(s);
243
245
  return inSqliteTransaction(db, false, () => validate(db), (header) => header.deliveryRevision, s);
244
- }, signal);
246
+ }, combined(signal));
245
247
  },
246
248
  async claimDelivery(input, signal) {
247
249
  return (await this.transactAs({ kind: 'participant', seat: input.seat }, (facts) => facts.claimDelivery(input), signal)).value;
@@ -374,7 +376,7 @@ export async function writeSquareFile(location, state, signal) {
374
376
  const revision = createSqliteDriver(db).header().revision;
375
377
  if (revision === Number.MAX_SAFE_INTEGER)
376
378
  throw invalidArtifact('revision cannot advance beyond the safe integer limit.');
377
- for (const table of ['landed_audience', 'attention', 'participants', 'delivery_cancellations', 'delivery_evidence', 'seats', 'catch_leases', 'receipts', 'activities', 'square_meta'])
379
+ for (const table of ['landed_audience', 'attention', 'participants', 'delivery_deferrals', 'delivery_cancellations', 'delivery_evidence', 'seats', 'catch_leases', 'receipts', 'activities', 'square_meta'])
378
380
  db.exec(`DROP TABLE ${table}`);
379
381
  initializeSqlite(db, state, revision + 1);
380
382
  }, signal);
@@ -7,7 +7,8 @@ export declare function renderPendingAtBoundary(inbox: PendingMembership[]): str
7
7
  *
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
- * presentation evidence live, and `presentPending` still re-checks the pending head, join, ownership
11
- * claim and seen CAS. A source that cannot verify its reuse must read the inbox again.
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.
12
+ * A source that cannot verify its reuse must read the inbox again.
12
13
  */
13
14
  export declare function presentPendingAtBoundary<T>(identity: Body, present: (context: string) => T | Promise<T>, lookup?: (identity: Body, env?: NodeJS.ProcessEnv, signal?: AbortSignal) => Promise<PendingMembership[]> | PendingMembership[], env?: NodeJS.ProcessEnv, signal?: AbortSignal): Promise<T | undefined>;
@@ -2,35 +2,11 @@ import { squareIndexPath } from './square-index.js';
2
2
  import { createHash } from 'node:crypto';
3
3
  import { withFileLock } from './file-lock.js';
4
4
  import { createArtifactPort } from './artifact.js';
5
- import { presentPending } from './presentation-operations.js';
5
+ import { preparePendingPresentation } from './presentation-operations.js';
6
6
  import { sessionInbox, projectBoundaryEligibility, pendingAtBoundary, pendingKey } from './inbox.js';
7
7
  import { attentionBodyIsClipped, renderAttentionPreview, renderSquareReminder } from './attention-presentation.js';
8
8
  export { pendingAtBoundary };
9
9
  const CONTEXT_MAX = 1200;
10
- const presentationLocks = new Map();
11
- /** One settled promise loses to an abort so an in-process presentation wait stays bounded too. */
12
- async function awaitWithSignal(promise, signal) {
13
- if (signal === undefined)
14
- return promise;
15
- let onAbort;
16
- try {
17
- await Promise.race([
18
- promise,
19
- new Promise((_, reject) => {
20
- if (signal.aborted) {
21
- reject(signal.reason ?? new Error('Presentation aborted'));
22
- return;
23
- }
24
- onAbort = () => reject(signal.reason ?? new Error('Presentation aborted'));
25
- signal.addEventListener('abort', onAbort, { once: true });
26
- }),
27
- ]);
28
- }
29
- finally {
30
- if (onAbort !== undefined)
31
- signal.removeEventListener('abort', onAbort);
32
- }
33
- }
34
10
  export function renderPendingAtBoundary(inbox) {
35
11
  return renderBoundary(inbox).context;
36
12
  }
@@ -77,70 +53,67 @@ async function presentPendingAtBoundaryUnlocked(identity, present, lookup = sess
77
53
  const pending = await projectBoundaryEligibility(inbox);
78
54
  if (pending.length === 0 || signal?.aborted)
79
55
  return undefined;
80
- const delivered = renderBoundary(pending);
81
- if (delivered.context === '')
82
- return undefined;
83
- let result;
84
- let rendered = false;
85
- const renderOnce = async () => { if (!rendered) {
86
- rendered = true;
87
- result = await present(delivered.context);
88
- } };
89
- for (const entry of delivered.complete) {
90
- let artifact;
91
- try {
92
- artifact = createArtifactPort(entry.membership.location, undefined, signal);
93
- try {
94
- await artifact.read(facts => facts.header(), signal);
95
- }
96
- catch {
97
- continue;
56
+ const artifacts = new Map();
57
+ const claims = [];
58
+ try {
59
+ // Claim the entire candidate batch before its one external effect. No later item needs
60
+ // a new claim (and new eligibility decision) after its bytes have already been rendered.
61
+ for (const entry of renderBoundary(pending).complete) {
62
+ const { location, seat } = entry.membership;
63
+ let artifact = artifacts.get(location);
64
+ if (artifact === undefined) {
65
+ // This boundary owns the connection until all effect evidence/cleanup has settled.
66
+ // Request cancellation travels with pre-effect operations, not that lifetime.
67
+ artifact = createArtifactPort(location);
68
+ artifacts.set(location, artifact);
98
69
  }
99
70
  for (const index of entry.actIndexes) {
100
- const key = pendingKey(entry.membership, index);
101
- const prior = presentationLocks.get(key);
102
- if (prior !== undefined) {
103
- await awaitWithSignal(prior, signal);
104
- if (signal?.aborted)
105
- return undefined;
106
- if (!rendered)
107
- return undefined;
108
- continue;
109
- }
110
- const work = presentPending({ artifact, location: entry.membership.location, participant: entry.membership.seat.participant, activity: index, seat: entry.membership.seat, sink: { present: renderOnce }, markSeen: entry.markSeen, now: Date.now(), signal }).then(async (outcome) => {
111
- // A stale projection can outlive its activity; still surface the bounded preview,
112
- // but there is no artifact observation to commit.
113
- if (!outcome.presented) {
114
- const exists = await artifact.read((facts) => facts.activity(index) !== undefined, signal).catch(() => undefined);
115
- if (exists?.value === false)
116
- await renderOnce();
117
- }
118
- });
119
- presentationLocks.set(key, work);
120
- try {
121
- await work;
122
- }
123
- finally {
124
- presentationLocks.delete(key);
125
- }
126
- if (!rendered)
127
- return undefined;
71
+ const claim = await preparePendingPresentation({ artifact, location, participant: seat.participant,
72
+ activity: index, seat, markSeen: entry.markSeen, signal });
73
+ if (claim !== undefined)
74
+ claims.push({ key: pendingKey(entry.membership, index), claim });
128
75
  }
129
76
  }
130
- finally {
131
- if (artifact !== undefined)
132
- await artifact.close();
77
+ const eligible = new Set();
78
+ for (const { key, claim } of claims)
79
+ if (await claim.eligible())
80
+ eligible.add(key);
81
+ const fresh = renderBoundary(pending.map(membership => ({ ...membership,
82
+ notifications: membership.notifications.filter(note => eligible.has(pendingKey(membership, note.actIndex))),
83
+ })));
84
+ const included = new Set(fresh.complete.flatMap(entry => entry.actIndexes.map(index => pendingKey(entry.membership, index))));
85
+ if (included.size === 0 || signal?.aborted)
86
+ return undefined;
87
+ const rendered = claims.filter(({ key }) => included.has(key));
88
+ let result;
89
+ try {
90
+ result = await present(fresh.context);
133
91
  }
92
+ catch (error) {
93
+ await Promise.all(rendered.map(({ claim }) => claim.release('failed', error instanceof Error ? error.message : String(error))));
94
+ throw error;
95
+ }
96
+ // These are successful sink effects, not fresh delivery attempts. Settle the entire
97
+ // rendered set before closing any connection, even if one member's settlement fails.
98
+ const settlements = await Promise.allSettled(rendered.map(({ claim }) => claim.recordPresented()));
99
+ for (const settlement of settlements)
100
+ if (settlement.status === 'rejected')
101
+ throw settlement.reason;
102
+ return result;
103
+ }
104
+ finally {
105
+ await Promise.all(claims.map(({ claim }) => claim.release()));
106
+ await Promise.all([...artifacts.values()].map(artifact => artifact.close()));
134
107
  }
135
- return result;
136
108
  }
137
109
  /**
138
110
  * Serialize a boundary across hook processes so one stale inbox cannot render twice.
139
111
  *
140
112
  * `lookup` returns the session's memberships for this boundary. It may reuse an observation it has
141
113
  * verified against the current presence binding and artifact revision; the boundary still projects
142
- * presentation evidence live, and `presentPending` still re-checks the pending head, join, ownership
143
- * claim and seen CAS. A source that cannot verify its reuse must read the inbox again.
114
+ * presentation evidence live. Every item is claimed and revalidated before the batch sink;
115
+ * after success, captured-seat commits record only its included items without an eligibility veto.
116
+ * A source that cannot verify its reuse must read the inbox again.
144
117
  */
145
118
  export async function presentPendingAtBoundary(identity, present, lookup = sessionInbox, env = process.env, signal) {
146
119
  const indexPath = squareIndexPath(env);
@@ -160,9 +160,7 @@ function commandIdentity(env) {
160
160
  if (selected === undefined)
161
161
  return {};
162
162
  const body = { channel: selected.channel, session: selected.session };
163
- const wakeAddress = selected.channel === 'codex'
164
- ? { kind: 'codex-queue', address: { threadId: selected.session } }
165
- : paseo === undefined ? undefined : { kind: 'paseo', address: { agentId: paseo.session } };
163
+ const wakeAddress = paseo === undefined ? undefined : { kind: 'paseo', address: { agentId: paseo.session } };
166
164
  return { body, ...(wakeAddress === undefined ? {} : { wakeAddress }) };
167
165
  }
168
166
  /** Adapter lifetime only; each callback uses the actual public resource API. */
@@ -0,0 +1,28 @@
1
+ import { type ActivityMetadata, type ActivityId } from './model.js';
2
+ import type { SeatRef, SquareRead } from './ports.js';
3
+ export type MetadataScalar = null | boolean | number | string;
4
+ export interface MetadataMatch {
5
+ readonly fields: readonly Readonly<{
6
+ path: readonly string[];
7
+ equals: MetadataScalar;
8
+ }>[];
9
+ }
10
+ export interface DeliveryDeferral {
11
+ readonly id: string;
12
+ readonly expiresAt: string;
13
+ }
14
+ export interface DeliveryDeferralFact {
15
+ readonly participant: string;
16
+ readonly generation: ActivityId;
17
+ readonly id: string;
18
+ readonly match: MetadataMatch;
19
+ readonly expiresAt: number;
20
+ }
21
+ export declare const MAX_DELIVERY_DEFERRAL_MS: number;
22
+ export declare function validateMetadataMatch(value: unknown): MetadataMatch;
23
+ export declare function validateDeferralDuration(value: unknown): number;
24
+ export declare function validateDeferralId(value: unknown): string;
25
+ export declare function matchesMetadata(metadata: ActivityMetadata | undefined, match: MetadataMatch): boolean;
26
+ export declare function matchingDeferrals(deferrals: readonly DeliveryDeferralFact[], metadata: ActivityMetadata | undefined, now: number): readonly DeliveryDeferralFact[];
27
+ export declare function deliveryDeferralExpiry(facts: SquareRead, captured: SeatRef, index: number, now: number): number | undefined;
28
+ export declare function earliestMatchingDeferralExpiry(deferrals: readonly DeliveryDeferralFact[], metadata: ActivityMetadata | undefined, now: number): number | undefined;
@@ -0,0 +1,83 @@
1
+ import { SquareError, sameBody } from './model.js';
2
+ export const MAX_DELIVERY_DEFERRAL_MS = 24 * 60 * 60 * 1000;
3
+ export function validateMetadataMatch(value) {
4
+ const fail = () => { throw new SquareError('invalid_args', 'Invalid metadata match.'); };
5
+ const record = (item) => {
6
+ if (typeof item !== 'object' || item === null || Array.isArray(item))
7
+ return false;
8
+ const prototype = Object.getPrototypeOf(item);
9
+ return (prototype === Object.prototype || prototype === null) && Object.getOwnPropertySymbols(item).length === 0
10
+ && Object.getOwnPropertyNames(item).every(key => {
11
+ const descriptor = Object.getOwnPropertyDescriptor(item, key);
12
+ return descriptor.enumerable && 'value' in descriptor;
13
+ });
14
+ };
15
+ const dense = (item) => Array.isArray(item)
16
+ && Object.getOwnPropertySymbols(item).length === 0 && Object.keys(item).length === item.length
17
+ && Object.getOwnPropertyNames(item).length === item.length + 1
18
+ && Array.from({ length: item.length }, (_, index) => Object.getOwnPropertyDescriptor(item, index))
19
+ .every(descriptor => descriptor !== undefined && descriptor.enumerable && 'value' in descriptor);
20
+ if (!record(value) || Object.keys(value).length !== 1 || !Object.hasOwn(value, 'fields'))
21
+ return fail();
22
+ const fields = value.fields;
23
+ if (!dense(fields) || fields.length === 0)
24
+ return fail();
25
+ const paths = new Set();
26
+ const validated = fields.map((field) => {
27
+ if (!record(field) || Object.keys(field).length !== 2
28
+ || !Object.hasOwn(field, 'path') || !Object.hasOwn(field, 'equals'))
29
+ return fail();
30
+ const { path, equals } = field;
31
+ if (!dense(path) || path.length === 0 || !path.every((part) => typeof part === 'string' && part.length > 0))
32
+ return fail();
33
+ if (!(equals === null || typeof equals === 'string' || typeof equals === 'boolean'
34
+ || typeof equals === 'number' && Number.isFinite(equals)))
35
+ return fail();
36
+ const key = JSON.stringify(path);
37
+ if (paths.has(key))
38
+ return fail();
39
+ paths.add(key);
40
+ return Object.freeze({ path: Object.freeze([...path]), equals: equals });
41
+ });
42
+ return Object.freeze({ fields: Object.freeze(validated) });
43
+ }
44
+ export function validateDeferralDuration(value) {
45
+ if (!Number.isSafeInteger(value) || value <= 0)
46
+ throw new SquareError('invalid_args', 'Delivery deferral duration must be a positive safe integer.');
47
+ return Math.min(value, MAX_DELIVERY_DEFERRAL_MS);
48
+ }
49
+ export function validateDeferralId(value) {
50
+ if (typeof value !== 'string' || !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value))
51
+ throw new SquareError('invalid_args', 'Invalid delivery deferral id.');
52
+ return value;
53
+ }
54
+ export function matchesMetadata(metadata, match) {
55
+ if (metadata === undefined)
56
+ return false;
57
+ return match.fields.every(({ path, equals }) => {
58
+ let value = metadata;
59
+ for (const segment of path) {
60
+ if (typeof value !== 'object' || value === null || Array.isArray(value) || !Object.hasOwn(value, segment))
61
+ return false;
62
+ value = value[segment];
63
+ }
64
+ return value === equals && (value === null || typeof value !== 'object');
65
+ });
66
+ }
67
+ export function matchingDeferrals(deferrals, metadata, now) {
68
+ return deferrals.filter((lease) => lease.expiresAt > now && matchesMetadata(metadata, lease.match));
69
+ }
70
+ export function deliveryDeferralExpiry(facts, captured, index, now) {
71
+ const seat = facts.seat(captured.participant);
72
+ if (seat === undefined || seat.generation !== captured.generation || !sameBody(seat.body, captured.body))
73
+ return undefined;
74
+ const leases = facts.deliveryDeferrals(seat.participant, seat.generation);
75
+ if (!leases.some(lease => lease.expiresAt > now))
76
+ return undefined;
77
+ const activity = facts.activity(index);
78
+ return activity?.kind === 'say' ? earliestMatchingDeferralExpiry(leases, activity.metadata, now) : undefined;
79
+ }
80
+ export function earliestMatchingDeferralExpiry(deferrals, metadata, now) {
81
+ const matches = matchingDeferrals(deferrals, metadata, now);
82
+ return matches.length ? Math.min(...matches.map((lease) => lease.expiresAt)) : undefined;
83
+ }
@@ -5,6 +5,7 @@ import { pendingHead, pendingHeads, pendingRecipients, presentationBlocksWake }
5
5
  import { asSeatRef } from './seats.js';
6
6
  import { canonicalPath } from './canonical-path.js';
7
7
  import { freshWatchLease } from './runtime.js';
8
+ import { deliveryDeferralExpiry } from './delivery-deferral.js';
8
9
  import { leaseOwnsNotification } from './delivery.js';
9
10
  function routeFor(location, seat, now) {
10
11
  if (seat.wakeAddress === undefined)
@@ -33,7 +34,8 @@ async function currentness(artifact, request, now) {
33
34
  const caught = head !== undefined && lease !== undefined && leaseOwnsNotification(lease, {
34
35
  actor: request.actor, route: head.route, recipient: request.participant,
35
36
  });
36
- return { current: activityPending && !cancelled && !presented && !caught, activityPending, seatCurrent, cancelled, presented };
37
+ const deferred = deliveryDeferralExpiry(facts, request.seat, index, now) !== undefined;
38
+ return { current: activityPending && !cancelled && !presented && !caught && !deferred, activityPending, seatCurrent, cancelled, presented };
37
39
  });
38
40
  return value;
39
41
  }
@@ -50,14 +52,14 @@ async function attemptWakeWithin(transport, request, timeoutMs, beforeSend) {
50
52
  clearTimeout(timer);
51
53
  }
52
54
  }
53
- async function candidates(artifact, requested) {
55
+ async function candidates(artifact, requested, now = Date.now()) {
54
56
  const { value } = await artifact.read((facts) => {
55
57
  const heads = requested === undefined
56
58
  ? facts.roster().flatMap((participant) => participant.joined ? pendingHeads(facts, participant.name) : [])
57
59
  : pendingRecipients(facts, requested);
58
60
  return heads.flatMap((head) => {
59
61
  const seat = facts.seat(head.recipient);
60
- return seat === undefined ? [] : [{ seat, head }];
62
+ return seat === undefined || deliveryDeferralExpiry(facts, seat, head.index, now) !== undefined ? [] : [{ seat, head }];
61
63
  });
62
64
  });
63
65
  return value;
@@ -84,7 +86,7 @@ export async function deliverPending(input) {
84
86
  : typeof input.activity === 'number' ? input.activity : parseActivityId(input.activity);
85
87
  if (input.activity !== undefined && requested === undefined)
86
88
  return { attempted: 0, accepted: 0, failed: 0, unknown: 0, notCapable: 0 };
87
- const pending = await candidates(input.artifact, requested);
89
+ const pending = await candidates(input.artifact, requested, input.now ?? Date.now());
88
90
  let attempted = 0, accepted = 0, failed = 0, unknown = 0, notCapable = 0;
89
91
  for (const { seat, head } of pending) {
90
92
  const route = routeFor(location, seat, input.now ?? Date.now());
@@ -121,7 +123,7 @@ export async function deliverPending(input) {
121
123
  let suppressed = false;
122
124
  const beforeSend = async () => {
123
125
  try {
124
- final = await currentness(input.artifact, withClaim, Date.now());
126
+ final = await currentness(input.artifact, withClaim, input.now ?? Date.now());
125
127
  suppressed = !final.current;
126
128
  }
127
129
  catch {
@@ -177,7 +179,7 @@ export async function deliverPending(input) {
177
179
  export async function sweepPending(input) {
178
180
  if (input.limit <= 0)
179
181
  return [];
180
- const pending = await candidates(input.artifact);
182
+ const pending = await candidates(input.artifact, undefined, input.now);
181
183
  const selected = [];
182
184
  for (const { head } of pending) {
183
185
  if (input.now - head.at <= input.graceMs)
package/dist/delivery.js CHANGED
@@ -1,12 +1,15 @@
1
1
  import { renderAttentionPreview, renderSquareReminder } from './attention-presentation.js';
2
2
  import { parseActivityId } from './square-core.js';
3
+ import { deliveryDeferralExpiry } from './delivery-deferral.js';
3
4
  import { matchesCatchSelection } from './catch-selection.js';
4
5
  /** Exact stored activity projection for transports that send an awareness reminder. */
5
6
  export function wakePreview(facts, request) {
6
7
  const index = parseActivityId(request.activity);
7
8
  const head = index === undefined ? undefined : pendingHead(facts, request.participant, index);
8
9
  const item = index === undefined ? undefined : facts.activity(index);
9
- if (head === undefined || item?.kind !== 'say')
10
+ const seat = facts.seat(request.participant);
11
+ if (head === undefined || item?.kind !== 'say' || seat === undefined
12
+ || deliveryDeferralExpiry(facts, request.seat, item.index, Date.now()) !== undefined)
10
13
  return undefined;
11
14
  return renderSquareReminder([renderAttentionPreview({
12
15
  squarePath: request.location, recipient: request.participant, actIndex: item.index,
package/dist/inbox.js CHANGED
@@ -5,6 +5,7 @@ import { canonicalPath } from './canonical-path.js';
5
5
  import { formatActivityId, parseActivityId } from './square-core.js';
6
6
  import { freshWatchLease, WATCH_STALE_MS } from './runtime.js';
7
7
  import { pendingHead, pendingNotifications, leaseOwnsNotification } from './delivery.js';
8
+ import { deliveryDeferralExpiry } from './delivery-deferral.js';
8
9
  import { attentionBodyIsClipped, renderAttentionPreview, renderSquareReminder } from './attention-presentation.js';
9
10
  export function pendingKey(membership, actIndex) {
10
11
  return membership.location + '\0' + membership.seat.participant.toLocaleLowerCase() + '\0' + actIndex;
@@ -17,15 +18,25 @@ export function pendingAtBoundary(inbox) {
17
18
  return { ...membership, notifications: membership.notifications.filter((notification) => !leaseOwnsNotification(lease, { ...notification, recipient: membership.seat.participant })) };
18
19
  }).filter((membership) => membership.notifications.length > 0);
19
20
  }
20
- function eligibleNotifications(facts, membership) {
21
+ function eligibleNotifications(facts, membership, now = Date.now()) {
21
22
  const seat = facts.seat(membership.seat.participant);
22
23
  if (seat === undefined || seat.generation !== membership.seat.generation
23
24
  || !sameBody(seat.body, membership.seat.body))
24
- return [];
25
+ return { notifications: [] };
25
26
  const cancelledThrough = facts.cancelledThrough(seat.participant, seat.generation);
26
- return membership.notifications.filter(notification => (cancelledThrough === undefined || notification.actIndex > cancelledThrough)
27
- && !facts.deliveryEvidence(seat.participant, seat.generation, formatActivityId(notification.actIndex))
28
- .some(row => row.kind === 'presentation' && (row.outcome === 'presented' || row.outcome === 'clipped')));
27
+ let expiry = Infinity;
28
+ const notifications = membership.notifications.filter(notification => {
29
+ if (cancelledThrough !== undefined && notification.actIndex <= cancelledThrough
30
+ || facts.deliveryEvidence(seat.participant, seat.generation, formatActivityId(notification.actIndex))
31
+ .some(row => row.kind === 'presentation' && (row.outcome === 'presented' || row.outcome === 'clipped')))
32
+ return false;
33
+ const deferredUntil = deliveryDeferralExpiry(facts, seat, notification.actIndex, now);
34
+ if (deferredUntil === undefined)
35
+ return true;
36
+ expiry = Math.min(expiry, deferredUntil);
37
+ return false;
38
+ });
39
+ return { notifications, ...(expiry === Infinity ? {} : { deliveryDeferralExpiry: expiry }) };
29
40
  }
30
41
  /** Revalidate a supplied batch immediately before the boundary's independent claim/send checks. */
31
42
  export async function projectBoundaryEligibility(inbox) {
@@ -40,8 +51,8 @@ export async function projectBoundaryEligibility(inbox) {
40
51
  const artifact = createArtifactPort(location);
41
52
  try {
42
53
  const { value } = await artifact.read(facts => memberships.flatMap(membership => {
43
- const notifications = eligibleNotifications(facts, membership);
44
- return notifications.length === 0 ? [] : [{ ...membership, notifications }];
54
+ const eligibility = eligibleNotifications(facts, membership);
55
+ return eligibility.notifications.length === 0 ? [] : [{ ...membership, ...eligibility }];
45
56
  }));
46
57
  pending.push(...value);
47
58
  }
@@ -146,7 +157,7 @@ async function projectPending(candidates, cache, signal) {
146
157
  // without a conversation revision and must not force body hydration or self-wake retries.
147
158
  return { cached, pending: cached.value.map(membership => {
148
159
  const catchLease = freshWatchLease(facts, membership.seat.participant);
149
- return { ...membership, notifications: eligibleNotifications(facts, membership),
160
+ return { ...membership, ...eligibleNotifications(facts, membership),
150
161
  ...(catchLease === undefined ? {} : { catchLease }) };
151
162
  }) };
152
163
  }, signal);
@@ -184,7 +195,7 @@ export function nativePendingPreview(facts, input) {
184
195
  if (!head || (lease !== undefined && leaseOwnsNotification(lease, head)))
185
196
  return undefined;
186
197
  const item = facts.activity(input.actIndex);
187
- if (item?.kind !== 'say')
198
+ if (item?.kind !== 'say' || deliveryDeferralExpiry(facts, seat, input.actIndex, input.now) !== undefined)
188
199
  return undefined;
189
200
  return { payload: renderSquareReminder([renderAttentionPreview({ squarePath: input.location, recipient: input.participant, actIndex: item.index,
190
201
  actor: item.actor, route: head.route, body: item.body ?? '(structured activity)' })]), clipped: attentionBodyIsClipped(item.body ?? '(structured activity)') };
@@ -322,7 +333,7 @@ export async function observeSessionPending(identity, suppliedEnv = process.env)
322
333
  return [];
323
334
  // Eligibility may change at lease expiry without any storage write.
324
335
  const leaseExpiry = Math.min(Infinity, ...inbox.flatMap((membership) => membership.catchLease === undefined ? [] : [Math.min(membership.catchLease.expiresAt, membership.catchLease.heartbeatAt + WATCH_STALE_MS + 1)]));
325
- const nextExpiry = leaseExpiry;
336
+ const nextExpiry = Math.min(leaseExpiry, ...inbox.map(membership => membership.deliveryDeferralExpiry ?? Infinity));
326
337
  const duration = Math.min(remaining, Math.max(1, nextExpiry - Date.now()));
327
338
  const cancelled = new AbortController();
328
339
  const waitSignal = AbortSignal.any([signal, cancelled.signal]);
package/dist/mcp.js CHANGED
@@ -44,9 +44,6 @@ export function createSquareMcpServer(options) {
44
44
  : baseEnv;
45
45
  square = await Square.at({
46
46
  ...options, env, body, cwd: claude?.cwd ?? options.cwd,
47
- // A call-local Codex thread uses its native queue even when managed by Paseo.
48
- wakeAddress: claude === undefined && typeof threadId === 'string' && threadId.trim()
49
- ? { kind: 'codex-queue', address: { threadId } } : options.wakeAddress,
50
47
  path: location ?? options.path,
51
48
  participant: participant ?? options.participant,
52
49
  // The server owner is bound to its configured square; overrides close after each call.
package/dist/model.d.ts CHANGED
@@ -119,6 +119,7 @@ export interface LocatedSeat {
119
119
  export interface PendingMembership extends LocatedSeat {
120
120
  readonly notifications: InboxNotification[];
121
121
  readonly catchLease?: WatchLease;
122
+ readonly deliveryDeferralExpiry?: number;
122
123
  }
123
124
  export interface WatchOptions {
124
125
  id?: ActivityId;
@@ -293,10 +293,6 @@ export function renderStatusResult(squarePath, data) {
293
293
  const output = [
294
294
  style('dim', counts),
295
295
  ...(hold === undefined ? [] : ['', hold]), '', style('dim', 'around the square'), ...people, '', style('dim', 'latest'), ...latest,
296
- ...(data.listening === undefined ? [] : [
297
- '', style('dim', 'you listen to'),
298
- ...(data.listening.length === 0 ? [' ○ not turned toward anyone'] : data.listening.map((name) => ` · ${participantIdentity(name)}`)),
299
- ]),
300
296
  ].join('\n');
301
297
  return withPathOutput(squarePath, output, { participantCount: presentation.standingCount, held: presentation.held });
302
298
  }
package/dist/ports.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { Act, ActivityId, Body, HardCap, Seat, StoredAct, WakeAddress, WakeRoute, WatchLease } from './model.js';
2
+ import type { MetadataMatch, DeliveryDeferralFact } from './delivery-deferral.js';
2
3
  export type ActivityIndex = number;
3
4
  export interface SquareHeader {
4
5
  readonly revision: number;
@@ -145,7 +146,7 @@ export type DeliveryClaimResult = {
145
146
  readonly claimToken: string;
146
147
  readonly attemptN: number;
147
148
  } | {
148
- readonly status: 'busy' | 'terminal' | 'stale';
149
+ readonly status: 'busy' | 'terminal' | 'stale' | 'deferred';
149
150
  readonly evidence?: DeliveryEvidence;
150
151
  };
151
152
  export interface DeliveryPreparation {
@@ -211,6 +212,7 @@ export interface SquareRead {
211
212
  receiptFacts(requestedName: string, afterIndex: number): ReceiptFacts;
212
213
  receiptAssignments(): readonly ReceiptAssignment[];
213
214
  lease(requestedName: string): WatchLease | undefined;
215
+ deliveryDeferrals(participant: string, generation: ActivityId): readonly DeliveryDeferralFact[];
214
216
  }
215
217
  export interface SquareWrite extends SquareRead {
216
218
  land(activity: Act & {
@@ -229,6 +231,9 @@ export interface SquareWrite extends SquareRead {
229
231
  markSeen(requestedName: string, indexes: readonly ActivityIndex[], at: number): number;
230
232
  putLease(requestedName: string, lease: WatchLease, generation?: ActivityId): boolean;
231
233
  removeLease(requestedName: string, expectedLeaseId: string, generation?: ActivityId): boolean;
234
+ putDeliveryDeferral(seat: SeatRef, id: string, match: MetadataMatch, expiresAt: number, now: number): boolean;
235
+ renewDeliveryDeferral(seat: SeatRef, id: string, expiresAt: number, now: number): boolean;
236
+ releaseDeliveryDeferral(seat: SeatRef, id: string, now: number): boolean;
232
237
  }
233
238
  export interface SquareReadResult<R> {
234
239
  readonly value: R;
@@ -242,6 +247,9 @@ export interface SquareArtifactPort {
242
247
  read<R>(select: (facts: SquareRead) => R, signal?: AbortSignal): Promise<SquareReadResult<R>>;
243
248
  /** Participant/domain writes must carry an explicit authority; no body-less mutation path exists. */
244
249
  transactAs<R>(authority: ArtifactWriteAuthority, change: (facts: SquareWrite) => R, signal?: AbortSignal): Promise<SquareCommitResult<R>>;
250
+ /** Bounded cleanup/evidence settlement on this same connection after custody was acquired.
251
+ * Independent of request/lifetime cancellation; explicit close and authority fences still apply. */
252
+ settleAs<R>(authority: ArtifactWriteAuthority, change: (facts: SquareWrite) => R): Promise<SquareCommitResult<R>>;
245
253
  revision(signal?: AbortSignal): Promise<number>;
246
254
  deliveryRevision(signal?: AbortSignal): Promise<number>;
247
255
  claimDelivery(input: DeliveryClaimInput, signal?: AbortSignal): Promise<DeliveryClaimResult>;
@@ -349,10 +357,10 @@ export interface PresentPendingInput {
349
357
  readonly generation?: ActivityId;
350
358
  readonly now?: number;
351
359
  readonly timeoutMs?: number;
352
- /** One hook deadline propagated into artifact reads/transitions so busy retries stay bounded. */
360
+ /** Pre-effect work control only; successful sink evidence settles independently, with a bounded lifetime. */
353
361
  readonly signal?: AbortSignal;
354
362
  /** Boundary previews that were clipped remain pending for a later full presentation. */
355
363
  readonly markSeen?: boolean;
356
- /** Receiver-specific fences checked inside the artifact observation transaction. */
364
+ /** Receiver-specific pre-effect eligibility, never a veto of an already successful sink. */
357
365
  readonly current?: (facts: SquareRead) => boolean;
358
366
  }
@@ -1,3 +1,22 @@
1
1
  import type { PresentationResult, PresentPendingInput } from './ports.js';
2
- /** Presentation is external; its claim and receipt/evidence commit are generation-fenced in the artifact. */
2
+ /** Prepare custody before any external effect. A batch prepares every included item first. */
3
+ export declare function preparePendingPresentation(input: Omit<PresentPendingInput, 'sink'>): Promise<{
4
+ activity: {
5
+ kind: "say";
6
+ actor: import("./square-core.js").Participant;
7
+ at?: number;
8
+ body?: string;
9
+ metadata?: import("./model.js").ActivityMetadata;
10
+ mentions?: readonly import("./square-core.js").Participant[];
11
+ reach?: import("./square-core.js").Reach;
12
+ reply?: number;
13
+ } & {
14
+ index: number;
15
+ at: number;
16
+ };
17
+ eligible(): Promise<boolean>;
18
+ release(outcome?: "released" | "failed", message?: string): Promise<void>;
19
+ recordPresented(): Promise<PresentationResult>;
20
+ } | undefined>;
21
+ /** Eligibility precedes the sink; only captured ownership fences its truthful completion. */
3
22
  export declare function presentPending(input: PresentPendingInput): Promise<PresentationResult>;