@astrosheep/square 0.3.58 → 0.3.62

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 (91) hide show
  1. package/claude-plugin/.claude-plugin/plugin.json +1 -1
  2. package/claude-plugin/skills/square/SKILL.md +5 -7
  3. package/codex-plugin/.codex-plugin/plugin.json +1 -1
  4. package/dist/activity.d.ts +6 -2
  5. package/dist/activity.js +170 -133
  6. package/dist/artifact.d.ts +4 -0
  7. package/dist/artifact.js +43 -14
  8. package/dist/attention-presentation.d.ts +1 -0
  9. package/dist/attention-presentation.js +21 -19
  10. package/dist/automatic-session.js +16 -26
  11. package/dist/boundary-presentation.d.ts +1 -2
  12. package/dist/boundary-presentation.js +4 -19
  13. package/dist/catch-decisions.js +1 -1
  14. package/dist/cli/context.d.ts +2 -0
  15. package/dist/cli/context.js +17 -33
  16. package/dist/cli/harness-command.js +1 -1
  17. package/dist/cli/mcp-server-command.d.ts +2 -0
  18. package/dist/cli/mcp-server-command.js +13 -0
  19. package/dist/cli/observation-commands.js +132 -64
  20. package/dist/cli/program.js +36 -9
  21. package/dist/cli/registry.js +2 -0
  22. package/dist/cli/square-commands.d.ts +1 -1
  23. package/dist/cli/square-commands.js +103 -133
  24. package/dist/decisions.d.ts +4 -0
  25. package/dist/decisions.js +82 -34
  26. package/dist/delivery-operations.d.ts +1 -2
  27. package/dist/delivery-operations.js +1 -4
  28. package/dist/file-changes.d.ts +12 -0
  29. package/dist/file-changes.js +235 -0
  30. package/dist/help.js +4 -8
  31. package/dist/host-ledger-file-adapter.d.ts +15 -13
  32. package/dist/host-ledger-file-adapter.js +131 -79
  33. package/dist/host-ledger-root.d.ts +2 -0
  34. package/dist/host-ledger-root.js +9 -0
  35. package/dist/host-ledger.d.ts +4 -24
  36. package/dist/inbox.d.ts +10 -5
  37. package/dist/inbox.js +152 -67
  38. package/dist/index.d.ts +3 -1
  39. package/dist/index.js +1 -0
  40. package/dist/list.js +12 -9
  41. package/dist/mcp.d.ts +7 -0
  42. package/dist/mcp.js +122 -0
  43. package/dist/mention-parse.d.ts +8 -0
  44. package/dist/mention-parse.js +107 -0
  45. package/dist/model.d.ts +5 -0
  46. package/dist/model.js +4 -1
  47. package/dist/notifications.d.ts +1 -0
  48. package/dist/notifications.js +25 -20
  49. package/dist/participant-host.js +2 -2
  50. package/dist/ports.d.ts +2 -9
  51. package/dist/presentation.d.ts +19 -1
  52. package/dist/presentation.js +111 -73
  53. package/dist/presented.js +2 -1
  54. package/dist/registry.d.ts +8 -4
  55. package/dist/registry.js +43 -76
  56. package/dist/routes.d.ts +19 -2
  57. package/dist/routes.js +48 -5
  58. package/dist/runtime.d.ts +10 -0
  59. package/dist/runtime.js +39 -0
  60. package/dist/square-actions.d.ts +26 -13
  61. package/dist/square-actions.js +203 -87
  62. package/dist/square-application.d.ts +58 -0
  63. package/dist/square-application.js +183 -0
  64. package/dist/square-facade.d.ts +24 -9
  65. package/dist/square-file-adapter.d.ts +5 -12
  66. package/dist/square-file-adapter.js +12 -64
  67. package/dist/square-projections.d.ts +3 -2
  68. package/dist/square-projections.js +12 -3
  69. package/dist/square-storage.js +27 -34
  70. package/dist/square-wiring.d.ts +12 -7
  71. package/dist/square-wiring.js +36 -32
  72. package/dist/state-cell.d.ts +1 -1
  73. package/dist/stream.js +31 -27
  74. package/dist/tty-style.d.ts +7 -0
  75. package/dist/tty-style.js +13 -0
  76. package/dist/views.d.ts +3 -3
  77. package/dist/views.js +10 -9
  78. package/dist/wake-attempts.js +2 -1
  79. package/dist/wake-evidence.js +2 -8
  80. package/dist/wakes.d.ts +1 -0
  81. package/dist/wakes.js +5 -5
  82. package/dist/watch.d.ts +6 -1
  83. package/dist/watch.js +43 -28
  84. package/extensions/square-pi.js +107 -109
  85. package/guides/application-boundary.md +32 -0
  86. package/guides/change-waits.md +28 -0
  87. package/guides/host-ledger.md +9 -0
  88. package/guides/mcp.md +9 -0
  89. package/guides/operation-cancellation.md +17 -0
  90. package/package.json +10 -4
  91. package/skills/square/SKILL.md +5 -7
@@ -1,18 +1,21 @@
1
1
  import { formatActivityId, parseActivityId } from './square-core.js';
2
- import { coreDone, coreHold, coreIgnore, coreListen, coreListening, coreResume, decideAct, decideImplicitJoin, decideJoin, resolveKnownName } from './decisions.js';
2
+ import { coreDone, coreHold, coreIgnore, coreListen, coreListening, coreResume, decideAct, decideImplicitJoin, decideJoin, resolveKnownName, validateDoneBody } from './decisions.js';
3
3
  import { isSquareError, nameKey, SquareError, validateName } from './model.js';
4
4
  import { participantIdentity } from './participant-identity.js';
5
5
  import { deliverPending } from './delivery-operations.js';
6
6
  import { decideCatch } from './catch-decisions.js';
7
- import { claimSessionParticipant, claimSessionTakeover, readParticipantOwner, withOwnershipClaimLock } from './registry.js';
7
+ import { claimSessionParticipant, claimSessionTakeover, releaseSessionParticipantClaim, readParticipantOwner, withOwnershipClaimLock } from './registry.js';
8
8
  import { assertLiveOwner, ensureLocalPresence, identityRouteDraft, processIdentity, publishIdentityRoute, retireIdentityRoute } from './participant-host.js';
9
- import { applyWakeRouteToState, dropParticipantWakeRoutesFromState, dropSessionWakeRoutesFromState, sessionCanEndParticipant } from './routes.js';
9
+ import { applyWakeRouteToState, dropEndedSessionWakeRoutesFromState, dropParticipantWakeRoutesFromState, dropSessionWakeRoutesFromState, sessionCanEndParticipant } from './routes.js';
10
+ function throwIfAborted(control) { if (control?.signal?.aborted)
11
+ throw control.signal.reason ?? new Error('Operation aborted'); }
10
12
  function exposeCaught(activity, perception) {
11
13
  if (activity.kind === 'read' || activity.actor === undefined)
12
14
  throw new Error(`Cannot expose stored activity ${formatActivityId(activity.index)}`);
13
15
  const result = {
14
16
  id: formatActivityId(activity.index), at: activity.at, kind: activity.kind, actor: activity.actor,
15
17
  mentions: activity.kind === 'say' ? activity.mentions ?? [] : [],
18
+ ...(activity.kind === 'say' && activity.reach !== undefined ? { reach: activity.reach } : {}),
16
19
  ...('body' in activity && activity.body !== undefined ? { body: activity.body } : {}),
17
20
  ...('target' in activity ? { target: activity.target } : {}),
18
21
  ...(activity.kind === 'say' && activity.reply !== undefined ? { reply: formatActivityId(activity.reply) } : {}),
@@ -22,19 +25,21 @@ function exposeCaught(activity, perception) {
22
25
  const { body: _body, ...withoutBody } = result;
23
26
  return { ...withoutBody, perception };
24
27
  }
25
- export async function catchUp(square, name, options = {}, project) {
28
+ export async function catchUp(square, name, options = {}, project, control) {
26
29
  const idle = options.idle ?? 0;
27
30
  if (!Number.isFinite(idle) || idle < 0)
28
31
  throw new SquareError('invalid_args', 'Catch idle duration must be a non-negative number');
29
32
  const deadline = Date.now() + idle;
33
+ if (control?.signal?.aborted)
34
+ throw control.signal.reason ?? new Error('Operation aborted');
30
35
  while (true) {
31
36
  if (!await assertLiveOwner(square, name)) {
32
- throw new SquareError('already_joined', `${participantIdentity(name)} is already bound to another session`);
37
+ throw new SquareError('already_joined', `✕ ${participantIdentity(name)} already stands here — another session holds the name`);
33
38
  }
34
39
  const attempt = await square.artifact.transact((state, version) => {
35
40
  const decision = decideCatch(state, name, options, square.clock(), project);
36
41
  return { ...(decision.changed ? { state } : {}), result: { version, decision } };
37
- });
42
+ }, control?.signal);
38
43
  await ensureLocalPresence(square, name);
39
44
  await publishIdentityRoute(square, name);
40
45
  if (attempt.decision.delivered.length > 0 || idle === 0) {
@@ -53,10 +58,10 @@ export async function catchUp(square, name, options = {}, project) {
53
58
  // unexpired, so only a real external change can move the artifact version. Establish the
54
59
  // wait baseline after those self-side effects; if the version moved anyway (own refresh or
55
60
  // a racing external commit), re-snapshot instead of waiting on a stale baseline.
56
- const baseline = (await square.artifact.read()).version;
61
+ const baseline = (await square.artifact.read(control?.signal)).version;
57
62
  if (baseline !== attempt.version)
58
63
  continue;
59
- if (!await square.artifact.changed(baseline, remaining)) {
64
+ if (!await square.artifact.changed(baseline, remaining, control?.signal)) {
60
65
  return { activities: [], consumedThrough: attempt.decision.consumedThrough, idleExpired: true, remaining: 0 };
61
66
  }
62
67
  }
@@ -84,6 +89,7 @@ function exposeActivity(stored) {
84
89
  id: formatActivityId(stored.index), at: stored.at, kind: stored.kind, actor: stored.actor,
85
90
  ...('body' in stored && stored.body !== undefined ? { body: stored.body } : {}),
86
91
  mentions: stored.kind === 'say' ? stored.mentions ?? [] : [],
92
+ ...(stored.kind === 'say' && stored.reach !== undefined ? { reach: stored.reach } : {}),
87
93
  ...('target' in stored ? { target: stored.target } : {}),
88
94
  ...(stored.kind === 'say' && stored.reply !== undefined ? { reply: formatActivityId(stored.reply) } : {}),
89
95
  };
@@ -94,10 +100,11 @@ function parseRequiredActivityId(id) {
94
100
  throw new SquareError('invalid_args', `Invalid activity id: ${id}`);
95
101
  return index;
96
102
  }
97
- export async function join(square, name) {
103
+ export async function join(square, name, control) {
98
104
  // Rejected validation must not perform an ownership claim.
105
+ throwIfAborted(control);
99
106
  validateName(name);
100
- const preview = await square.artifact.read();
107
+ const preview = await square.artifact.read(control?.signal);
101
108
  const previewDecision = decideJoin(preview.state, name, square.clock());
102
109
  if (previewDecision.joinAct === undefined) {
103
110
  // An active artifact participant is idempotent only for its current session.
@@ -111,14 +118,6 @@ export async function join(square, name) {
111
118
  // visible yet; concurrent callers must still serialize through CAS.
112
119
  }
113
120
  else if (owner.sessionId !== identity.session) {
114
- const userPresence = await square.hostLedger.listPresence({
115
- location: square.location,
116
- participant: previewDecision.joinedName,
117
- scopes: ['user'],
118
- });
119
- if (userPresence.some((binding) => binding.session === owner.sessionId)) {
120
- return { name: previewDecision.joinedName, activity: null };
121
- }
122
121
  // Continue into the ownership claim below; it will produce the stable
123
122
  // already_joined error without mutating the artifact.
124
123
  }
@@ -131,32 +130,44 @@ export async function join(square, name) {
131
130
  }
132
131
  }
133
132
  let epoch;
133
+ let ownershipClaim;
134
134
  if (square.hostLedger !== undefined && square.location !== undefined && square.location !== 'memory') {
135
- const claim = await claimSessionParticipant(square.location, name, square.env ?? process.env);
135
+ const claim = await claimSessionParticipant(square.location, name, square.env ?? process.env, control?.signal);
136
+ ownershipClaim = claim;
136
137
  epoch = claim?.epoch;
137
138
  }
138
- const now = square.clock();
139
- const route = await identityRouteDraft(square, name);
140
- const committed = await square.artifact.transact((state) => {
141
- const decision = decideJoin(state, name, now);
142
- if (decision.joinAct === undefined) {
143
- return { result: { name: decision.joinedName, stored: null } };
144
- }
145
- if (square.location !== undefined && square.location !== 'memory')
146
- dropParticipantWakeRoutesFromState(state, square.location, decision.joinedName);
147
- if (route !== undefined)
148
- applyWakeRouteToState(state, route, now);
149
- return { state, result: { name: decision.joinedName, stored: committedActivity(storeActs(state, [decision.joinAct]), 'join') } };
150
- });
139
+ let committed;
140
+ try {
141
+ const now = square.clock();
142
+ const route = await identityRouteDraft(square, name);
143
+ throwIfAborted(control);
144
+ committed = await square.artifact.transact((state) => {
145
+ const decision = decideJoin(state, name, now);
146
+ if (decision.joinAct === undefined) {
147
+ return { result: { name: decision.joinedName, stored: null } };
148
+ }
149
+ if (square.location !== undefined && square.location !== 'memory')
150
+ dropParticipantWakeRoutesFromState(state, square.location, decision.joinedName);
151
+ if (route !== undefined)
152
+ applyWakeRouteToState(state, route, now);
153
+ return { state, result: { name: decision.joinedName, stored: committedActivity(storeActs(state, [decision.joinAct]), 'join') } };
154
+ }, control?.signal);
155
+ }
156
+ catch (error) {
157
+ await releaseSessionParticipantClaim(square.location, name, square.env ?? process.env, ownershipClaim).catch(() => undefined);
158
+ throw error;
159
+ }
151
160
  await ensureLocalPresence(square, committed.name, epoch);
152
161
  await publishIdentityRoute(square, committed.name, epoch);
153
162
  return { name: committed.name, activity: committed.stored === null ? null : exposeActivity(committed.stored) };
154
163
  }
155
164
  /** End the standing participant and immediately let the caller reclaim the name. */
156
- export async function takeover(square, name, _oldSessionIds = []) {
165
+ export async function takeover(square, name, _oldSessionIds = [], control) {
157
166
  // Rejected validation must not perform an ownership claim.
167
+ throwIfAborted(control);
158
168
  validateName(name);
159
169
  const commitLifecycle = async () => {
170
+ throwIfAborted(control);
160
171
  const now = square.clock();
161
172
  const committed = await square.artifact.transact((state) => {
162
173
  const joinedName = resolveKnownName(state, name);
@@ -168,7 +179,7 @@ export async function takeover(square, name, _oldSessionIds = []) {
168
179
  const storedJoin = committedActivity(storeActs(state, [decision.joinAct]), 'join');
169
180
  state.routes = (state.routes ?? []).filter((route) => nameKey(route.participant) !== nameKey(joinedName));
170
181
  return { state, result: { name: joinedName, stored: [storedDone, storedJoin] } };
171
- });
182
+ }, control?.signal);
172
183
  return committed;
173
184
  };
174
185
  if (square.hostLedger !== undefined && square.location !== undefined && square.location !== 'memory') {
@@ -176,23 +187,25 @@ export async function takeover(square, name, _oldSessionIds = []) {
176
187
  // Gate the ownership claim on the artifact's authoritative state: a takeover that cannot
177
188
  // commit its lifecycle (a name that never joined, or a standing participant that is no
178
189
  // longer joined) must not claim or remove presence. The refusals mirror the transaction.
179
- const { state } = await square.artifact.read();
190
+ const { state } = await square.artifact.read(control?.signal);
180
191
  const joinedName = resolveKnownName(state, name); // invalid_args when the name never joined
181
192
  coreDone(state, joinedName, '', square.clock()); // already_done/not_joined when not standing
182
193
  const owner = await readParticipantOwner(square.location, name, env);
194
+ throwIfAborted(control);
183
195
  // The claim and the artifact lifecycle are one fenced critical section: a losing or refused
184
196
  // takeover never mutates the winner, and no second takeover can interleave mid-commit.
185
197
  const outcome = await claimSessionTakeover(square.location, name, env, {
186
198
  expectedEpoch: owner?.epoch ?? 0,
187
199
  expectedSession: owner?.sessionId ?? '',
188
200
  }, async (claim) => {
201
+ throwIfAborted(control);
189
202
  const committed = await commitLifecycle();
190
203
  await ensureLocalPresence(square, committed.name, claim.epoch);
191
204
  await publishIdentityRoute(square, committed.name, claim.epoch);
192
205
  return committed;
193
- });
206
+ }, control?.signal);
194
207
  if (outcome.status === 'busy')
195
- throw new SquareError('already_joined', `${participantIdentity(name)} is already bound to another session`);
208
+ throw new SquareError('already_joined', `✕ ${participantIdentity(name)} already stands here — another session holds the name`);
196
209
  return { name: outcome.result.name, activities: outcome.result.stored.map(exposeActivity), epoch: outcome.epoch };
197
210
  }
198
211
  const committed = await commitLifecycle();
@@ -200,9 +213,11 @@ export async function takeover(square, name, _oldSessionIds = []) {
200
213
  await publishIdentityRoute(square, committed.name);
201
214
  return { name: committed.name, activities: committed.stored.map(exposeActivity) };
202
215
  }
203
- export async function implicitJoin(square, name) {
216
+ export async function implicitJoin(square, name, control) {
217
+ throwIfAborted(control);
204
218
  const now = square.clock();
205
219
  const route = await identityRouteDraft(square, name);
220
+ throwIfAborted(control);
206
221
  const committed = await square.artifact.transact((state) => {
207
222
  const decision = decideImplicitJoin(state, name, now);
208
223
  if (decision.state === 'done') {
@@ -217,7 +232,7 @@ export async function implicitJoin(square, name) {
217
232
  if (route !== undefined)
218
233
  applyWakeRouteToState(state, { ...route, participant: decision.joinedName }, now);
219
234
  return { state, result: { name: decision.joinedName, state: decision.state, stored: committedActivity(storeActs(state, [decision.joinAct]), 'join') } };
220
- });
235
+ }, control?.signal);
221
236
  await ensureLocalPresence(square, committed.name);
222
237
  if (committed.state === 'done')
223
238
  await retireIdentityRoute(square, committed.name);
@@ -225,31 +240,52 @@ export async function implicitJoin(square, name) {
225
240
  await publishIdentityRoute(square, committed.name);
226
241
  return { name: committed.name, state: committed.state, activity: committed.stored === null ? null : exposeActivity(committed.stored) };
227
242
  }
228
- export async function express(square, name, body, options = {}) {
243
+ /** Only admission failures can advertise a safe retry; storage and post-commit
244
+ * failures must not be mistaken for a rejected send merely by their error code. */
245
+ function rejectUnsentActivity(operation) {
246
+ try {
247
+ return operation();
248
+ }
249
+ catch (error) {
250
+ if (isSquareError(error))
251
+ error.facts = { ...error.facts, activityUnsent: true };
252
+ throw error;
253
+ }
254
+ }
255
+ export async function express(square, name, body, options = {}, control) {
256
+ if (control?.signal?.aborted)
257
+ throw control.signal.reason ?? new Error('Operation aborted');
229
258
  if (!await assertLiveOwner(square, name)) {
230
- throw new SquareError('already_joined', `${participantIdentity(name)} is already bound to another session`);
259
+ throw new SquareError('already_joined', `✕ ${participantIdentity(name)} already stands here — another session holds the name`, { activityUnsent: true });
231
260
  }
261
+ throwIfAborted(control);
232
262
  const now = square.clock();
233
- const reply = options.reply === undefined ? undefined : parseRequiredActivityId(options.reply);
263
+ const reply = rejectUnsentActivity(() => options.reply === undefined ? undefined : parseRequiredActivityId(options.reply));
234
264
  const committed = await square.artifact.transact((state) => {
235
- const decision = decideAct(state, { name, body, force: options.force ?? false, now, mentions: options.mentions, ...(options.reach === undefined ? {} : { reach: options.reach }), ...(reply === undefined ? {} : { reply }) });
236
- if (decision.type === 'blocked') {
237
- const pending = decision.activitySummaries.reduce((count, summary) => count + summary.count, 0) + decision.unreadRoomChanges.length;
238
- throw new SquareError('behind', `${participantIdentity(name)} has pending activity`, { pending });
239
- }
240
- if (decision.type === 'held') {
241
- const holder = state.acts.filter((activity) => activity.kind === 'hold').at(-1)?.actor;
242
- throw new SquareError('held', 'The square is held', holder === undefined ? undefined : { holder });
243
- }
244
- if (decision.type === 'capped')
245
- throw new SquareError('capped', `${participantIdentity(name)} reached the activity cap`);
246
- if (decision.type === 'throttled')
247
- throw new SquareError('throttled', `${name} is throttled`, { retryAfterMs: decision.delayMs });
248
- if (decision.type === 'bell_quota')
249
- throw new SquareError('bell_quota', `${participantIdentity(name)} cannot ring the bell yet`, { retryAfterMs: Math.max(1, decision.nextAt - now) });
265
+ const decision = rejectUnsentActivity(() => {
266
+ const decision = decideAct(state, { name, body, force: options.force ?? false, now, mentions: options.mentions, ...(options.reach === undefined ? {} : { reach: options.reach }), ...(options.noMention === undefined ? {} : { noMention: options.noMention }), ...(reply === undefined ? {} : { reply }) });
267
+ if (decision.type === 'blocked') {
268
+ const pending = decision.activitySummaries.reduce((count, summary) => count + summary.count, 0) + decision.unreadRoomChanges.length;
269
+ throw new SquareError('behind', `${participantIdentity(name)} has pending activity`, { pending });
270
+ }
271
+ if (decision.type === 'held') {
272
+ const holder = state.acts.filter((activity) => activity.kind === 'hold').at(-1)?.actor;
273
+ throw new SquareError('held', 'The square is held', {
274
+ ...(holder === undefined ? {} : { holder }),
275
+ ...(decision.reason === undefined ? {} : { holdReason: decision.reason }),
276
+ });
277
+ }
278
+ if (decision.type === 'capped')
279
+ throw new SquareError('capped', `${participantIdentity(name)} reached the activity cap`);
280
+ if (decision.type === 'throttled')
281
+ throw new SquareError('throttled', `${name} is throttled`, { retryAfterMs: decision.delayMs });
282
+ if (decision.type === 'bell_quota')
283
+ throw new SquareError('bell_quota', `${participantIdentity(name)} cannot ring the bell yet`, { retryAfterMs: Math.max(1, decision.nextAt - now) });
284
+ return decision;
285
+ });
250
286
  const stored = committedActivity(storeActs(state, [decision.act]), 'express');
251
287
  return { state, result: { stored } };
252
- });
288
+ }, control?.signal);
253
289
  await ensureLocalPresence(square, name);
254
290
  await publishIdentityRoute(square, name);
255
291
  let delivery;
@@ -261,64 +297,118 @@ export async function express(square, name, body, options = {}) {
261
297
  }
262
298
  return { activity: exposeActivity(committed.stored), delivery };
263
299
  }
264
- async function landListenerChange(square, verb, actor, target) {
300
+ async function landListenerChange(square, verb, actor, target, control) {
301
+ throwIfAborted(control);
265
302
  const now = square.clock();
266
303
  const stored = await square.artifact.transact((state) => {
267
304
  const act = verb === 'listen' ? coreListen(state, actor, target, now) : coreIgnore(state, actor, target, now);
268
305
  if (act === undefined)
269
306
  return { result: null };
270
307
  return { state, result: committedActivity(storeActs(state, [act]), verb) };
271
- });
308
+ }, control?.signal);
272
309
  return { activity: stored === null ? null : exposeActivity(stored) };
273
310
  }
274
- export function listen(square, actor, target) { return landListenerChange(square, 'listen', actor, target); }
275
- export function ignore(square, actor, target) { return landListenerChange(square, 'ignore', actor, target); }
276
- export async function listening(square, actor) { const { state } = await square.artifact.read(); return coreListening(state, actor); }
277
- async function landCore(square, verb, actor, body = '', fence = {}) {
278
- // Done completes ownership: the live-owner validation and the artifact commit share one
279
- // ownership critical section, so a takeover finalizing between validation and commit can never
280
- // be completed by a stale done — an old expected epoch refuses instead of appending.
281
- const commit = async () => {
311
+ export function listen(square, actor, target, control) { return landListenerChange(square, 'listen', actor, target, control); }
312
+ export function ignore(square, actor, target, control) { return landListenerChange(square, 'ignore', actor, target, control); }
313
+ export async function listening(square, actor, control) { const { state } = await square.artifact.read(control?.signal); return coreListening(state, actor); }
314
+ async function landCore(square, verb, actor, body = '', fence = {}, control) {
315
+ if (verb === 'done')
316
+ validateDoneBody(body);
317
+ // Done completes ownership: the live-owner validation, the artifact commit, the ended
318
+ // session's route retirement, and presence cleanup share one ownership critical section.
319
+ // A takeover finalizing between validation and commit can never be completed by a stale
320
+ // done, and no post-lock cleanup can remove a replacement owner's fresh rows.
321
+ const fenced = verb === 'done' && square.hostLedger !== undefined && square.location !== undefined && square.location !== 'memory';
322
+ const session = fenced ? processIdentity(square.env ?? process.env).session : undefined;
323
+ const commitAndCleanup = async () => {
282
324
  if (verb === 'done' && !await assertLiveOwner(square, actor, fence.expectedEpoch)) {
283
- throw new SquareError('already_done', `${participantIdentity(actor)} is already bound to another session`);
325
+ throw new SquareError('already_done', `✕ ${participantIdentity(actor)} already stands here — another session holds the name`);
326
+ }
327
+ let ownerRecords = [];
328
+ if (fenced && session !== undefined) {
329
+ ownerRecords = (await square.hostLedger.listPresence({ location: square.location, participant: actor }))
330
+ .filter((row) => row.session === session);
284
331
  }
285
332
  const now = square.clock();
286
- return square.artifact.transact((state) => {
333
+ const stored = await square.artifact.transact((state) => {
287
334
  const act = verb === 'done' ? coreDone(state, actor, body, now) : verb === 'hold' ? coreHold(state, actor, body, now) : coreResume(state, actor, now);
288
- return { state, result: committedActivity(storeActs(state, [act]), verb) };
289
- });
335
+ const result = committedActivity(storeActs(state, [act]), verb);
336
+ if (fenced && session !== undefined) {
337
+ dropEndedSessionWakeRoutesFromState(state, square.location, session, { expectedEpoch: fence.expectedEpoch, force: true, participant: actor });
338
+ }
339
+ return { state, result };
340
+ }, control?.signal);
341
+ for (const ownerRecord of ownerRecords) {
342
+ await square.hostLedger.removePresenceIfUnchanged(ownerRecord).catch((error) => {
343
+ process.stderr.write(`! host presence cleanup degraded: ${error instanceof Error ? error.message : String(error)}\n`);
344
+ });
345
+ }
346
+ return stored;
290
347
  };
291
- const fenced = verb === 'done' && square.hostLedger !== undefined && square.location !== undefined && square.location !== 'memory';
292
348
  const stored = fenced
293
- ? await withOwnershipClaimLock(square.env ?? process.env, commit)
294
- : await commit();
295
- if (verb === 'done')
296
- await retireIdentityRoute(square, actor, fence.expectedEpoch);
349
+ ? await withOwnershipClaimLock(square.env ?? process.env, commitAndCleanup, control?.signal)
350
+ : await commitAndCleanup();
297
351
  return { activity: exposeActivity(stored) };
298
352
  }
299
- export function done(square, name, body = '', fence = {}) {
300
- return landCore(square, 'done', name, body, fence);
353
+ export function done(square, name, body = '', fence = {}, control) { return landCore(square, 'done', name, body, fence, control); }
354
+ function liveClaimedParticipants(rows, sessionId) {
355
+ const claimed = new Set();
356
+ const foreign = new Set();
357
+ for (const row of rows) {
358
+ const key = nameKey(row.participant);
359
+ if (row.session === sessionId)
360
+ claimed.add(key);
361
+ else
362
+ foreign.add(key);
363
+ }
364
+ for (const key of foreign)
365
+ claimed.delete(key);
366
+ return claimed;
301
367
  }
302
- /** SessionEnd retires the ended session's routes even when its presence row is gone. */
368
+ async function readLiveClaimedParticipants(square, location, sessionId) {
369
+ if (square.hostLedger === undefined)
370
+ return new Set();
371
+ try {
372
+ return liveClaimedParticipants(await square.hostLedger.listPresence({ location, now: square.clock() }), sessionId);
373
+ }
374
+ catch {
375
+ return undefined;
376
+ }
377
+ }
378
+ /**
379
+ * End one session's ownership of a participant inside the ownership claim critical
380
+ * section. The artifact `done` (when this session is still the owner) and retirement of
381
+ * this session's routes share one transaction, and the exact captured presence row is
382
+ * removed only while the lock is still held. A rejoin or takeover therefore lands either
383
+ * before the whole cleanup — and is part of the state being ended — or after it, with
384
+ * fresh presence and route rows that this cleanup can never touch.
385
+ */
303
386
  export async function endOwnedSession(square, name, sessionId, expectedEpoch) {
304
387
  const location = square.location;
305
388
  const run = async () => {
306
389
  const now = square.clock();
307
390
  let currentSessionId;
308
391
  let ownerMatches = expectedEpoch === undefined;
392
+ let ownerRecords = [];
393
+ let liveParticipants = new Set();
309
394
  if (location !== undefined && location !== 'memory' && square.hostLedger !== undefined) {
310
395
  try {
311
- const bindings = await square.hostLedger.listPresence({ location, participant: name, scopes: ['user', 'local'], now });
396
+ const rows = await square.hostLedger.listPresence({ location, now });
397
+ const bindings = rows.filter((row) => nameKey(row.participant) === nameKey(name));
312
398
  currentSessionId = bindings.find((binding) => binding.session !== sessionId)?.session
313
399
  ?? bindings.toSorted((left, right) => (right.updatedAt ?? 0) - (left.updatedAt ?? 0))[0]?.session;
400
+ ownerRecords = rows.filter((row) => row.session === sessionId);
401
+ liveParticipants = liveClaimedParticipants(rows, sessionId);
314
402
  if (expectedEpoch !== undefined) {
315
403
  const owner = await readParticipantOwner(location, name, square.env ?? process.env);
316
404
  ownerMatches = owner?.sessionId === sessionId && owner.epoch === expectedEpoch;
317
405
  }
318
406
  }
319
- catch { /* stale evidence leaves ownership fenced by artifact routes */ }
407
+ catch {
408
+ liveParticipants = undefined; /* unknown evidence preserves routes; artifact routes still fence ownership */
409
+ }
320
410
  }
321
- return square.artifact.transact((state) => {
411
+ const stored = await square.artifact.transact((state) => {
322
412
  let committed = null;
323
413
  if (location !== undefined && location !== 'memory' && ownerMatches && sessionCanEndParticipant(state, location, name, sessionId, currentSessionId)) {
324
414
  try {
@@ -329,10 +419,19 @@ export async function endOwnedSession(square, name, sessionId, expectedEpoch) {
329
419
  throw error;
330
420
  }
331
421
  }
332
- if (location !== undefined && location !== 'memory')
333
- dropSessionWakeRoutesFromState(state, location, sessionId);
422
+ if (location !== undefined && location !== 'memory') {
423
+ dropEndedSessionWakeRoutesFromState(state, location, sessionId, { expectedEpoch, liveParticipants, force: committed !== null });
424
+ }
334
425
  return { state, result: committed };
335
426
  });
427
+ if (stored !== null) {
428
+ for (const ownerRecord of ownerRecords) {
429
+ await square.hostLedger.removePresenceIfUnchanged(ownerRecord).catch((error) => {
430
+ process.stderr.write(`! host presence cleanup degraded: ${error instanceof Error ? error.message : String(error)}\n`);
431
+ });
432
+ }
433
+ }
434
+ return stored;
336
435
  };
337
436
  const fenced = location !== undefined && location !== 'memory' && square.hostLedger !== undefined;
338
437
  const stored = fenced
@@ -340,5 +439,22 @@ export async function endOwnedSession(square, name, sessionId, expectedEpoch) {
340
439
  : await run();
341
440
  return stored === null ? null : { activity: exposeActivity(stored) };
342
441
  }
343
- export function hold(square, name, reason = '') { return landCore(square, 'hold', name, reason); }
344
- export function resume(square, name) { return landCore(square, 'resume', name); }
442
+ /**
443
+ * Retire an ended session's orphan routes when no participant name is known. The
444
+ * ownership claim lock is held across the ledger read and the artifact transaction, so a
445
+ * rejoin that claims first is seen as a live participant and its route survives.
446
+ */
447
+ export async function retireEndedSessionRoutes(square, sessionId) {
448
+ const location = square.location;
449
+ if (location === undefined || location === 'memory' || square.hostLedger === undefined)
450
+ return;
451
+ await withOwnershipClaimLock(square.env ?? process.env, async () => {
452
+ const liveParticipants = await readLiveClaimedParticipants(square, location, sessionId);
453
+ await square.artifact.transact((state) => {
454
+ dropEndedSessionWakeRoutesFromState(state, location, sessionId, { liveParticipants, force: false });
455
+ return { state, result: undefined };
456
+ });
457
+ });
458
+ }
459
+ export function hold(square, name, reason = '', control) { return landCore(square, 'hold', name, reason, {}, control); }
460
+ export function resume(square, name, control) { return landCore(square, 'resume', name, '', {}, control); }
@@ -0,0 +1,58 @@
1
+ import type { HostLedgerPort, WakeTransportPort } from './ports.js';
2
+ import { statusPresentation, participantsPresentation } from './views.js';
3
+ import type { Activity, CatchOptions, CatchResult, ExpressOptions, ExpressResult, HistoryQuery, ListenerChangeResult, ParticipantStatus, PerceivedActivity, OperationControl } from './square-facade.js';
4
+ /** Final control coordinate shared by facade, actions, and application adapters. */
5
+ export interface SquareApplicationContext {
6
+ readonly cwd: string;
7
+ readonly env: NodeJS.ProcessEnv;
8
+ readonly squarePath?: string;
9
+ readonly participant?: string;
10
+ readonly clock?: () => number;
11
+ readonly hostLedger?: HostLedgerPort;
12
+ readonly wakeTransport?: WakeTransportPort;
13
+ }
14
+ export interface JoinApplicationOptions {
15
+ readonly takeover?: boolean;
16
+ }
17
+ export interface JoinApplicationResult {
18
+ readonly participant: string;
19
+ readonly activity: Activity | null;
20
+ readonly kind: 'joined' | 'reconnected' | 'taken-over';
21
+ readonly participantCount: number;
22
+ }
23
+ export interface ExpressApplicationOptions extends ExpressOptions {
24
+ readonly noWait?: boolean;
25
+ }
26
+ export interface ExpressApplicationResult extends ExpressResult {
27
+ readonly waited: boolean;
28
+ }
29
+ export interface ApplicationStatus {
30
+ readonly context: string;
31
+ readonly actCount: number;
32
+ readonly hardCap: number | null;
33
+ readonly throttlePerMinute?: number;
34
+ readonly held: {
35
+ readonly by: string;
36
+ readonly reason?: string;
37
+ } | null;
38
+ readonly participants: readonly ParticipantStatus[];
39
+ }
40
+ export type ApplicationParticipants = Awaited<ReturnType<typeof participantsPresentation>>;
41
+ export type ApplicationStatusProjection = Awaited<ReturnType<typeof statusPresentation>>;
42
+ export interface SquareApplication {
43
+ join(options?: JoinApplicationOptions, control?: OperationControl): Promise<JoinApplicationResult>;
44
+ express(body: string, options?: ExpressApplicationOptions, control?: OperationControl): Promise<ExpressApplicationResult>;
45
+ catch(options?: CatchOptions, control?: OperationControl): Promise<CatchResult>;
46
+ history(query?: HistoryQuery, control?: OperationControl): Promise<Activity[]>;
47
+ listen(target: string, control?: OperationControl): Promise<ListenerChangeResult>;
48
+ ignore(target: string, control?: OperationControl): Promise<ListenerChangeResult>;
49
+ listening(control?: OperationControl): Promise<readonly string[]>;
50
+ hold(reason?: string, control?: OperationControl): Promise<ExpressResult>;
51
+ resume(control?: OperationControl): Promise<ExpressResult>;
52
+ /** Leave without a message. Only an omitted or empty body is accepted. */
53
+ done(body?: string, control?: OperationControl): Promise<ExpressResult>;
54
+ status(): Promise<ApplicationStatusProjection>;
55
+ participants(): Promise<ApplicationParticipants>;
56
+ }
57
+ export declare function createSquareApplication(context: SquareApplicationContext): SquareApplication;
58
+ export type { CatchResult, PerceivedActivity };