@astrosheep/square 0.3.61 → 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 (87) 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 +168 -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 +8 -6
  10. package/dist/automatic-session.js +14 -24
  11. package/dist/boundary-presentation.d.ts +1 -2
  12. package/dist/boundary-presentation.js +4 -19
  13. package/dist/cli/context.d.ts +2 -0
  14. package/dist/cli/context.js +2 -18
  15. package/dist/cli/harness-command.js +1 -1
  16. package/dist/cli/mcp-server-command.d.ts +2 -0
  17. package/dist/cli/mcp-server-command.js +13 -0
  18. package/dist/cli/observation-commands.js +47 -19
  19. package/dist/cli/program.js +5 -2
  20. package/dist/cli/registry.js +2 -0
  21. package/dist/cli/square-commands.d.ts +1 -1
  22. package/dist/cli/square-commands.js +93 -125
  23. package/dist/decisions.d.ts +2 -0
  24. package/dist/decisions.js +44 -3
  25. package/dist/delivery-operations.d.ts +1 -2
  26. package/dist/delivery-operations.js +1 -4
  27. package/dist/file-changes.d.ts +12 -0
  28. package/dist/file-changes.js +235 -0
  29. package/dist/help.js +4 -8
  30. package/dist/host-ledger-file-adapter.d.ts +15 -13
  31. package/dist/host-ledger-file-adapter.js +131 -79
  32. package/dist/host-ledger-root.d.ts +2 -0
  33. package/dist/host-ledger-root.js +9 -0
  34. package/dist/host-ledger.d.ts +4 -24
  35. package/dist/inbox.d.ts +10 -5
  36. package/dist/inbox.js +152 -67
  37. package/dist/index.d.ts +3 -1
  38. package/dist/index.js +1 -0
  39. package/dist/mcp.d.ts +7 -0
  40. package/dist/mcp.js +122 -0
  41. package/dist/mention-parse.d.ts +8 -0
  42. package/dist/mention-parse.js +107 -0
  43. package/dist/model.d.ts +5 -0
  44. package/dist/model.js +4 -1
  45. package/dist/notifications.d.ts +1 -0
  46. package/dist/notifications.js +25 -20
  47. package/dist/participant-host.js +2 -2
  48. package/dist/ports.d.ts +2 -9
  49. package/dist/presentation.d.ts +12 -1
  50. package/dist/presentation.js +42 -12
  51. package/dist/presented.js +2 -1
  52. package/dist/registry.d.ts +8 -4
  53. package/dist/registry.js +41 -74
  54. package/dist/routes.d.ts +19 -2
  55. package/dist/routes.js +48 -5
  56. package/dist/runtime.d.ts +2 -0
  57. package/dist/runtime.js +2 -0
  58. package/dist/square-actions.d.ts +26 -13
  59. package/dist/square-actions.js +200 -84
  60. package/dist/square-application.d.ts +58 -0
  61. package/dist/square-application.js +183 -0
  62. package/dist/square-facade.d.ts +24 -9
  63. package/dist/square-file-adapter.d.ts +5 -12
  64. package/dist/square-file-adapter.js +12 -64
  65. package/dist/square-projections.d.ts +3 -2
  66. package/dist/square-projections.js +12 -3
  67. package/dist/square-storage.js +27 -34
  68. package/dist/square-wiring.d.ts +12 -7
  69. package/dist/square-wiring.js +36 -32
  70. package/dist/state-cell.d.ts +1 -1
  71. package/dist/stream.js +30 -26
  72. package/dist/views.d.ts +3 -3
  73. package/dist/views.js +4 -4
  74. package/dist/wake-attempts.js +2 -1
  75. package/dist/wake-evidence.js +2 -8
  76. package/dist/wakes.d.ts +1 -0
  77. package/dist/wakes.js +5 -5
  78. package/dist/watch.d.ts +6 -1
  79. package/dist/watch.js +20 -18
  80. package/extensions/square-pi.js +100 -112
  81. package/guides/application-boundary.md +32 -0
  82. package/guides/change-waits.md +28 -0
  83. package/guides/host-ledger.md +9 -0
  84. package/guides/mcp.md +9 -0
  85. package/guides/operation-cancellation.md +17 -0
  86. package/package.json +8 -2
  87. package/skills/square/SKILL.md +5 -7
@@ -12,6 +12,7 @@ export declare function hasDeliveredNotification(squarePath: string, name: strin
12
12
  export declare function hasAttentionNotification(squarePath: string, name: string, ref: number | ActivityId, env?: NodeJS.ProcessEnv): Promise<boolean>;
13
13
  export declare function waitForDeliveredNotification(squarePath: string, name: string, ref: number | ActivityId, opts?: {
14
14
  timeoutMs?: number;
15
+ signal?: AbortSignal;
15
16
  }): Promise<boolean>;
16
17
  interface ProcessNotificationOptions {
17
18
  adapters?: WakeAdapter[];
@@ -1,12 +1,12 @@
1
- import { setTimeout as sleep } from 'node:timers/promises';
1
+ import { hostLedgerRoot } from './host-ledger-root.js';
2
2
  import path from 'node:path';
3
3
  import fs from 'node:fs';
4
4
  import { planActNotifications, } from './delivery.js';
5
5
  import { SquareError } from './model.js';
6
- import { SLEEP_MS, matchesMentionTarget } from './runtime.js';
6
+ import { matchesMentionTarget } from './runtime.js';
7
7
  import { formatActivityId, parseActivityId } from './square-core.js';
8
8
  import { displayAttentionPath } from './attention-presentation.js';
9
- import { openSquare } from './square-file-adapter.js';
9
+ import { openSquare, observeSquareChanges } from './square-file-adapter.js';
10
10
  import { closeOpenSquare } from './open-square.js';
11
11
  import { notificationDelivered, resolveParticipant } from './views.js';
12
12
  import { deliverPending, observeSquare, sweepPending, sweepPendingFromState } from './delivery-operations.js';
@@ -63,8 +63,8 @@ export async function hasAttentionNotification(squarePath, name, ref, env = proc
63
63
  const index = notificationIndex(ref);
64
64
  if (await notificationDelivered(square, recipient, index))
65
65
  return true;
66
- const root = env.SQUARE_REGISTRY === undefined ? undefined : path.dirname(env.SQUARE_REGISTRY);
67
- const hostLedger = createHostLedgerPort({ userPath: env.SQUARE_HOST_LEDGER_USER ?? root, localPath: env.SQUARE_HOST_LEDGER_LOCAL ?? root, readableScopes: ['user'], writableScope: 'user' });
66
+ const root = hostLedgerRoot(env);
67
+ const hostLedger = createHostLedgerPort({ rootPath: root });
68
68
  return (await projectPresentationEvidence({ hostLedger, location: squarePath, participant: recipient, activity: formatActivityId(index), now: Date.now() })).some((row) => row.outcome === 'presented');
69
69
  }
70
70
  finally {
@@ -73,12 +73,20 @@ export async function hasAttentionNotification(squarePath, name, ref, env = proc
73
73
  }
74
74
  export async function waitForDeliveredNotification(squarePath, name, ref, opts = {}) {
75
75
  const deadline = Date.now() + (opts.timeoutMs ?? 30000);
76
- while (Date.now() <= deadline) {
77
- if (await hasDeliveredNotification(squarePath, name, ref))
78
- return true;
79
- await sleep(Math.min(SLEEP_MS, Math.max(1, deadline - Date.now())));
76
+ const observer = await observeSquareChanges(squarePath);
77
+ try {
78
+ while (Date.now() <= deadline) {
79
+ const baseline = await observer.read(opts.signal);
80
+ if (await hasDeliveredNotification(squarePath, name, ref))
81
+ return true;
82
+ if (!await observer.changed(baseline, Math.max(0, deadline - Date.now()), opts.signal))
83
+ return false;
84
+ }
85
+ return false;
86
+ }
87
+ finally {
88
+ observer.close();
80
89
  }
81
- return false;
82
90
  }
83
91
  export async function defaultWakeAdapters() {
84
92
  const adapters = [];
@@ -188,10 +196,9 @@ export function createWakeTransport(adapters, hostLedger, clock) {
188
196
  export async function processActNotificationsOnce(squarePath, actIndex, opts = {}) {
189
197
  const env = opts.env ?? process.env;
190
198
  const now = opts.now ?? Date.now;
191
- const ledgerRoot = env.SQUARE_REGISTRY === undefined ? undefined : path.dirname(env.SQUARE_REGISTRY);
199
+ const ledgerRoot = hostLedgerRoot(env);
192
200
  const hostLedger = createHostLedgerPort({
193
- userPath: env.SQUARE_HOST_LEDGER_USER ?? ledgerRoot,
194
- localPath: env.SQUARE_HOST_LEDGER_LOCAL ?? ledgerRoot,
201
+ rootPath: ledgerRoot,
195
202
  });
196
203
  const square = await openSquare(squarePath, { clock: now, hostLedger, env });
197
204
  try {
@@ -213,11 +220,11 @@ export async function sweepPrivilegedPending(cwd, env = process.env, suppliedAda
213
220
  const remainingMs = () => Math.max(0, deadline - Date.now());
214
221
  if (remainingMs() === 0 || signal?.aborted)
215
222
  return;
216
- const root = env.SQUARE_REGISTRY === undefined ? undefined : path.dirname(env.SQUARE_REGISTRY);
217
- const hostLedger = createHostLedgerPort({ userPath: env.SQUARE_HOST_LEDGER_USER ?? root, localPath: env.SQUARE_HOST_LEDGER_LOCAL ?? root, readableScopes: ['user'], writableScope: 'user' });
223
+ const root = hostLedgerRoot(env);
224
+ const hostLedger = createHostLedgerPort({ rootPath: root });
218
225
  let indexed = [];
219
226
  try {
220
- indexed = await hostLedger.listPresence({ scopes: ['user'], now: Date.now() });
227
+ indexed = await hostLedger.listPresence({ now: Date.now() });
221
228
  }
222
229
  catch { /* capability is best effort */ }
223
230
  const paths = new Set(indexed.map((binding) => binding.location));
@@ -237,7 +244,6 @@ export async function sweepPrivilegedPending(cwd, env = process.env, suppliedAda
237
244
  try {
238
245
  const square = await openSquare(squarePath, { hostLedger, env, signal });
239
246
  try {
240
- await hostLedger.reconcileBinding({ artifact: square.artifact, scopes: ['user'], now: Date.now() }).catch(() => undefined);
241
247
  if (remainingMs() === 0 || signal?.aborted)
242
248
  break;
243
249
  const limit = Number.parseInt(env.SQUARE_NOTIFY_SWEEP_LIMIT ?? '8', 10);
@@ -262,7 +268,7 @@ export async function sweepPrivilegedPending(cwd, env = process.env, suppliedAda
262
268
  }
263
269
  /** Select sweep candidates from one frozen snapshot and one delivery replay. */
264
270
  export async function pendingNotificationSweepFromState(squarePath, state, now, env, limit, deriveDelivery) {
265
- const ledger = createHostLedgerPort({ userPath: env.SQUARE_HOST_LEDGER_USER, writableScope: 'user', readableScopes: ['user'] });
271
+ const ledger = createHostLedgerPort({ rootPath: hostLedgerRoot(env) });
266
272
  return sweepPendingFromState({ state, hostLedger: ledger, location: squarePath, now, graceMs: wakeGraceMs(env), limit, deriveDelivery });
267
273
  }
268
274
  /** Select old pending attention at a bounded action boundary for an explicit executor. */
@@ -272,8 +278,7 @@ export async function sweepPendingNotifications(squarePath, opts = {}) {
272
278
  return [];
273
279
  const now = opts.now ?? Date.now();
274
280
  const limit = opts.limit ?? 8;
275
- const ledgerRoot = env.SQUARE_REGISTRY === undefined ? undefined : path.dirname(env.SQUARE_REGISTRY);
276
- const hostLedger = createHostLedgerPort({ userPath: env.SQUARE_HOST_LEDGER_USER ?? ledgerRoot, localPath: env.SQUARE_HOST_LEDGER_LOCAL ?? ledgerRoot });
281
+ const hostLedger = createHostLedgerPort({ rootPath: hostLedgerRoot(env) });
277
282
  const square = await openSquare(squarePath, { clock: () => now, hostLedger, env });
278
283
  let selected;
279
284
  try {
@@ -53,7 +53,7 @@ export async function ensureLocalPresence(context, participant, epoch) {
53
53
  const now = context.clock();
54
54
  // Semantic publish: an unexpired presence row already stands when it carries the same owner epoch.
55
55
  try {
56
- const existing = await context.hostLedger.listPresence({ location: context.location, participant, session: identity.session, scopes: ['local'], now });
56
+ const existing = await context.hostLedger.listPresence({ location: context.location, participant, session: identity.session, now });
57
57
  if (existing.some((row) => row.channel === identity.channel
58
58
  && now - (row.updatedAt ?? 0) < ROUTE_FRESH_MS
59
59
  && (ownerEpoch === undefined || row.epoch === ownerEpoch)))
@@ -68,7 +68,7 @@ export async function ensureLocalPresence(context, participant, epoch) {
68
68
  // Presence rows are host-ledger wall-time evidence; the square clock belongs to artifact activities.
69
69
  updatedAt: Date.now(),
70
70
  ...(ownerEpoch === undefined || ownerEpoch <= 0 ? {} : { epoch: ownerEpoch }),
71
- }, 'local').catch((error) => ({
71
+ }).catch((error) => ({
72
72
  status: 'degraded',
73
73
  record: { location: context.location, participant, session: identity.session, channel: identity.channel },
74
74
  error,
package/dist/ports.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { InboxNotification, SquareState, StoredAct, WakeRoute, WatchLease } from './model.js';
2
2
  import type { PlannedNotification } from './delivery.js';
3
- export type { ClaimResult, EvidenceClaim, EvidenceRelease, EvidenceGc, EvidenceLookup, EvidenceRecord, HostLedgerPort, HostLedgerScope, PresenceKey, PresenceLookup, PresenceRecord, PresenceClaimResult, PresenceChannel, PresenceResult, ReconcileBindingInput as HostLedgerReconcileBindingInput, ReconcileBindingResult, } from './host-ledger.js';
3
+ export type { ClaimResult, EvidenceClaim, EvidenceRelease, EvidenceGc, EvidenceLookup, EvidenceRecord, HostLedgerPort, PresenceKey, PresenceLookup, PresenceRecord, PresenceClaimResult, PresenceChannel, PresenceResult, } from './host-ledger.js';
4
4
  import type { HostLedgerPort } from './host-ledger.js';
5
5
  /** Atomic artifact access. Never exposes framing, compression, locks, or storage schema. */
6
6
  export interface SquareArtifactPort {
@@ -12,7 +12,7 @@ export interface SquareArtifactPort {
12
12
  state?: SquareState;
13
13
  result: R;
14
14
  }, signal?: AbortSignal): Promise<R>;
15
- changed(sinceVersion: number, timeoutMs: number): Promise<boolean>;
15
+ changed(sinceVersion: number, timeoutMs: number, signal?: AbortSignal): Promise<boolean>;
16
16
  close(): Promise<void>;
17
17
  }
18
18
  /** Capability-neutral wake transport. Unused by this Contract's activity operations. */
@@ -101,13 +101,6 @@ export interface ObserveSquareInput {
101
101
  readonly location?: string;
102
102
  readonly now?: number;
103
103
  }
104
- export interface ReconcileBindingInput {
105
- readonly artifact: SquareArtifactPort;
106
- readonly hostLedger: HostLedgerPort;
107
- readonly location?: string;
108
- readonly scopes?: readonly import('./host-ledger.js').HostLedgerScope[];
109
- readonly now?: number;
110
- }
111
104
  export interface DeliveryResult {
112
105
  readonly attempted: number;
113
106
  readonly accepted: number;
@@ -21,6 +21,7 @@ interface ActivityLimitOptions extends ParticipantOutputOptions {
21
21
  draftPath?: string;
22
22
  }
23
23
  interface ActivityBlockedOptions extends ParticipantOutputOptions {
24
+ retryCommand: string;
24
25
  forceCommand: string;
25
26
  activitySummaries: UnreadActivitySummary[];
26
27
  unreadRoomChanges: RoomChangeAct[];
@@ -29,10 +30,12 @@ interface ActivityBlockedOptions extends ParticipantOutputOptions {
29
30
  interface ExpressWaitingOptions {
30
31
  reason: 'throttled' | 'held';
31
32
  delayMs?: number;
33
+ holder?: string;
34
+ holdReason?: string;
32
35
  }
33
36
  interface ExpressNoWaitOptions extends ParticipantOutputOptions {
34
37
  reason: 'throttled' | 'held';
35
- forceCommand: string;
38
+ retryCommand: string;
36
39
  delayMs?: number;
37
40
  holdReason?: string;
38
41
  draftPath?: string;
@@ -58,6 +61,8 @@ export declare function commandPrefix(squarePath: string): string;
58
61
  export declare function participantCommandPrefix(squarePath: string, name: string): string;
59
62
  /** Blocked actions end with a full copy-pasteable recovery command. */
60
63
  export declare function joinRecoveryCommand(squarePath: string, name: string): string;
64
+ /** Taking a name is an explicit recovery choice, never an automatic retry. */
65
+ export declare function takeoverRecoveryLines(squarePath: string, name: string): string[];
61
66
  export declare function participantsRecoveryCommand(squarePath: string): string;
62
67
  export declare function renderPresenceLines(participants: ParticipantStatus[], now: number, limit?: number): string[];
63
68
  export declare function expressHintLine(ownActivityCount: number): string | undefined;
@@ -85,6 +90,12 @@ export declare function renderAmbientEvent(event: StoredAct, viewer: string, opt
85
90
  }): string;
86
91
  export declare function renderPendingFeed(history: StoredAct[], publicItems: PublicAct[], roomChanges: RoomChangeAct[], viewer: string | undefined, squareState: SquareState): string;
87
92
  export declare function renderActivityBlocked(opts: ActivityBlockedOptions): string;
93
+ /** An indeterminate or already-committed send only points to inspection, never resend. */
94
+ export declare function renderActivityUncertain(opts: ParticipantOutputOptions & {
95
+ draftPath: string;
96
+ landedId?: string;
97
+ detail?: string;
98
+ }): string;
88
99
  export declare function renderExpressWaiting(opts: ExpressWaitingOptions): string;
89
100
  export declare function renderExpressNoWait(opts: ExpressNoWaitOptions): string;
90
101
  export declare function renderPublicTail(squareState: SquareState, events: StoredAct[], lastN: number | null | undefined, now?: number, viewer?: string): string;
@@ -5,7 +5,7 @@ import { participantIdentity } from './participant-identity.js';
5
5
  export { participantIdentity } from './participant-identity.js';
6
6
  import { audienceIncludes, audienceOf, MAX_IDENTITY_SET_SIZE, replayLandedAudiences } from './square-core.js';
7
7
  import { perceiveActivity } from './delivery.js';
8
- import { actId, presenceAnchor, publicActs, rosterNames, sayNumberFor } from './runtime.js';
8
+ import { actId, presenceAnchor, publicActs, rosterNames, sayNumberFor, HELD_WAIT_BUDGET_MS } from './runtime.js';
9
9
  import { formatDuration, formatRelativeTime, formatTimestamp } from './time.js';
10
10
  import { compareParticipantActivity } from './decisions.js';
11
11
  import { grepSnippet } from './search.js';
@@ -55,6 +55,13 @@ export function participantCommandPrefix(squarePath, name) {
55
55
  export function joinRecoveryCommand(squarePath, name) {
56
56
  return `${participantCommandPrefix(squarePath, name)} join`;
57
57
  }
58
+ /** Taking a name is an explicit recovery choice, never an automatic retry. */
59
+ export function takeoverRecoveryLines(squarePath, name) {
60
+ return [
61
+ 'If this is your name to reclaim, --kick banishes the one standing here so you can step in:',
62
+ `${participantCommandPrefix(squarePath, name)} join --kick`,
63
+ ];
64
+ }
58
65
  export function participantsRecoveryCommand(squarePath) {
59
66
  return `${commandPrefix(squarePath)} participants`;
60
67
  }
@@ -281,19 +288,42 @@ export function renderActivityBlocked(opts) {
281
288
  ...renderUnreadSummary({ activitySummaries: opts.activitySummaries, roomChanges: opts.unreadRoomChanges, viewer: opts.name }),
282
289
  ...draftSavedLines(opts.draftPath),
283
290
  `${readNowCommand}`,
284
- ' take it in, then express again',
285
- `${withDraftInput(opts.forceCommand, opts.draftPath)}`,
286
- ' only if you truly mean to express over unread activity',
291
+ ' · take it in, then retry:',
292
+ withDraftInput(opts.retryCommand, opts.draftPath),
293
+ ' · optional: express over unread activity with --force:',
294
+ withDraftInput(opts.forceCommand, opts.draftPath),
287
295
  ].join('\n'), { participantCount: opts.participantCount, held: opts.held });
288
296
  }
297
+ /** An indeterminate or already-committed send only points to inspection, never resend. */
298
+ export function renderActivityUncertain(opts) {
299
+ return formatRefusal(opts.squarePath, [
300
+ opts.landedId === undefined
301
+ ? '✕ your activity may have landed — check history before sending again'
302
+ : `✕ your activity landed · ${opts.landedId} — the confirmation could not finish`,
303
+ ...(opts.detail === undefined ? [] : [` · ${truncateExternalDiagnostic(opts.detail)}`]),
304
+ ...draftSavedLines(opts.draftPath),
305
+ ' · the draft is a saved copy; sending it again could repeat your activity',
306
+ `${commandPrefix(opts.squarePath)} history${opts.landedId === undefined ? ' --limit 10' : ` --at ${opts.landedId}`} --no-truncate`,
307
+ ], opts);
308
+ }
289
309
  export function renderExpressWaiting(opts) {
290
310
  if (opts.reason === 'throttled') {
291
- return ['✕ the square is packed', ` · your activity is waiting · next opening in ${formatDuration(opts.delayMs)}`].join('\n');
311
+ return [
312
+ '✕ the square is packed',
313
+ ` · your activity is waiting · next opening in ${formatDuration(opts.delayMs)}`,
314
+ ' · --no-wait saves a draft and returns now',
315
+ ].join('\n');
292
316
  }
293
- return ["✕ your activity doesn't land — a hand is raised", ' · your activity is waiting'].join('\n');
317
+ const holder = opts.holder === undefined ? 'a hand is raised' : `${participantIdentity(opts.holder)} raised a hand`;
318
+ return [
319
+ `✕ your activity doesn't land — ${holder}`,
320
+ ...(opts.holdReason === undefined ? [] : [` · ${opts.holdReason}`]),
321
+ ` · your activity is waiting — after ${formatDuration(HELD_WAIT_BUDGET_MS)} it saves a draft and stops`,
322
+ ' · --no-wait saves a draft and returns now',
323
+ ].join('\n');
294
324
  }
295
325
  export function renderExpressNoWait(opts) {
296
- const retryCommand = opts.forceCommand;
326
+ const retryCommand = opts.retryCommand;
297
327
  const lines = opts.reason === 'throttled'
298
328
  ? [
299
329
  `${style('blocked', '✕')} the square is packed`,
@@ -411,12 +441,12 @@ export function renderGrepActivitiesView(visible, totalMatches, noTruncate, squa
411
441
  }
412
442
  function renderActivityLimitBody(opts) {
413
443
  const countText = opts.count !== undefined && opts.hardCap !== undefined ? ` (${opts.count}/${opts.hardCap})` : '';
414
- const doneCommand = `${participantCommandPrefix(opts.squarePath, opts.name)} done -`;
444
+ const doneCommand = `${participantCommandPrefix(opts.squarePath, opts.name)} done`;
415
445
  return [
416
446
  `${style('blocked', '✕')} your activity doesn't land — the cap is reached${countText}`,
417
447
  ...draftSavedLines(opts.draftPath),
418
- `${withDraftInput(doneCommand, opts.draftPath)}`,
419
- ' your draft becomes your final note',
448
+ ' · your draft stays unsent; done only steps out',
449
+ doneCommand,
420
450
  ].join('\n');
421
451
  }
422
452
  export function renderActivityLimit(opts) {
@@ -462,9 +492,9 @@ export function renderWatchStatus(opts) {
462
492
  ].join('\n');
463
493
  }
464
494
  case 'quorum':
465
- return [`${style('release', '✓')} everyone else has left — the square is yours alone`, `${participantCommandPrefix(opts.squarePath, opts.name)} done -`].join('\n');
495
+ return [`${style('release', '✓')} everyone else has left — the square is yours alone`, `${participantCommandPrefix(opts.squarePath, opts.name)} done`].join('\n');
466
496
  case 'capped':
467
- return [`${style('blocked', '✕')} nothing left in you — the cap is reached`, `${participantCommandPrefix(opts.squarePath, opts.name)} done -`].join('\n');
497
+ return [`${style('blocked', '✕')} nothing left in you — the cap is reached`, `${participantCommandPrefix(opts.squarePath, opts.name)} done`].join('\n');
468
498
  }
469
499
  }
470
500
  function renderRoomChanges(changes) {
package/dist/presented.js CHANGED
@@ -1,9 +1,10 @@
1
1
  import os from 'node:os';
2
+ import { hostLedgerRoot } from './host-ledger-root.js';
2
3
  import path from 'node:path';
3
4
  import { canonicalSquarePath } from './registry.js';
4
5
  import { createHostLedgerPort } from './host-ledger-file-adapter.js';
5
6
  import { formatActivityId } from './square-core.js';
6
7
  import { projectPresentationEvidence } from './square-projections.js';
7
8
  export function presentedPath(env = process.env) { return env.SQUARE_PRESENTED || path.join(os.homedir(), '.square', 'presented.ndjsonl'); }
8
- function evidence(env = process.env) { const file = presentedPath(env); return createHostLedgerPort({ userPath: env.SQUARE_HOST_LEDGER_USER ?? path.dirname(file), writableScope: 'user', readableScopes: ['user'] }); }
9
+ function evidence(env = process.env) { const file = presentedPath(env); return createHostLedgerPort({ rootPath: hostLedgerRoot(env) }); }
9
10
  export async function hasPresentedForOwner(sessionId, squarePath, name, actIndex, env = process.env, now = Date.now()) { const resolved = await canonicalSquarePath(squarePath); return (await projectPresentationEvidence({ hostLedger: evidence(env), now, location: resolved, participant: name, sessionId, activity: formatActivityId(actIndex) })).some((row) => row.outcome === 'presented'); }
@@ -1,6 +1,6 @@
1
1
  /** Machine-local participant discovery cache. */
2
2
  import { type StoredAct } from './model.js';
3
- import type { PresenceRecord } from './host-ledger.js';
3
+ import type { HostLedgerPort, PresenceRecord } from './host-ledger.js';
4
4
  export type SessionChannel = 'claude-code' | 'codex' | 'opencode' | 'pi' | 'paseo' | 'unknown';
5
5
  export interface RegistryBinding {
6
6
  sessionId: string;
@@ -26,7 +26,7 @@ export declare function registryPath(env?: NodeJS.ProcessEnv): string;
26
26
  export declare function canonicalSquarePath(squarePath: string): Promise<string>;
27
27
  export declare function presenceEpoch(record: PresenceWithEpoch | undefined): number;
28
28
  /** Runs `fn` inside the ownership claim critical section, under the same lock as claims and finalize. */
29
- export declare function withOwnershipClaimLock<T>(env: NodeJS.ProcessEnv, fn: () => Promise<T>): Promise<T>;
29
+ export declare function withOwnershipClaimLock<T>(env: NodeJS.ProcessEnv, fn: () => Promise<T>, signal?: AbortSignal): Promise<T>;
30
30
  export declare function recordJoin(sessionId: string, name: string, squarePath: string, options?: RegistryWriteOptions): Promise<void>;
31
31
  export declare function recordDone(sessionId: string, name: string, squarePath: string, options?: RegistryWriteOptions): Promise<void>;
32
32
  export declare function readActiveBindings(now?: number, env?: NodeJS.ProcessEnv): Promise<RegistryBinding[]>;
@@ -45,6 +45,7 @@ export type CurrentParticipantBinding = Readonly<{
45
45
  }>;
46
46
  export type OwnershipClaim = {
47
47
  readonly status: 'acquired' | 'owned';
48
+ readonly record: PresenceWithEpoch;
48
49
  readonly sessionId: string;
49
50
  readonly epoch: number;
50
51
  } | undefined;
@@ -63,7 +64,8 @@ export type TakeoverRunResult<T> = {
63
64
  };
64
65
  export declare function readParticipantOwner(squarePath: string, name: string, env?: NodeJS.ProcessEnv): Promise<RegistryBinding | undefined>;
65
66
  export declare function sessionOwnsParticipant(squarePath: string, name: string, sessionId: string, env?: NodeJS.ProcessEnv, expectedEpoch?: number): Promise<boolean>;
66
- export declare function claimSessionParticipant(squarePath: string, name: string, env?: NodeJS.ProcessEnv): Promise<OwnershipClaim>;
67
+ export declare function releaseSessionParticipantClaim(squarePath: string, name: string, env: NodeJS.ProcessEnv, claim: OwnershipClaim): Promise<void>;
68
+ export declare function claimSessionParticipant(squarePath: string, name: string, env?: NodeJS.ProcessEnv, signal?: AbortSignal): Promise<OwnershipClaim>;
67
69
  /**
68
70
  * Fenced takeover: the ownership claim and the lifecycle run as one critical section under the
69
71
  * presence-claim lock, so no second takeover can interleave between claim and commit. The claim
@@ -76,7 +78,7 @@ export declare function claimSessionParticipant(squarePath: string, name: string
76
78
  export declare function claimSessionTakeover<T>(squarePath: string, name: string, env: NodeJS.ProcessEnv | undefined, opts: {
77
79
  readonly expectedEpoch?: number;
78
80
  readonly expectedSession?: string;
79
- } | undefined, lifecycle: (claim: TakeoverClaimToken) => Promise<T>): Promise<TakeoverRunResult<T>>;
81
+ } | undefined, lifecycle: (claim: TakeoverClaimToken) => Promise<T>, signal?: AbortSignal): Promise<TakeoverRunResult<T>>;
80
82
  export declare function releaseSessionParticipant(squarePath: string, name: string, env?: NodeJS.ProcessEnv): Promise<void>;
81
83
  export declare function bindCurrentParticipant(squarePath: string, name: string, env?: NodeJS.ProcessEnv): Promise<CurrentParticipantBinding>;
82
84
  export declare function unbindCurrentParticipant(squarePath: string, name: string, env?: NodeJS.ProcessEnv): Promise<boolean>;
@@ -97,4 +99,6 @@ export declare function recordLocalJoin(name: string, squarePath: string, env?:
97
99
  export declare function recordLocalDone(name: string, squarePath: string, env?: NodeJS.ProcessEnv): Promise<void>;
98
100
  export declare function recordSessionJoin(sessionId: string, name: string, squarePath: string, channel: SessionChannel, env?: NodeJS.ProcessEnv): Promise<string>;
99
101
  export declare function recordSessionDone(sessionId: string, name: string, squarePath: string, channel: SessionChannel, env?: NodeJS.ProcessEnv): Promise<boolean>;
102
+ /** Bind the host ledger to the caller's captured environment. */
103
+ export declare function hostLedgerForEnv(env?: NodeJS.ProcessEnv): HostLedgerPort;
100
104
  export {};
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
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
  }
@@ -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>;