@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
package/dist/registry.js CHANGED
@@ -1,11 +1,11 @@
1
1
  /** Machine-local participant discovery cache. */
2
2
  import path from 'node:path';
3
- import { homedir } from 'node:os';
4
3
  import { withFileLock } from './file-lock.js';
5
4
  import { sameName, SquareError } from './model.js';
6
5
  import { isCurrentlyJoined } from './runtime.js';
7
6
  import { squareAssignedParticipantName as computeSquareAssignedParticipantName } from './participant-identity.js';
8
7
  import { createHostLedgerPort } from './host-ledger-file-adapter.js';
8
+ import { hostLedgerRoot } from './host-ledger-root.js';
9
9
  const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
10
10
  const PRESENCE_CLAIM_LOCK = { retryMs: 10 };
11
11
  const LOCAL_SESSION_SOURCES = [
@@ -14,23 +14,15 @@ const LOCAL_SESSION_SOURCES = [
14
14
  { variable: 'OPENCODE_SESSION_ID', channel: 'opencode' },
15
15
  { variable: 'PI_SESSION_ID', channel: 'pi' },
16
16
  ];
17
- export function registryPath(env = process.env) { return env.SQUARE_REGISTRY || path.join(homedir(), '.square', 'sessions.ndjsonl'); }
17
+ export function registryPath(env = process.env) { return env.SQUARE_REGISTRY || path.join(hostLedgerRoot(env), '..', 'sessions.ndjsonl'); }
18
18
  export async function canonicalSquarePath(squarePath) { const absolute = path.resolve(squarePath); try {
19
19
  return await (await import('node:fs/promises')).realpath(absolute);
20
20
  }
21
21
  catch {
22
22
  return absolute;
23
23
  } }
24
- function ledgerRoot(env) {
25
- const root = path.dirname(registryPath(env));
26
- return {
27
- userPath: env.SQUARE_HOST_LEDGER_USER ?? (env.SQUARE_REGISTRY ? root : path.join(homedir(), '.square', 'host-ledger')),
28
- localPath: env.SQUARE_HOST_LEDGER_LOCAL ?? (env.SQUARE_REGISTRY ? root : path.join(process.cwd(), '.square', 'host-ledger')),
29
- };
30
- }
31
- function ledger(env, writableScope = 'local') {
32
- return createHostLedgerPort({ ...ledgerRoot(env), writableScope });
33
- }
24
+ function ledgerRoot(env) { return hostLedgerRoot(env); }
25
+ function ledger(env) { return createHostLedgerPort({ rootPath: ledgerRoot(env) }); }
34
26
  export function presenceEpoch(record) {
35
27
  return typeof record?.epoch === 'number' && Number.isSafeInteger(record.epoch) && record.epoch > 0 ? record.epoch : 0;
36
28
  }
@@ -47,22 +39,21 @@ function toBinding(record) {
47
39
  };
48
40
  }
49
41
  function presenceClaimLockPath(env) {
50
- return path.join(ledgerRoot(env).userPath, 'presence-claim.lock');
42
+ return path.join(ledgerRoot(env), 'presence-claim.lock');
51
43
  }
52
44
  /** Runs `fn` inside the ownership claim critical section, under the same lock as claims and finalize. */
53
- export async function withOwnershipClaimLock(env, fn) {
54
- return withFileLock(presenceClaimLockPath(env), PRESENCE_CLAIM_LOCK, fn);
45
+ export async function withOwnershipClaimLock(env, fn, signal) {
46
+ return withFileLock(presenceClaimLockPath(env), { ...PRESENCE_CLAIM_LOCK, signal }, fn);
55
47
  }
56
- async function activeBindings(now, env) { return (await ledger(env).listPresence({ now, scopes: ['user', 'local'] })).map(toBinding).sort((a, b) => b.updatedAt - a.updatedAt); }
57
- async function writePresence(sessionId, name, squarePath, options, done, scope = 'local') { if (!sessionId || !name || !squarePath)
58
- return; const env = options.env ?? process.env; const channel = options.channel ?? 'unknown'; const port = ledger(env, scope); const location = await canonicalSquarePath(squarePath); if (done)
48
+ async function activeBindings(now, env) { return (await ledger(env).listPresence({ now })).map(toBinding).sort((a, b) => b.updatedAt - a.updatedAt); }
49
+ async function writePresence(sessionId, name, squarePath, options, done) { if (!sessionId || !name || !squarePath)
50
+ return; const env = options.env ?? process.env; const channel = options.channel ?? 'unknown'; const port = ledger(env); const location = await canonicalSquarePath(squarePath); if (done)
59
51
  await port.removePresence({ location, participant: name, session: sessionId, channel });
60
52
  else
61
53
  await port.ensurePresence({ location, participant: name, session: sessionId, channel, updatedAt: options.at ?? Date.now() }); }
62
54
  export function recordJoin(sessionId, name, squarePath, options = {}) { return writePresence(sessionId, name, squarePath, options, false); }
63
55
  export async function recordDone(sessionId, name, squarePath, options = {}) {
64
56
  await writePresence(sessionId, name, squarePath, options, true);
65
- await writePresence(sessionId, name, squarePath, options, true, 'user');
66
57
  }
67
58
  export async function readActiveBindings(now = Date.now(), env = process.env) { try {
68
59
  return await activeBindings(now, env);
@@ -87,10 +78,18 @@ export async function sessionOwnsParticipant(squarePath, name, sessionId, env =
87
78
  return false;
88
79
  return expectedEpoch === undefined || owner.epoch === expectedEpoch;
89
80
  }
90
- export async function claimSessionParticipant(squarePath, name, env = process.env) {
81
+ export async function releaseSessionParticipantClaim(squarePath, name, env, claim) {
82
+ if (claim?.status !== 'acquired')
83
+ return;
84
+ const port = ledger(env);
85
+ await port.removePresenceIfUnchanged(claim.record);
86
+ }
87
+ export async function claimSessionParticipant(squarePath, name, env = process.env, signal) {
91
88
  const identity = localSessionIdentities(env)[0];
92
89
  if (identity === undefined)
93
90
  return undefined;
91
+ if (signal?.aborted)
92
+ throw signal.reason ?? new Error('Operation aborted');
94
93
  const location = await canonicalSquarePath(squarePath);
95
94
  const result = await ledger(env).claimPresence({
96
95
  location,
@@ -99,13 +98,13 @@ export async function claimSessionParticipant(squarePath, name, env = process.en
99
98
  channel: identity.channel,
100
99
  updatedAt: Date.now(),
101
100
  epoch: 1,
102
- }, 'local');
101
+ }, signal);
103
102
  if (result.status === 'busy')
104
- throw new SquareError('already_joined', `${name} is already bound to another session`);
103
+ throw new SquareError('already_joined', `✕ ${name} already stands here — another session holds the name`);
105
104
  if (result.status === 'degraded')
106
105
  throw result.error;
107
- const epoch = presenceEpoch(result.record) || (result.status === 'acquired' ? 1 : 0);
108
- return { status: result.status, sessionId: identity.sessionId, epoch };
106
+ const record = result.record;
107
+ return { status: result.status, record, sessionId: record.session, epoch: presenceEpoch(record) };
109
108
  }
110
109
  /**
111
110
  * Fenced takeover: the ownership claim and the lifecycle run as one critical section under the
@@ -116,18 +115,15 @@ export async function claimSessionParticipant(squarePath, name, env = process.en
116
115
  * carrying the claim token (session + channel + claim epoch) are withdrawn and the captured
117
116
  * standing rows are restored exactly.
118
117
  */
119
- export async function claimSessionTakeover(squarePath, name, env = process.env, opts = {}, lifecycle) {
118
+ export async function claimSessionTakeover(squarePath, name, env = process.env, opts = {}, lifecycle, signal) {
120
119
  const identity = localSessionIdentities(env)[0];
121
120
  if (identity === undefined)
122
121
  throw new SquareError('invalid_args', 'No local session identity for takeover');
123
122
  const location = await canonicalSquarePath(squarePath);
124
- return withFileLock(presenceClaimLockPath(env), PRESENCE_CLAIM_LOCK, async () => {
123
+ return withFileLock(presenceClaimLockPath(env), { ...PRESENCE_CLAIM_LOCK, signal }, async () => {
125
124
  const port = ledger(env);
126
- const standing = {
127
- user: await port.listPresence({ location, participant: name, scopes: ['user'] }),
128
- local: await port.listPresence({ location, participant: name, scopes: ['local'] }),
129
- };
130
- const owner = [...standing.user, ...standing.local]
125
+ const standing = await port.listPresence({ location, participant: name });
126
+ const owner = standing
131
127
  .map((row) => toBinding(row))
132
128
  .sort((left, right) => right.epoch - left.epoch || right.updatedAt - left.updatedAt)[0];
133
129
  const currentEpoch = owner?.epoch ?? 0;
@@ -147,50 +143,21 @@ export async function claimSessionTakeover(squarePath, name, env = process.env,
147
143
  // token (session + channel + epoch) — whatever the lifecycle itself ensured — then restore
148
144
  // the captured standing rows so the old owner survives byte-consistently. Foreign rows and
149
145
  // later owners are never touched.
150
- for (const scope of ['user', 'local']) {
151
- const tokenRows = await ledger(env, scope).listPresence({
152
- location,
153
- participant: name,
154
- session: identity.sessionId,
155
- scopes: [scope],
156
- });
157
- for (const row of tokenRows) {
158
- if (row.channel !== identity.channel || presenceEpoch(row) !== epoch)
159
- continue;
160
- await ledger(env, scope).removePresence({
161
- location: row.location,
162
- participant: row.participant,
163
- session: row.session,
164
- channel: row.channel,
165
- });
166
- }
167
- for (const row of standing[scope]) {
168
- await ledger(env, scope).ensurePresence(row, scope);
169
- }
146
+ const tokenRows = await ledger(env).listPresence({ location, participant: name, session: identity.sessionId });
147
+ for (const row of tokenRows) {
148
+ if (row.channel === identity.channel && presenceEpoch(row) === epoch)
149
+ await ledger(env).removePresence({ location: row.location, participant: row.participant, session: row.session, channel: row.channel });
170
150
  }
151
+ for (const row of standing)
152
+ await ledger(env).ensurePresence(row);
171
153
  throw error;
172
154
  }
173
155
  // Finalize: the lifecycle committed. Replace the standing owner with the new owner's row at
174
- // the claim epoch in every scope where the owner was visible, so exactly one current owner
156
+ // the claim epoch where the owner was visible, so exactly one current owner
175
157
  // remains — also when the takeover is a self-takeover (same session as the standing owner).
176
- for (const scope of ['user', 'local']) {
177
- for (const row of standing[scope]) {
178
- await ledger(env, scope).removePresence({
179
- location: row.location,
180
- participant: row.participant,
181
- session: row.session,
182
- channel: row.channel,
183
- });
184
- }
185
- await ledger(env, scope).ensurePresence({
186
- location,
187
- participant: name,
188
- session: identity.sessionId,
189
- channel: identity.channel,
190
- updatedAt: Date.now(),
191
- epoch,
192
- }, scope);
193
- }
158
+ for (const row of standing)
159
+ await ledger(env).removePresence({ location: row.location, participant: row.participant, session: row.session, channel: row.channel });
160
+ await ledger(env).ensurePresence({ location, participant: name, session: identity.sessionId, channel: identity.channel, updatedAt: Date.now(), epoch });
194
161
  return { status: 'acquired', sessionId: identity.sessionId, epoch, result };
195
162
  });
196
163
  }
@@ -203,7 +170,7 @@ export async function releaseSessionParticipant(squarePath, name, env = process.
203
170
  export async function bindCurrentParticipant(squarePath, name, env = process.env) { if (squareAssignedParticipantName(env) !== name)
204
171
  throw new SquareError('invalid_args', `The current session is not assigned ${name}`); const sessionId = await localParticipantOwner(squarePath, name, env); if (sessionId !== undefined)
205
172
  return { created: false, sessionId }; if ((await lookupParticipant(squarePath, name, Date.now(), env)).at(0) !== undefined)
206
- throw new SquareError('already_joined', `${name} is already bound to another session`); await recordLocalJoin(name, squarePath, env); const currentSessionId = await localParticipantOwner(squarePath, name, env); if (currentSessionId === undefined)
173
+ throw new SquareError('already_joined', `✕ ${name} already stands here — another session holds the name`); await recordLocalJoin(name, squarePath, env); const currentSessionId = await localParticipantOwner(squarePath, name, env); if (currentSessionId === undefined)
207
174
  throw new Error(`Current participant binding did not commit for ${name}`); return { created: true, sessionId: currentSessionId }; }
208
175
  export async function unbindCurrentParticipant(squarePath, name, env = process.env) { const identities = new Set(localSessionIdentities(env).map((identity) => identity.sessionId)); const current = (await lookupParticipant(squarePath, name, Date.now(), env)).filter((binding) => identities.has(binding.sessionId)); for (const binding of current)
209
176
  await recordSessionDone(binding.sessionId, binding.name, binding.squarePath, binding.channel, env); return current.length > 0; }
@@ -226,9 +193,9 @@ export async function recordLocalJoin(name, squarePath, env = process.env) { con
226
193
  } }
227
194
  export async function recordLocalDone(name, squarePath, env = process.env) { const at = Date.now(); const identities = new Set(localSessionIdentities(env).map((identity) => identity.sessionId)); const current = (await lookupParticipant(squarePath, name, at, env)).filter((binding) => identities.has(binding.sessionId)); for (const binding of current)
228
195
  await recordDone(binding.sessionId, binding.name, binding.squarePath, { channel: binding.channel, at, env }); }
229
- export async function recordSessionJoin(sessionId, name, squarePath, channel, env = process.env) { const at = Date.now(); const current = (await lookupParticipant(squarePath, name, at, env)).filter((binding) => binding.sessionId === sessionId); for (const binding of current) {
230
- await recordDone(binding.sessionId, binding.name, binding.squarePath, { channel: binding.channel, at, env });
231
- await writePresence(binding.sessionId, binding.name, binding.squarePath, { channel: binding.channel, at, env }, true, 'user');
232
- } await recordJoin(sessionId, name, squarePath, { channel, at, env }); await writePresence(sessionId, name, squarePath, { channel, at, env }, false, 'user'); return sessionId; }
196
+ export async function recordSessionJoin(sessionId, name, squarePath, channel, env = process.env) { const at = Date.now(); const current = (await lookupParticipant(squarePath, name, at, env)).filter((binding) => binding.sessionId === sessionId); for (const binding of current)
197
+ await recordDone(binding.sessionId, binding.name, binding.squarePath, { channel: binding.channel, at, env }); await recordJoin(sessionId, name, squarePath, { channel, at, env }); return sessionId; }
233
198
  export async function recordSessionDone(sessionId, name, squarePath, channel, env = process.env) { const canonicalPath = await canonicalSquarePath(squarePath); const binding = (await lookupSessionBindings(sessionId, Date.now(), env)).find((item) => item.squarePath === canonicalPath && sameName(item.name, name) && item.channel === channel); if (binding === undefined)
234
- return false; const options = { channel, at: Date.now(), env }; await recordDone(sessionId, binding.name, binding.squarePath, options); await writePresence(sessionId, binding.name, binding.squarePath, options, true, 'user'); return true; }
199
+ return false; const options = { channel, at: Date.now(), env }; await recordDone(sessionId, binding.name, binding.squarePath, options); return true; }
200
+ /** Bind the host ledger to the caller's captured environment. */
201
+ export function hostLedgerForEnv(env = process.env) { return ledger(env); }
package/dist/routes.d.ts CHANGED
@@ -36,7 +36,22 @@ export type RouteEpoch = {
36
36
  readonly epoch?: number;
37
37
  };
38
38
  export declare function applyWakeRouteToState(state: import('./model.js').SquareState, route: Omit<WakeRoute, 'updatedAt'> & RouteEpoch, at?: number): void;
39
- export declare function dropSessionWakeRoutesFromState(state: import('./model.js').SquareState, location: string, sessionId: string): void;
39
+ export declare function dropSessionWakeRoutesFromState(state: import('./model.js').SquareState, location: string, sessionId: string, expectedEpoch?: number): void;
40
+ /**
41
+ * Drop one ended session's routes at a location, atomically with the lifecycle decide.
42
+ *
43
+ * A route survives when the ending transaction did not commit `done` and this session
44
+ * still holds the sole live presence for its participant — an in-flight same-session
45
+ * rejoin. `liveParticipants === undefined` means the ledger could not be read, so
46
+ * nothing is dropped on suspicion. An explicit epoch fence additionally keeps routes
47
+ * published by a later generation.
48
+ */
49
+ export declare function dropEndedSessionWakeRoutesFromState(state: import('./model.js').SquareState, location: string, sessionId: string, opts: {
50
+ readonly expectedEpoch?: number;
51
+ readonly liveParticipants?: ReadonlySet<string>;
52
+ readonly force: boolean;
53
+ readonly participant?: string;
54
+ }): void;
40
55
  export declare function dropParticipantWakeRoutesFromState(state: import('./model.js').SquareState, location: string, participant: string): void;
41
56
  export declare function sessionOwnsParticipantRoutes(state: import('./model.js').SquareState, location: string, participant: string, sessionId: string): boolean;
42
57
  export declare function sessionCanEndParticipant(state: import('./model.js').SquareState, location: string, participant: string, sessionId: string, currentSessionId?: string): boolean;
@@ -52,5 +67,7 @@ export declare function retireWakeRoute(route: WakeRoute, opts?: {
52
67
  export declare function retireWakeRouteFromArtifact(artifact: import('./ports.js').SquareArtifactPort, route: Pick<WakeRoute, 'location' | 'participant' | 'sessionId'>, opts?: {
53
68
  readonly expectedEpoch?: number;
54
69
  }): Promise<void>;
55
- export declare function retireWakeRoutesForSessionFromArtifact(artifact: import('./ports.js').SquareArtifactPort, route: Pick<WakeRoute, 'location' | 'sessionId'>): Promise<void>;
70
+ export declare function retireWakeRoutesForSessionFromArtifact(artifact: import('./ports.js').SquareArtifactPort, route: Pick<WakeRoute, 'location' | 'sessionId'>, opts?: {
71
+ readonly expectedEpoch?: number;
72
+ }): Promise<void>;
56
73
  export declare function canonicalRouteLocation(location: string): Promise<string>;
package/dist/routes.js CHANGED
@@ -10,7 +10,7 @@ export async function defaultWakeRouteCapabilities(hostLedger) {
10
10
  let userCapable = hostLedger !== undefined;
11
11
  if (hostLedger !== undefined) {
12
12
  try {
13
- await hostLedger.listPresence({ scopes: ['user'], now: Date.now() });
13
+ await hostLedger.listPresence({ now: Date.now() });
14
14
  }
15
15
  catch {
16
16
  userCapable = false;
@@ -109,9 +109,44 @@ export function applyWakeRouteToState(state, route, at = Date.now()) {
109
109
  return !(itemLocation === location && routeIdentityKey(item, itemLocation) === routeIdentityKey(route, location));
110
110
  }), { ...route, location, updatedAt: at }];
111
111
  }
112
- export function dropSessionWakeRoutesFromState(state, location, sessionId) {
112
+ export function dropSessionWakeRoutesFromState(state, location, sessionId, expectedEpoch) {
113
113
  const canonical = canonicalRouteLocationSync(location);
114
- state.routes = (state.routes ?? []).filter((item) => !(item.sessionId === sessionId && canonicalRouteLocationSync(item.location) === canonical));
114
+ state.routes = (state.routes ?? []).filter((item) => {
115
+ if (item.sessionId !== sessionId || canonicalRouteLocationSync(item.location) !== canonical)
116
+ return true;
117
+ if (expectedEpoch === undefined)
118
+ return false;
119
+ return item.epoch !== expectedEpoch;
120
+ });
121
+ }
122
+ /**
123
+ * Drop one ended session's routes at a location, atomically with the lifecycle decide.
124
+ *
125
+ * A route survives when the ending transaction did not commit `done` and this session
126
+ * still holds the sole live presence for its participant — an in-flight same-session
127
+ * rejoin. `liveParticipants === undefined` means the ledger could not be read, so
128
+ * nothing is dropped on suspicion. An explicit epoch fence additionally keeps routes
129
+ * published by a later generation.
130
+ */
131
+ export function dropEndedSessionWakeRoutesFromState(state, location, sessionId, opts) {
132
+ const canonical = canonicalRouteLocationSync(location);
133
+ state.routes = (state.routes ?? []).filter((item) => {
134
+ if (item.sessionId !== sessionId || canonicalRouteLocationSync(item.location) !== canonical)
135
+ return true;
136
+ if (opts.participant !== undefined && nameKey(item.participant) !== nameKey(opts.participant))
137
+ return true;
138
+ if (!opts.force) {
139
+ // Keep only a route whose participant is still joined and whose ended session still
140
+ // holds the sole live presence — an in-flight same-session rejoin. A no-longer-joined
141
+ // participant is retired even when a stale presence row survives.
142
+ if (isCurrentlyJoined(state.acts, item.participant)
143
+ && (opts.liveParticipants === undefined || opts.liveParticipants.has(nameKey(item.participant))))
144
+ return true;
145
+ }
146
+ if (opts.expectedEpoch !== undefined && item.epoch !== opts.expectedEpoch)
147
+ return true;
148
+ return false;
149
+ });
115
150
  }
116
151
  export function dropParticipantWakeRoutesFromState(state, location, participant) {
117
152
  const canonical = canonicalRouteLocationSync(location);
@@ -163,8 +198,16 @@ export async function retireWakeRouteFromArtifact(artifact, route, opts = {}) {
163
198
  return opts.expectedEpoch !== undefined && itemEpoch !== opts.expectedEpoch;
164
199
  }) }, result: undefined }));
165
200
  }
166
- export async function retireWakeRoutesForSessionFromArtifact(artifact, route) {
167
- await artifact.transact((state) => { dropSessionWakeRoutesFromState(state, route.location, route.sessionId); return { state, result: undefined }; });
201
+ export async function retireWakeRoutesForSessionFromArtifact(artifact, route, opts = {}) {
202
+ const location = canonicalRouteLocationSync(route.location);
203
+ await artifact.transact((state) => ({ state: { ...state, routes: (state.routes ?? []).filter((item) => {
204
+ if (canonicalRouteLocationSync(item.location) !== location || item.sessionId !== route.sessionId)
205
+ return true;
206
+ if (opts.expectedEpoch === undefined)
207
+ return false;
208
+ const itemEpoch = item.epoch;
209
+ return itemEpoch !== undefined && itemEpoch !== opts.expectedEpoch;
210
+ }) }, result: undefined }));
168
211
  }
169
212
  export async function canonicalRouteLocation(location) {
170
213
  return canonicalRouteLocationSync(location);
package/dist/runtime.d.ts CHANGED
@@ -7,6 +7,8 @@ export declare const WATCH_STALE_MS: number;
7
7
  export declare const LOCK_RETRY_MS: number;
8
8
  export declare const THROTTLE_WINDOW_MS: number;
9
9
  export declare const UNREAD_BLOCK_GRACE_MS: number;
10
+ /** A held express waits for the hand to lower, but not forever: past this budget it gives up exactly like --no-wait (draft saved, retry printed). 0 disables waiting. */
11
+ export declare const HELD_WAIT_BUDGET_MS: number;
10
12
  export declare function nowMs(): number;
11
13
  /** Pure directed-activity filter. Bell matches every viewer; mentions match by audience. */
12
14
  export declare function matchesMentionTarget(act: {
@@ -38,6 +40,14 @@ export declare function observationFor(squareState: SquareState, name: string, i
38
40
  export declare function recordObservation(squareState: SquareState, name: string, index: number, state: ObservationState, at?: number): boolean;
39
41
  export declare function readCursor(squareState: SquareState, name: string, landed?: LandedAudienceReplay): number;
40
42
  export declare function latestActIndex(acts: StoredAct[]): number;
43
+ /**
44
+ * "Was here" is a claim of presence, so it rides on evidence: the participant's own acts
45
+ * and seen receipts after their latest join. Anything else — in particular acts merely
46
+ * not addressed to them — says nothing about where they actually were.
47
+ */
48
+ export declare function presenceEvidenceCursor(squareState: SquareState, name: string, landed?: LandedAudienceReplay): number;
49
+ /** The last public act the participant was verifiably present for, or -1 when there is no evidence. */
50
+ export declare function presenceAnchor(squareState: SquareState, name: string, landed?: LandedAudienceReplay): number;
41
51
  export declare function freshWatchLease(squareState: SquareState, name: string, at?: number): WatchLease | undefined;
42
52
  export declare function watchLease(squareState: SquareState, name: string): WatchLease | undefined;
43
53
  export declare function writeWatchLease(squareState: SquareState, name: string, lease: WatchLease): void;
package/dist/runtime.js CHANGED
@@ -40,6 +40,8 @@ export const WATCH_STALE_MS = parseIntegerEnv('SQUARE_WATCH_STALE_MS', scaledMs(
40
40
  export const LOCK_RETRY_MS = parseIntegerEnv('SQUARE_LOCK_RETRY_MS', 25);
41
41
  export const THROTTLE_WINDOW_MS = parseIntegerEnv('SQUARE_THROTTLE_WINDOW_MS', 60000);
42
42
  export const UNREAD_BLOCK_GRACE_MS = parseIntegerEnv('SQUARE_UNREAD_BLOCK_GRACE_MS', 90000);
43
+ /** A held express waits for the hand to lower, but not forever: past this budget it gives up exactly like --no-wait (draft saved, retry printed). 0 disables waiting. */
44
+ export const HELD_WAIT_BUDGET_MS = parseIntegerEnv('SQUARE_HELD_WAIT_BUDGET_MS', 10 * 60 * 1000);
43
45
  export function nowMs() {
44
46
  const override = process.env.SQUARE_NOW_MS;
45
47
  if (override === undefined)
@@ -184,6 +186,43 @@ export function readCursor(squareState, name, landed = replayLandedAudiences(squ
184
186
  export function latestActIndex(acts) {
185
187
  return acts.reduce((max, act) => Math.max(max, act.index), -1);
186
188
  }
189
+ /**
190
+ * "Was here" is a claim of presence, so it rides on evidence: the participant's own acts
191
+ * and seen receipts after their latest join. Anything else — in particular acts merely
192
+ * not addressed to them — says nothing about where they actually were.
193
+ */
194
+ export function presenceEvidenceCursor(squareState, name, landed = replayLandedAudiences(squareState.acts)) {
195
+ const recipient = landed.resolveParticipant(name) ?? name;
196
+ const boundary = landed.lastJoinIndex(recipient) ?? -1;
197
+ if (boundary < 0)
198
+ return -1;
199
+ let evidence = boundary;
200
+ for (const act of squareState.acts) {
201
+ if (act.index <= boundary || act.index <= evidence || act.actor === undefined)
202
+ continue;
203
+ if (sameName(act.actor, recipient)
204
+ || squareState.runtime.observations?.[recipient]?.[formatActivityId(act.index)]?.state === 'seen') {
205
+ evidence = act.index;
206
+ }
207
+ }
208
+ return evidence;
209
+ }
210
+ /** The last public act the participant was verifiably present for, or -1 when there is no evidence. */
211
+ export function presenceAnchor(squareState, name, landed = replayLandedAudiences(squareState.acts)) {
212
+ const recipient = landed.resolveParticipant(name) ?? name;
213
+ const boundary = landed.lastJoinIndex(recipient) ?? -1;
214
+ if (boundary < 0)
215
+ return -1;
216
+ const evidence = presenceEvidenceCursor(squareState, recipient, landed);
217
+ for (let i = squareState.acts.length - 1; i >= 0; i--) {
218
+ const event = squareState.acts[i];
219
+ if (event.index <= boundary || event.index > evidence)
220
+ continue;
221
+ if (event.kind === 'say' || event.kind === 'done')
222
+ return event.index;
223
+ }
224
+ return -1;
225
+ }
187
226
  export function freshWatchLease(squareState, name, at = Date.now()) {
188
227
  const key = canonicalRuntimeName(squareState, name);
189
228
  const lease = watchLease(squareState, key);
@@ -1,37 +1,50 @@
1
1
  import { type SquareState } from './model.js';
2
2
  import type { WakeTransportPort } from './ports.js';
3
- import type { Activity, CatchOptions, CatchResult, ExpressOptions, ExpressResult, OwnershipFenceOptions } from './square-facade.js';
3
+ import type { Activity, CatchOptions, CatchResult, ExpressOptions, ExpressResult, OperationControl, OwnershipFenceOptions } from './square-facade.js';
4
4
  import { type CatchProjection } from './catch-decisions.js';
5
5
  import { type HostContext } from './participant-host.js';
6
6
  export interface OperationContext extends HostContext {
7
7
  readonly wakeTransport?: WakeTransportPort;
8
8
  }
9
9
  export type { OwnershipFenceOptions };
10
- export declare function catchUp(square: OperationContext, name: string, options?: CatchOptions, project?: (state: SquareState) => CatchProjection): Promise<CatchResult>;
11
- export declare function join(square: OperationContext, name: string): Promise<{
10
+ export declare function catchUp(square: OperationContext, name: string, options?: CatchOptions, project?: (state: SquareState) => CatchProjection, control?: OperationControl): Promise<CatchResult>;
11
+ export declare function join(square: OperationContext, name: string, control?: OperationControl): Promise<{
12
12
  readonly name: string;
13
13
  readonly activity: Activity | null;
14
14
  }>;
15
15
  /** End the standing participant and immediately let the caller reclaim the name. */
16
- export declare function takeover(square: OperationContext, name: string, _oldSessionIds?: readonly string[]): Promise<{
16
+ export declare function takeover(square: OperationContext, name: string, _oldSessionIds?: readonly string[], control?: OperationControl): Promise<{
17
17
  readonly name: string;
18
18
  readonly activities: readonly Activity[];
19
19
  readonly epoch?: number;
20
20
  }>;
21
- export declare function implicitJoin(square: OperationContext, name: string): Promise<{
21
+ export declare function implicitJoin(square: OperationContext, name: string, control?: OperationControl): Promise<{
22
22
  readonly name: string;
23
23
  readonly state: 'joined' | 'active' | 'done';
24
24
  readonly activity: Activity | null;
25
25
  }>;
26
- export declare function express(square: OperationContext, name: string, body: string, options?: ExpressOptions): Promise<ExpressResult>;
26
+ export declare function express(square: OperationContext, name: string, body: string, options?: ExpressOptions, control?: OperationControl): Promise<ExpressResult>;
27
27
  export interface ListenerChangeResult {
28
28
  readonly activity: Activity | null;
29
29
  }
30
- export declare function listen(square: OperationContext, actor: string, target: string): Promise<ListenerChangeResult>;
31
- export declare function ignore(square: OperationContext, actor: string, target: string): Promise<ListenerChangeResult>;
32
- export declare function listening(square: OperationContext, actor: string): Promise<readonly string[]>;
33
- export declare function done(square: OperationContext, name: string, body?: string, fence?: OwnershipFenceOptions): Promise<ExpressResult>;
34
- /** SessionEnd retires the ended session's routes even when its presence row is gone. */
30
+ export declare function listen(square: OperationContext, actor: string, target: string, control?: OperationControl): Promise<ListenerChangeResult>;
31
+ export declare function ignore(square: OperationContext, actor: string, target: string, control?: OperationControl): Promise<ListenerChangeResult>;
32
+ export declare function listening(square: OperationContext, actor: string, control?: OperationControl): Promise<readonly string[]>;
33
+ export declare function done(square: OperationContext, name: string, body?: string, fence?: OwnershipFenceOptions, control?: OperationControl): Promise<ExpressResult>;
34
+ /**
35
+ * End one session's ownership of a participant inside the ownership claim critical
36
+ * section. The artifact `done` (when this session is still the owner) and retirement of
37
+ * this session's routes share one transaction, and the exact captured presence row is
38
+ * removed only while the lock is still held. A rejoin or takeover therefore lands either
39
+ * before the whole cleanup — and is part of the state being ended — or after it, with
40
+ * fresh presence and route rows that this cleanup can never touch.
41
+ */
35
42
  export declare function endOwnedSession(square: OperationContext, name: string, sessionId: string, expectedEpoch?: number): Promise<ExpressResult | null>;
36
- export declare function hold(square: OperationContext, name: string, reason?: string): Promise<ExpressResult>;
37
- export declare function resume(square: OperationContext, name: string): Promise<ExpressResult>;
43
+ /**
44
+ * Retire an ended session's orphan routes when no participant name is known. The
45
+ * ownership claim lock is held across the ledger read and the artifact transaction, so a
46
+ * rejoin that claims first is seen as a live participant and its route survives.
47
+ */
48
+ export declare function retireEndedSessionRoutes(square: OperationContext, sessionId: string): Promise<void>;
49
+ export declare function hold(square: OperationContext, name: string, reason?: string, control?: OperationControl): Promise<ExpressResult>;
50
+ export declare function resume(square: OperationContext, name: string, control?: OperationControl): Promise<ExpressResult>;