@astrosheep/square 0.3.61 → 0.3.63

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 (90) 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/architect.md +77 -77
  83. package/guides/brainstorm.md +197 -197
  84. package/guides/change-waits.md +28 -0
  85. package/guides/host-ledger.md +9 -0
  86. package/guides/mcp.md +9 -0
  87. package/guides/operation-cancellation.md +17 -0
  88. package/guides/participant.md +7 -7
  89. package/package.json +8 -2
  90. package/skills/square/SKILL.md +5 -7
@@ -0,0 +1,235 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ /** Filesystem notifications are hints, never proof of a committed change. */
4
+ const RECONCILE_MS = 2_000;
5
+ export async function canonicalFilePath(value) {
6
+ let current = path.resolve(value);
7
+ const suffix = [];
8
+ for (;;) {
9
+ try {
10
+ return path.join(await fs.promises.realpath(current), ...suffix.reverse());
11
+ }
12
+ catch (error) {
13
+ if (error.code !== 'ENOENT')
14
+ return path.resolve(value);
15
+ const parent = path.dirname(current);
16
+ if (parent === current)
17
+ return path.resolve(value);
18
+ suffix.push(path.basename(current));
19
+ current = parent;
20
+ }
21
+ }
22
+ }
23
+ /** A local edge counter: register first, inspect state, then wait from that counter. */
24
+ class Edges {
25
+ version = 0;
26
+ listeners = new Set();
27
+ emit() { this.version += 1; for (const listener of [...this.listeners])
28
+ listener(); }
29
+ wait(since, timeoutMs, signal) {
30
+ if (signal?.aborted)
31
+ return Promise.reject(signal.reason ?? new Error('Wait aborted'));
32
+ if (this.version !== since)
33
+ return Promise.resolve(true);
34
+ if (timeoutMs <= 0)
35
+ return Promise.resolve(false);
36
+ return new Promise((resolve, reject) => {
37
+ let timer;
38
+ const cleanup = () => { if (timer !== undefined)
39
+ clearTimeout(timer); this.listeners.delete(changed); signal?.removeEventListener('abort', aborted); };
40
+ const changed = () => { cleanup(); resolve(true); };
41
+ const aborted = () => { cleanup(); reject(signal?.reason ?? new Error('Wait aborted')); };
42
+ this.listeners.add(changed);
43
+ signal?.addEventListener('abort', aborted, { once: true });
44
+ if (Number.isFinite(timeoutMs))
45
+ timer = setTimeout(() => { cleanup(); resolve(false); }, Math.min(timeoutMs, 2_147_483_647));
46
+ if (signal?.aborted)
47
+ aborted();
48
+ else if (this.version !== since)
49
+ changed();
50
+ });
51
+ }
52
+ }
53
+ const directories = new Map();
54
+ function attachDirectory(directory, entry) {
55
+ try {
56
+ const stat = fs.statSync(directory);
57
+ const identity = stat.dev + ':' + stat.ino;
58
+ if (entry.watcher !== undefined && entry.identity === identity)
59
+ return;
60
+ entry.watcher?.close();
61
+ entry.watcher = undefined;
62
+ entry.identity = identity;
63
+ const watcher = fs.watch(directory, (_event, filename) => {
64
+ const name = filename?.toString();
65
+ for (const [target, edges] of entry.targets) {
66
+ if (name === undefined || name === target || name === target + '-journal' || name === target + '-wal') {
67
+ for (const edge of edges)
68
+ edge.emit();
69
+ }
70
+ }
71
+ });
72
+ entry.watcher = watcher;
73
+ watcher.on('error', () => {
74
+ if (entry.watcher !== watcher)
75
+ return;
76
+ watcher.close();
77
+ entry.watcher = undefined;
78
+ for (const edges of entry.targets.values())
79
+ for (const edge of edges)
80
+ edge.emit();
81
+ });
82
+ }
83
+ catch {
84
+ entry.watcher?.close();
85
+ entry.watcher = undefined;
86
+ // Missing paths, resource limits, and unsupported filesystems use reconciliation.
87
+ }
88
+ }
89
+ function subscribeHints(file) {
90
+ const directory = path.dirname(file);
91
+ const name = path.basename(file);
92
+ let entry = directories.get(directory);
93
+ if (entry === undefined) {
94
+ entry = { targets: new Map() };
95
+ directories.set(directory, entry);
96
+ const owned = entry;
97
+ entry.timer = setInterval(() => {
98
+ attachDirectory(directory, owned);
99
+ for (const edges of owned.targets.values())
100
+ for (const edge of edges)
101
+ edge.emit();
102
+ }, RECONCILE_MS);
103
+ }
104
+ const edges = new Edges();
105
+ const targets = entry.targets.get(name) ?? new Set();
106
+ targets.add(edges);
107
+ entry.targets.set(name, targets);
108
+ attachDirectory(directory, entry);
109
+ let closed = false;
110
+ return { edges, close() {
111
+ if (closed)
112
+ return;
113
+ closed = true;
114
+ targets.delete(edges);
115
+ if (targets.size === 0)
116
+ entry.targets.delete(name);
117
+ if (entry.targets.size === 0) {
118
+ entry.watcher?.close();
119
+ clearInterval(entry.timer);
120
+ directories.delete(directory);
121
+ }
122
+ } };
123
+ }
124
+ /** Await a shared read without allowing one subscriber to cancel other subscribers. */
125
+ async function abortable(promise, signal) {
126
+ if (signal?.aborted)
127
+ throw signal.reason ?? new Error('Wait aborted');
128
+ if (signal === undefined)
129
+ return promise;
130
+ let abort;
131
+ try {
132
+ return await Promise.race([promise, new Promise((_resolve, reject) => {
133
+ abort = () => reject(signal.reason ?? new Error('Wait aborted'));
134
+ signal.addEventListener('abort', abort, { once: true });
135
+ if (signal.aborted)
136
+ abort();
137
+ })]);
138
+ }
139
+ finally {
140
+ if (abort !== undefined)
141
+ signal.removeEventListener('abort', abort);
142
+ }
143
+ }
144
+ const versions = new Map();
145
+ /** Shared only within this process. Durable state is always read at the storage boundary. */
146
+ export async function observeFileVersion(filePath, kind, read) {
147
+ const file = await canonicalFilePath(filePath);
148
+ const key = kind + '\0' + file;
149
+ let shared = versions.get(key);
150
+ if (shared === undefined) {
151
+ shared = { refs: 0, hints: subscribeHints(file), controller: new AbortController() };
152
+ versions.set(key, shared);
153
+ }
154
+ shared.refs += 1;
155
+ const entry = shared;
156
+ const local = new AbortController();
157
+ const readVersion = async (signal) => {
158
+ const opSignal = signal === undefined ? local.signal : AbortSignal.any([local.signal, signal]);
159
+ if (opSignal.aborted)
160
+ throw opSignal.reason;
161
+ const generation = entry.hints.edges.version;
162
+ if (entry.cached?.generation === generation)
163
+ return entry.cached.value;
164
+ if (entry.pending === undefined) {
165
+ entry.pending = read(file, entry.controller.signal).then((value) => {
166
+ entry.cached = { generation, value };
167
+ return value;
168
+ }).finally(() => { entry.pending = undefined; });
169
+ }
170
+ return abortable(entry.pending, opSignal);
171
+ };
172
+ return {
173
+ get generation() { return entry.hints.edges.version; },
174
+ hinted(since, timeoutMs, signal) {
175
+ return entry.hints.edges.wait(since, timeoutMs, signal === undefined ? local.signal : AbortSignal.any([local.signal, signal]));
176
+ },
177
+ read: readVersion,
178
+ async changed(since, timeoutMs, signal) {
179
+ const timeout = new AbortController();
180
+ const timer = Number.isFinite(timeoutMs) ? setTimeout(() => timeout.abort(), Math.min(Math.max(1, timeoutMs), 2_147_483_647)) : undefined;
181
+ const deadline = Date.now() + Math.max(0, timeoutMs);
182
+ const opSignal = AbortSignal.any([local.signal, timeout.signal, ...(signal === undefined ? [] : [signal])]);
183
+ try {
184
+ for (;;) {
185
+ const edge = entry.hints.edges.version;
186
+ if (!Object.is(await readVersion(opSignal), since))
187
+ return true;
188
+ const remaining = deadline - Date.now();
189
+ if (remaining <= 0)
190
+ return false;
191
+ // Another subscriber's in-flight read may predate the edge captured above.
192
+ if (entry.cached?.generation !== entry.hints.edges.version)
193
+ continue;
194
+ if (!await entry.hints.edges.wait(edge, remaining, opSignal))
195
+ return false;
196
+ }
197
+ }
198
+ catch (error) {
199
+ if (signal?.aborted)
200
+ throw signal.reason ?? error;
201
+ if (local.signal.aborted || timeout.signal.aborted)
202
+ return false;
203
+ throw error;
204
+ }
205
+ finally {
206
+ if (timer !== undefined)
207
+ clearTimeout(timer);
208
+ }
209
+ },
210
+ close() {
211
+ if (local.signal.aborted)
212
+ return;
213
+ local.abort(new Error('Change subscription closed'));
214
+ if (--entry.refs === 0) {
215
+ entry.controller.abort(new Error('Change monitor closed'));
216
+ entry.hints.close();
217
+ versions.delete(key);
218
+ }
219
+ },
220
+ };
221
+ }
222
+ /** File metadata invalidates a ledger projection; it is not presentation evidence. */
223
+ export function observeFileMetadata(file) {
224
+ return observeFileVersion(file, 'metadata', async (target) => {
225
+ try {
226
+ const stat = await fs.promises.stat(target, { bigint: true });
227
+ return [stat.dev, stat.ino, stat.size, stat.mtimeNs, stat.ctimeNs].join(':');
228
+ }
229
+ catch (error) {
230
+ if (error.code === 'ENOENT')
231
+ return 'missing';
232
+ throw error;
233
+ }
234
+ });
235
+ }
package/dist/help.js CHANGED
@@ -1,6 +1,7 @@
1
1
  const COMMANDS = [
2
2
  { names: ['help'], usage: 'help [command]', summary: 'Show the command index or help for one command.' },
3
3
  { names: ['version'], usage: 'version', summary: 'Print the installed version.', hiddenFromIndex: true },
4
+ { names: ['mcp-server'], usage: 'mcp-server', summary: 'Start the MCP stdio server.', usesSquare: true, hiddenFromIndex: true, details: ['Set SQUARE_LOCATION and SQUARE_PARTICIPANT_NAME, or pass --location and --as before the command.'] },
4
5
  {
5
6
  names: ['build'], usage: 'build [--cap <N|unlimited>] [--template <name>] [--throttle N] [-f] < body.md', usesSquare: true, group: 'host',
6
7
  summary: 'Create a square from Markdown on stdin.',
@@ -18,7 +19,7 @@ const COMMANDS = [
18
19
  {
19
20
  names: ['express'], usage: '--as <name> express [-f|--force] [--no-wait] (--mention <name>... | --no-mention | --bell) [--reply <activity-id>] <activity | ->', usesSquare: true, group: 'participant',
20
21
  summary: 'Speak, gesture, or do both.',
21
- details: ['Reach:', ' --mention <name> Address one participant; repeat for multiple participants. The body stays unchanged.', ' --no-mention Land a bare activity explicitly; listen opts someone into future bare delivery.', " --bell Call every participant's attention to this activity.", '', 'Options:', ' -f, --force Express without first catching unread activity or attention etiquette.', ' --no-wait If held or throttled, save a draft and return.', ' --reply <activity-id> Mark this activity as a reply to an earlier activity (for example act/12).'],
22
+ details: ['Reach:', ' --mention <name> Address one participant; repeat for multiple participants. The body stays unchanged.', ' --no-mention Land a bare activity explicitly; @names stay plain text. listen opts someone into future bare delivery.', " --bell Call every participant's attention to this activity.", ' @name An @name in the body also addresses that standing participant; `backticks` keep @text literal.', '', 'Options:', ' -f, --force Express without first catching unread activity or attention etiquette.', ' --no-wait If held or throttled, save a draft and return instead of waiting. A held wait gives up on its own after 10 minutes.', ' --reply <activity-id> Mark this activity as a reply to an earlier activity (for example act/12).'],
22
23
  },
23
24
  { names: ['listen'], usage: '--as <name> listen <participant>', usesSquare: true, group: 'participant', summary: 'Turn an ear toward one participant\'s future bare says.' },
24
25
  { names: ['ignore'], usage: '--as <name> ignore <participant>', usesSquare: true, group: 'participant', summary: 'Ignore one participant\'s future mentions and bare says.' },
@@ -28,7 +29,7 @@ const COMMANDS = [
28
29
  summary: 'Catch directed conversation since you last looked.',
29
30
  details: ['Modes:', ' --id <activity-id> Catch exactly one activity addressed to you, with its full body; implies --now.', ' Marks only this activity seen; repeats return it again. No filters, limit, idle or replace.', ' history stays read-only; catch --id consumes the selected activity.', ' --now Catch up immediately.', ' --idle <duration> Wait for something relevant, or for quiet to last this long.', '', 'Attention:', ' Mentions arrive when you are addressed; bells arrive for everyone; bare says require listen.', ' listen and ignore are future-only and fixed when each say lands.', '', 'Filters:', ' --from <names> Match only comma-separated participants.', ' --mention Match direct attention for your current participant (and bells).', ' --limit <count> Return one page (default 10, maximum 100).', '', 'Recovery:', ' --replace Replace another active catch for this participant.'],
30
31
  },
31
- { names: ['done'], usage: '--as <name> done [final | -]', usesSquare: true, group: 'participant', summary: 'Step out, optionally leaving a final note.' },
32
+ { names: ['done'], usage: '--as <name> done', usesSquare: true, group: 'participant', summary: 'Step out of the square.', details: ['done takes no message. Use express before leaving to send one.'] },
32
33
  {
33
34
  names: ['stream'], usage: 'stream [--ndjson [--for <name>] [--last <N> | --after <id>]]', usesSquare: true, hiddenFromIndex: true,
34
35
  summary: 'Follow activity without consuming participant presence.',
@@ -43,7 +44,7 @@ const COMMANDS = [
43
44
  {
44
45
  names: ['history'], usage: 'history [filters] [output]', usesSquare: true, group: 'participant',
45
46
  summary: 'Read or search the archive without changing what you have caught.',
46
- details: ['Filters:', ' --from <names> Match activities from participants.', ' --since <time> Match activities after a time.', ' --grep <regex> | --fixed <s> Search activity ids, participants, and original bodies.', ' --mention <name> Match direct attention for a participant.', ' --at <ids> Center on comma-separated activity ids; may repeat.', ' -B, -A, -C <N> Set non-negative context around every --at coordinate.', ' --before <id> Read the page immediately before an activity.', ' --after <id> Read the page immediately after an activity.', '', 'Results:', ' --limit <N> Page size (default 10, maximum 100).', ' --order <asc|desc> Set display order (default oldest first).', '', 'Output:', ' --no-truncate --json --format <fields>', ' One result shows its full body; multiple results use previews.', ' --no-truncate shows every original body.'],
47
+ details: ['Optional --as is accepted but does not change the archive or claim a name.', '', 'Filters:', ' --from <names> Match activities from participants.', ' --since <time> Match activities after a time.', ' --grep <regex> | --fixed <s> Search activity ids, participants, and original bodies.', ' --mention <name> Match direct attention for a participant.', ' --at <ids> Center on comma-separated activity ids; may repeat.', ' -B, -A, -C <N> Set non-negative context around every --at coordinate.', ' --before <id> Read the page immediately before an activity.', ' --after <id> Read the page immediately after an activity.', '', 'Results:', ' --limit <N> Page size (default 10, maximum 100).', ' --order <asc|desc> Set display order (default oldest first).', '', 'Output:', ' --no-truncate --json --format <fields>', ' One result shows its full body; multiple results use previews.', ' --no-truncate shows every original body.'],
47
48
  },
48
49
  { names: ['status'], usage: '[--as <name>] status', usesSquare: true, group: 'participant', summary: 'Show who is present and what happened most recently.' },
49
50
  {
@@ -125,22 +126,17 @@ export function renderSubcommandHelp(command) {
125
126
  }
126
127
  export function helpRequest(rawArgs) {
127
128
  const args = [];
128
- let hasExplicitName = false;
129
129
  for (let index = 0; index < rawArgs.length; index++) {
130
130
  const arg = rawArgs[index];
131
131
  if (arg === '--location' || arg === '--as') {
132
132
  const value = rawArgs[index + 1];
133
133
  if (value === undefined || value.startsWith('--'))
134
134
  return undefined;
135
- if (arg === '--as')
136
- hasExplicitName = true;
137
135
  index++;
138
136
  continue;
139
137
  }
140
138
  args.push(arg);
141
139
  }
142
- if (hasExplicitName && args[0] === 'history')
143
- return undefined;
144
140
  if (isHelpFlag(args[0]))
145
141
  return {};
146
142
  if (args[0] === 'help') {
@@ -1,24 +1,27 @@
1
- import type { HostLedgerPort, HostLedgerScope, PresenceRecord, PresenceKey, PresenceLookup, PresenceResult, PresenceClaimResult, EvidenceRecord, EvidenceClaim, EvidenceRelease, EvidenceLookup, EvidenceGc, ClaimResult, ReconcileBindingInput, ReconcileBindingResult, WakeDispatchClaim, WakeDispatchClaimInput, WakeDispatchReleaseInput, WakeDispatchTransitionInput, WakeAttemptLookup } from './host-ledger.js';
1
+ import { type VersionObserver } from './file-changes.js';
2
+ import type { HostLedgerPort, PresenceRecord, PresenceKey, PresenceLookup, PresenceResult, PresenceClaimResult, EvidenceRecord, EvidenceClaim, EvidenceRelease, EvidenceLookup, EvidenceGc, ClaimResult, WakeDispatchClaim, WakeDispatchClaimInput, WakeDispatchReleaseInput, WakeDispatchTransitionInput, WakeAttemptLookup } from './host-ledger.js';
2
3
  export interface HostLedgerFileAdapterOptions {
3
- userPath?: string;
4
- localPath?: string;
5
- writableScope?: HostLedgerScope;
6
- readableScopes?: readonly HostLedgerScope[];
4
+ rootPath?: string;
7
5
  claimsPath?: string;
8
6
  now?: () => number;
9
7
  }
10
8
  export declare class FileHostLedgerPort implements HostLedgerPort {
11
- private readonly user;
12
- private readonly local;
13
- private readonly writable;
14
- private readonly readable;
9
+ private readonly root;
15
10
  private readonly claims;
16
11
  private readonly clock;
17
12
  constructor(o?: HostLedgerFileAdapterOptions);
18
13
  private file;
19
- claimPresence(i: PresenceRecord, scope?: HostLedgerScope): Promise<PresenceClaimResult>;
20
- ensurePresence(i: PresenceRecord, scope?: HostLedgerScope): Promise<PresenceResult>;
14
+ /** Invalidation only: callers still use the ledger projections as their authority. */
15
+ observeChanges(): Promise<{
16
+ presence: VersionObserver<string>;
17
+ evidence: VersionObserver<string>;
18
+ }>;
19
+ /** Time can expire bindings/evidence even when their files do not change. */
20
+ nextExpiry(session: string): Promise<number>;
21
+ claimPresence(i: PresenceRecord, signal?: AbortSignal): Promise<PresenceClaimResult>;
22
+ ensurePresence(i: PresenceRecord): Promise<PresenceResult>;
21
23
  removePresence(i: PresenceKey): Promise<void>;
24
+ removePresenceIfUnchanged(i: PresenceRecord): Promise<boolean>;
22
25
  listPresence(i?: PresenceLookup): Promise<readonly PresenceRecord[]>;
23
26
  claimWakeDispatch(i: WakeDispatchClaimInput): Promise<WakeDispatchClaim>;
24
27
  transitionWakeDispatch(i: WakeDispatchTransitionInput): Promise<boolean>;
@@ -34,6 +37,5 @@ export declare class FileHostLedgerPort implements HostLedgerPort {
34
37
  }): Promise<void>;
35
38
  listEvidence(i?: EvidenceLookup): Promise<readonly EvidenceRecord[]>;
36
39
  gcEvidence(i: EvidenceGc): Promise<void>;
37
- reconcileBinding(i?: ReconcileBindingInput): Promise<ReconcileBindingResult>;
38
40
  }
39
- export declare function createHostLedgerPort(o?: HostLedgerFileAdapterOptions): HostLedgerPort;
41
+ export declare function createHostLedgerPort(o?: HostLedgerFileAdapterOptions): FileHostLedgerPort;
@@ -1,14 +1,18 @@
1
1
  import { promises as fs } from 'node:fs';
2
2
  import fsSync from 'node:fs';
3
- import os from 'node:os';
4
3
  import path from 'node:path';
4
+ import { randomUUID } from 'node:crypto';
5
+ import { setTimeout as sleep } from 'node:timers/promises';
5
6
  import { withFileLock as acquireFileLock } from './file-lock.js';
6
7
  import { nameKey } from './model.js';
7
- import { isCurrentlyJoined } from './runtime.js';
8
+ import { hostLedgerRoot } from './host-ledger-root.js';
9
+ import { observeFileMetadata } from './file-changes.js';
8
10
  const LOCK = { retryMs: 10 };
9
11
  const RETENTION = 7 * 86400000;
10
12
  // Prepare before locking: lock databases and their journals are local runtime data too.
11
13
  async function withFileLock(file, options, fn) {
14
+ if (options.signal?.aborted)
15
+ throw options.signal.reason ?? new Error('File lock acquisition aborted');
12
16
  const directory = path.dirname(file);
13
17
  await fs.mkdir(directory, { recursive: true });
14
18
  try {
@@ -56,8 +60,35 @@ catch (e) {
56
60
  return [];
57
61
  throw e;
58
62
  } }
59
- async function write(file, rows) { await fs.mkdir(path.dirname(file), { recursive: true }); const tmp = `${file}.${process.pid}.${Date.now()}.tmp`; await fs.writeFile(tmp, rows.length ? rows.map(row => JSON.stringify(row)).join('\n') + '\n' : '', { mode: 0o600 }); await fs.rename(tmp, file); }
60
- function k(v) { return JSON.stringify([v.location, nameKey(v.participant), v.session, v.channel, v.activity, v.kind, v.scope ?? null]); }
63
+ /** Keep the old ledger intact while Windows readers briefly deny replacement.
64
+ * The owning file lock stays held throughout; never unlink the destination. */
65
+ async function write(file, rows, signal) {
66
+ await fs.mkdir(path.dirname(file), { recursive: true });
67
+ const tmp = `${file}.${process.pid}.${randomUUID()}.tmp`;
68
+ try {
69
+ if (signal?.aborted)
70
+ throw signal.reason ?? new Error('Ledger write aborted');
71
+ await fs.writeFile(tmp, rows.length ? rows.map((row) => JSON.stringify(row)).join('\n') + '\n' : '', { mode: 0o600, flag: 'wx' });
72
+ for (let attempt = 0;; attempt += 1) {
73
+ if (signal?.aborted)
74
+ throw signal.reason ?? new Error('Ledger write aborted');
75
+ try {
76
+ await fs.rename(tmp, file);
77
+ return;
78
+ }
79
+ catch (error) {
80
+ const code = error.code;
81
+ if (attempt >= 5 || (code !== 'EPERM' && code !== 'EACCES' && code !== 'EBUSY'))
82
+ throw error;
83
+ await sleep(10 * 2 ** attempt, undefined, { signal });
84
+ }
85
+ }
86
+ }
87
+ finally {
88
+ await fs.rm(tmp, { force: true }).catch(() => undefined);
89
+ }
90
+ }
91
+ function k(v) { return JSON.stringify([v.location, nameKey(v.participant), v.session, v.channel, v.activity, v.kind]); }
61
92
  function evidenceKey(v) { return JSON.stringify([v.location, nameKey(v.participant), v.session, v.activity, v.kind, v.attemptN ?? null]); }
62
93
  async function readClaims(file, now) { try {
63
94
  return (await fs.readFile(file, 'utf8')).split('\n').flatMap(line => { try {
@@ -81,50 +112,103 @@ function processAlive(pid) { if (pid === undefined)
81
112
  catch (error) {
82
113
  return error.code === 'ESRCH' ? false : true;
83
114
  } }
84
- async function writeClaims(file, rows) { await fs.mkdir(path.dirname(file), { recursive: true }); const tmp = `${file}.${process.pid}.${Date.now()}.tmp`; await fs.writeFile(tmp, rows.length ? rows.map(row => JSON.stringify(row)).join('\n') + '\n' : '', { mode: 0o600 }); await fs.rename(tmp, file); }
115
+ function writeClaims(file, rows) { return write(file, rows); }
85
116
  export class FileHostLedgerPort {
86
- user;
87
- local;
88
- writable;
89
- readable;
117
+ root;
90
118
  claims;
91
119
  clock;
92
- constructor(o = {}) { const testRoot = process.env.SQUARE_REGISTRY ? path.dirname(process.env.SQUARE_REGISTRY) : undefined; this.user = canonRoot(o.userPath ?? (testRoot ?? path.join(os.homedir(), '.square', 'host-ledger'))); this.local = canonRoot(o.localPath ?? (testRoot ?? path.join(process.cwd(), '.square', 'host-ledger'))); this.writable = o.writableScope ?? 'local'; this.readable = o.readableScopes ?? ['user', 'local']; this.claims = canonRoot(o.claimsPath ?? path.join(this.user, 'wake-claims.ndjsonl')); this.clock = o.now ?? Date.now; }
93
- file(s, f) { return path.join(s === 'user' ? this.user : this.local, `${f}.ndjsonl`); }
94
- async claimPresence(i, scope = this.writable) { const location = await canon(i.location), r = { ...i, location, scope, ...(scope === 'local' ? { route: undefined } : {}), updatedAt: i.updatedAt ?? this.clock(), v: 1 }, lock = path.join(this.user, 'presence-claim.lock'); try {
95
- return await withFileLock(lock, LOCK, async () => { const candidates = []; for (const s of ['user', 'local']) {
96
- for (const row of await read(this.file(s, 'presence'), this.clock(), true)) {
97
- if ((row.scope ?? s) !== s || row.location !== location || nameKey(row.participant) !== nameKey(r.participant))
98
- continue;
99
- candidates.push(s === 'user' ? row : { ...row, route: undefined });
120
+ constructor(o = {}) {
121
+ this.root = canonRoot(o.rootPath ?? hostLedgerRoot());
122
+ this.claims = canonRoot(o.claimsPath ?? path.join(this.root, 'wake-claims.ndjsonl'));
123
+ this.clock = o.now ?? Date.now;
124
+ }
125
+ file(kind) { return path.join(this.root, `${kind}.ndjsonl`); }
126
+ /** Invalidation only: callers still use the ledger projections as their authority. */
127
+ async observeChanges() {
128
+ const presence = await observeFileMetadata(this.file('presence'));
129
+ try {
130
+ return { presence, evidence: await observeFileMetadata(this.file('evidence')) };
131
+ }
132
+ catch (error) {
133
+ presence.close();
134
+ throw error;
135
+ }
136
+ }
137
+ /** Time can expire bindings/evidence even when their files do not change. */
138
+ async nextExpiry(session) {
139
+ const now = this.clock();
140
+ const rows = [...await read(this.file('presence'), now, true),
141
+ ...await read(this.file('evidence'), now, true)].filter((row) => row.session === session);
142
+ let next = Infinity;
143
+ for (const row of rows) {
144
+ const at = 'updatedAt' in row ? row.updatedAt : 'at' in row ? row.at : undefined;
145
+ if (typeof at === 'number') {
146
+ if (at > now)
147
+ next = Math.min(next, at);
148
+ if (at + RETENTION + 1 > now && !('kind' in row && row.kind === 'wake' && row.outcome === 'accepted'))
149
+ next = Math.min(next, at + RETENTION + 1);
100
150
  }
101
- } const existing = candidates.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0))[0]; if (existing !== undefined) {
102
- if (existing.session === r.session)
103
- return { status: 'owned', record: existing };
104
- return { status: 'busy', record: existing };
105
- } const f = this.file(scope, 'presence'); await withFileLock(f + '.lock', LOCK, async () => write(f, [...(await read(f, this.clock(), true)).filter(x => k({ ...x, scope: x.scope ?? scope }) !== k(r)), r])); return { status: 'acquired', record: r }; });
151
+ if ('expiresAt' in row && typeof row.expiresAt === 'number' && row.expiresAt > now)
152
+ next = Math.min(next, row.expiresAt);
153
+ }
154
+ return next;
106
155
  }
107
- catch (error) {
108
- return { status: 'degraded', record: r, error };
109
- } }
110
- async ensurePresence(i, scope = this.writable) { const r = { ...i, location: await canon(i.location), scope, ...(scope === 'local' ? { route: undefined } : {}), updatedAt: i.updatedAt ?? this.clock(), v: 1 }; const f = this.file(scope, 'presence'); try {
111
- await withFileLock(f + '.lock', LOCK, async () => write(f, [...(await read(f, this.clock(), true)).filter(x => k({ ...x, scope: x.scope ?? scope }) !== k(r)), r]));
112
- return { status: 'ensured', record: r };
156
+ async claimPresence(i, signal) {
157
+ const location = await canon(i.location);
158
+ const record = { ...i, location, updatedAt: i.updatedAt ?? this.clock(), v: 1 };
159
+ try {
160
+ return await withFileLock(path.join(this.root, 'presence-claim.lock'), { ...LOCK, signal }, async () => {
161
+ const file = this.file('presence');
162
+ // The authoritative read, ownership check, and write share the presence file lock:
163
+ // a concurrent ensurePresence/removePresence between them must never be overwritten
164
+ // by a stale snapshot.
165
+ return withFileLock(file + '.lock', { ...LOCK, signal }, async () => {
166
+ const rows = await read(file, this.clock(), true);
167
+ const existing = rows.filter((row) => row.location === location && nameKey(row.participant) === nameKey(record.participant)).sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0))[0];
168
+ if (existing !== undefined)
169
+ return existing.session === record.session ? { status: 'owned', record: existing } : { status: 'busy', record: existing };
170
+ await write(file, [...rows, record], signal);
171
+ return { status: 'acquired', record };
172
+ });
173
+ });
174
+ }
175
+ catch (error) {
176
+ return { status: 'degraded', record, error };
177
+ }
178
+ }
179
+ async ensurePresence(i) {
180
+ const record = { ...i, location: await canon(i.location), updatedAt: i.updatedAt ?? this.clock(), v: 1 };
181
+ try {
182
+ await withFileLock(this.file('presence') + '.lock', LOCK, async () => write(this.file('presence'), [...(await read(this.file('presence'), this.clock(), true)).filter((row) => k(row) !== k(record)), record]));
183
+ return { status: 'ensured', record };
184
+ }
185
+ catch (error) {
186
+ return { status: 'degraded', record, error };
187
+ }
188
+ }
189
+ async removePresence(i) {
190
+ const record = { ...i, location: await canon(i.location) };
191
+ await withFileLock(this.file('presence') + '.lock', LOCK, async () => write(this.file('presence'), (await read(this.file('presence'), this.clock(), true)).filter((row) => k(row) !== k(record))));
192
+ }
193
+ async removePresenceIfUnchanged(i) {
194
+ const record = { ...i, location: await canon(i.location) };
195
+ return withFileLock(this.file('presence') + '.lock', LOCK, async () => {
196
+ const file = this.file('presence');
197
+ const rows = await read(file, this.clock(), true);
198
+ const matches = (row) => k(row) === k(record)
199
+ && (row.epoch ?? undefined) === (record.epoch ?? undefined)
200
+ && (row.updatedAt ?? undefined) === (record.updatedAt ?? undefined);
201
+ const current = rows.findLast(matches);
202
+ if (current === undefined)
203
+ return false;
204
+ await write(file, rows.filter((row) => row !== current));
205
+ return true;
206
+ });
207
+ }
208
+ async listPresence(i = {}) {
209
+ const location = i.location === undefined ? undefined : await canon(i.location);
210
+ return (await read(this.file('presence'), i.now ?? this.clock())).filter((row) => (!location || row.location === location) && (!i.participant || nameKey(row.participant) === nameKey(i.participant)) && (!i.session || row.session === i.session)).map(({ scope: _scope, ...row }) => row);
113
211
  }
114
- catch (error) {
115
- return { status: 'degraded', record: r, error };
116
- } }
117
- async removePresence(i) { const r = { ...i, location: await canon(i.location), scope: this.writable }, f = this.file(this.writable, 'presence'), shared = this.user === this.local; await withFileLock(f + '.lock', LOCK, async () => write(f, (await read(f, this.clock(), true)).filter(x => { const rowScope = x.scope ?? this.writable; return shared ? k({ ...x, scope: rowScope }) !== k({ ...r, scope: rowScope }) : k({ ...x, scope: rowScope }) !== k(r); }))); }
118
- async listPresence(i = {}) { const loc = i.location === undefined ? undefined : await canon(i.location), m = new Map(); for (const s of i.scopes ?? this.readable)
119
- for (const r of await read(this.file(s, 'presence'), i.now ?? this.clock())) {
120
- if ((r.scope ?? s) !== s || loc && r.location !== loc || i.participant && nameKey(r.participant) !== nameKey(i.participant) || i.session && r.session !== i.session)
121
- continue;
122
- const x = s === 'user' ? r : { ...r, route: undefined }, key = k({ ...x, scope: undefined }), old = m.get(key);
123
- if (old?.scope === 'user' && s === 'local')
124
- continue;
125
- if (!old || s === 'user' || x.updatedAt >= old.record.updatedAt)
126
- m.set(key, { record: x, scope: s });
127
- } return [...m.values()].map(({ record }) => { const { scope: _scope, ...publicRecord } = record; return publicRecord; }); }
128
212
  async claimWakeDispatch(i) { const location = await canon(i.attention.squarePath), key = JSON.stringify([location, nameKey(i.attention.recipient), i.attention.actIndex, 'wake']), at = i.at ?? this.clock(); return withFileLock(`${this.claims}.lock`, LOCK, async () => { const rows = await readClaims(this.claims, at), existing = rows.find((row) => row.attention_key === key); if (existing?.phase === 'dispatching') {
129
213
  const alive = processAlive(existing.ownerPid);
130
214
  if (existing.expiresAt <= at || alive === false)
@@ -145,7 +229,7 @@ export class FileHostLedgerPort {
145
229
  async listWakeAttempts(i = {}) { const attention = i.attention; return this.listEvidence({ kind: 'wake', ...(attention === undefined ? {} : { location: await canon(attention.squarePath), participant: attention.recipient, activity: `act/${attention.actIndex}` }), ...(i.session === undefined ? {} : { session: i.session }), now: i.now }); }
146
230
  async appendWakeAttempt(i) { if (i.kind !== 'wake')
147
231
  throw new Error('Wake attempt rows require wake evidence kind'); await this.appendEvidence(i); }
148
- async claimEvidence(i) { const f = this.file('user', 'evidence'), at = i.now ?? this.clock(), { leaseMs, claimToken: requestedToken, ...claim } = i, r = { ...claim, location: await canon(i.location), outcome: 'dispatching', at, expiresAt: at + leaseMs, v: 1 }; try {
232
+ async claimEvidence(i) { const f = this.file('evidence'), at = i.now ?? this.clock(), { leaseMs, claimToken: requestedToken, ...claim } = i, r = { ...claim, location: await canon(i.location), outcome: 'dispatching', at, expiresAt: at + leaseMs, v: 1 }; try {
149
233
  return await withFileLock(f + '.lock', LOCK, async () => { const all = await read(f, at, true), matching = all.filter(x => x.kind === r.kind && x.location === r.location && nameKey(x.participant) === nameKey(r.participant) && x.activity === r.activity && x.session === r.session), old = matching.findLast(x => x.outcome === 'dispatching'); if (old !== undefined && typeof old.expiresAt === 'number' && old.expiresAt > at)
150
234
  return { status: 'busy', record: old }; const delivered = (r.kind === 'wake' && matching.some(x => x.outcome === 'accepted')) || (r.kind === 'presentation' && matching.some(x => x.outcome === 'presented')); if (delivered)
151
235
  return { status: 'delivered', record: matching.findLast(x => x.outcome === 'accepted' || x.outcome === 'presented') }; const index = Number(r.activity.slice(4)), attentionKey = JSON.stringify([r.location, nameKey(r.participant), index, r.session ?? null, 'wake']), dispatchToken = r.kind === 'wake' && typeof requestedToken === 'string' && Number.isInteger(index) && (await readClaims(this.claims, at)).some((row) => row.leaseId === requestedToken && row.attention_key === attentionKey) ? requestedToken : undefined, claimToken = dispatchToken ?? `${process.pid}-${at}-${Math.random().toString(36).slice(2)}`, row = { ...r, claimToken }; await write(f, [...all.filter(x => !(x.kind === r.kind && x.location === r.location && nameKey(x.participant) === nameKey(r.participant) && x.activity === r.activity && x.session === r.session && (x.outcome === 'dispatching' || x.outcome === 'released'))), row]); return { status: 'acquired', claimToken }; });
@@ -153,44 +237,12 @@ export class FileHostLedgerPort {
153
237
  catch (error) {
154
238
  return { status: 'degraded', error };
155
239
  } }
156
- async releaseEvidence(i) { const f = this.file('user', 'evidence'), location = await canon(i.location), at = i.now ?? this.clock(); await withFileLock(f + '.lock', LOCK, async () => { const all = await read(f, at, true), same = (x) => x.kind === i.kind && x.location === location && nameKey(x.participant) === nameKey(i.participant) && x.activity === i.activity && x.session === i.session, current = all.findLast(x => same(x) && x.outcome === 'dispatching'); if (current === undefined || current.claimToken !== i.claimToken)
240
+ async releaseEvidence(i) { const f = this.file('evidence'), location = await canon(i.location), at = i.now ?? this.clock(); await withFileLock(f + '.lock', LOCK, async () => { const all = await read(f, at, true), same = (x) => x.kind === i.kind && x.location === location && nameKey(x.participant) === nameKey(i.participant) && x.activity === i.activity && x.session === i.session, current = all.findLast(x => same(x) && x.outcome === 'dispatching'); if (current === undefined || current.claimToken !== i.claimToken)
157
241
  return; const tombstone = { ...current, outcome: 'released', at, ...(i.routeKind === undefined ? {} : { routeKind: i.routeKind }), ...(i.attemptN === undefined ? {} : { attemptN: i.attemptN }), ...(i.signature === undefined ? {} : { signature: i.signature }), ...(i.message === undefined ? {} : { message: i.message }), ...(i.diagnostic === undefined ? {} : { diagnostic: i.diagnostic }), expiresAt: undefined }; delete tombstone.expiresAt; await write(f, [...all.filter(x => x !== current), tombstone]); }); }
158
- async appendEvidence(i) { const f = this.file('user', 'evidence'), r = { ...i, location: await canon(i.location), at: i.at ?? this.clock(), v: 1 }; const token = i.claimToken; if (typeof token !== 'string' || token.length === 0)
242
+ async appendEvidence(i) { const f = this.file('evidence'), r = { ...i, location: await canon(i.location), at: i.at ?? this.clock(), v: 1 }; const token = i.claimToken; if (typeof token !== 'string' || token.length === 0)
159
243
  return; await withFileLock(f + '.lock', LOCK, async () => { const all = await read(f, r.at, true); const same = (x) => x.kind === r.kind && x.location === r.location && nameKey(x.participant) === nameKey(r.participant) && x.activity === r.activity && x.session === r.session; const current = all.findLast(x => same(x) && x.outcome === 'dispatching'); if (current === undefined || current.claimToken !== token)
160
244
  return; const terminalKey = (x) => same(x) && x.attemptN === r.attemptN && x.outcome !== 'dispatching' && x.outcome !== 'released'; await write(f, [...all.filter(x => x !== current && !terminalKey(x)), r]); }); }
161
- async listEvidence(i = {}) { const loc = i.location === undefined ? undefined : await canon(i.location); return (await read(this.file('user', 'evidence'), i.now ?? this.clock())).filter(r => (i.includeReleased === true || r.outcome !== 'released') && (!loc || r.location === loc) && (!i.participant || nameKey(r.participant) === nameKey(i.participant)) && (!i.session || r.session === i.session) && (!i.activity || r.activity === i.activity) && (!i.kind || r.kind === i.kind)); }
162
- async gcEvidence(i) { const f = this.file('user', 'evidence'), pending = new Set(i.pendingWakeActivities ?? []); await withFileLock(f + '.lock', LOCK, async () => write(f, (await read(f, this.clock(), true)).filter(r => r.at >= i.before || r.kind === 'wake' && r.outcome === 'accepted' && pending.has(r.activity)))); }
163
- async reconcileBinding(i = {}) { let b = []; try {
164
- b = [...await this.listPresence({ scopes: i.scopes, now: i.now })];
165
- if (i.artifact) {
166
- const { state } = await i.artifact.read();
167
- const stale = b.filter(x => !isCurrentlyJoined(state.acts, x.participant));
168
- for (const x of stale) {
169
- for (const scope of i.scopes ?? this.readable) {
170
- const remover = new FileHostLedgerPort({ userPath: this.user, localPath: this.local, writableScope: scope, readableScopes: [scope], now: this.clock });
171
- await remover.removePresence(x);
172
- }
173
- }
174
- b = b.filter(x => !stale.includes(x));
175
- }
176
- const latest = new Map();
177
- for (const x of b) {
178
- const key = JSON.stringify([x.location, nameKey(x.participant), x.session]);
179
- const current = latest.get(key);
180
- if (current === undefined || ((x.updatedAt ?? 0) > (current.updatedAt ?? 0)))
181
- latest.set(key, x);
182
- }
183
- const winners = [...latest.values()];
184
- const u = new FileHostLedgerPort({ userPath: this.user, localPath: this.local, writableScope: 'user', readableScopes: ['user'], now: this.clock });
185
- for (const x of winners) {
186
- const result = await u.ensurePresence(x);
187
- if (result.status === 'degraded')
188
- return { status: 'degraded', bindings: winners, error: result.error };
189
- }
190
- return { status: 'reconciled', bindings: winners };
191
- }
192
- catch (error) {
193
- return { status: 'degraded', bindings: b, error };
194
- } }
245
+ async listEvidence(i = {}) { const loc = i.location === undefined ? undefined : await canon(i.location); return (await read(this.file('evidence'), i.now ?? this.clock())).filter(r => (i.includeReleased === true || r.outcome !== 'released') && (!loc || r.location === loc) && (!i.participant || nameKey(r.participant) === nameKey(i.participant)) && (!i.session || r.session === i.session) && (!i.activity || r.activity === i.activity) && (!i.kind || r.kind === i.kind)); }
246
+ async gcEvidence(i) { const f = this.file('evidence'), pending = new Set(i.pendingWakeActivities ?? []); await withFileLock(f + '.lock', LOCK, async () => write(f, (await read(f, this.clock(), true)).filter(r => r.at >= i.before || r.kind === 'wake' && r.outcome === 'accepted' && pending.has(r.activity)))); }
195
247
  }
196
248
  export function createHostLedgerPort(o = {}) { return new FileHostLedgerPort(o); }
@@ -0,0 +1,2 @@
1
+ /** Resolve the single user-level host ledger root. */
2
+ export declare function hostLedgerRoot(env?: NodeJS.ProcessEnv): string;
@@ -0,0 +1,9 @@
1
+ import path from 'node:path';
2
+ import { homedir } from 'node:os';
3
+ /** Resolve the single user-level host ledger root. */
4
+ export function hostLedgerRoot(env = process.env) {
5
+ return env.SQUARE_HOST_LEDGER_ROOT
6
+ ?? (env.SQUARE_REGISTRY === undefined
7
+ ? path.join(homedir(), '.square', 'host-ledger')
8
+ : path.dirname(env.SQUARE_REGISTRY));
9
+ }