@astrosheep/square 0.3.45 → 0.3.47

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.
@@ -0,0 +1,141 @@
1
+ import fs from 'node:fs';
2
+ import { isWakeRouteKind, SquareError, } from './model.js';
3
+ import { parseActivityId } from './square-core.js';
4
+ const guideNames = ['participant', 'architect', 'brainstorm'];
5
+ const guideContents = new Map(await Promise.all(guideNames.map(async (name) => [name, (await fs.promises.readFile(new URL(`../guides/${name}.md`, import.meta.url), 'utf8')).trim()])));
6
+ function invalidArtifact(detail) {
7
+ return new SquareError('invalid_args', `Invalid square artifact: ${detail}`);
8
+ }
9
+ function isObject(value) {
10
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
11
+ }
12
+ function hasExactKeys(value, required, optional = []) {
13
+ const allowed = new Set([...required, ...optional]);
14
+ return required.every((key) => Object.hasOwn(value, key)) && Object.keys(value).every((key) => allowed.has(key));
15
+ }
16
+ function isFiniteNumber(value) {
17
+ return typeof value === 'number' && Number.isFinite(value);
18
+ }
19
+ function isNonNegativeInteger(value) {
20
+ return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
21
+ }
22
+ function isNonblankString(value) {
23
+ return typeof value === 'string' && value.length > 0;
24
+ }
25
+ function isStringArray(value) {
26
+ return Array.isArray(value) && value.every((item) => typeof item === 'string');
27
+ }
28
+ function validateObservation(value) {
29
+ return isObject(value) && hasExactKeys(value, ['state', 'at']) && value.state === 'seen' && isFiniteNumber(value.at);
30
+ }
31
+ function validateWatchLease(value) {
32
+ if (!isObject(value) || !hasExactKeys(value, ['leaseId', 'heartbeatAt', 'expiresAt'], ['filter'])
33
+ || !isNonblankString(value.leaseId) || !isFiniteNumber(value.heartbeatAt)
34
+ || !isFiniteNumber(value.expiresAt) || value.expiresAt < value.heartbeatAt)
35
+ return false;
36
+ if (value.filter === undefined)
37
+ return true;
38
+ return isObject(value.filter) && hasExactKeys(value.filter, [], ['participants', 'mention'])
39
+ && (value.filter.participants === undefined || isStringArray(value.filter.participants))
40
+ && (value.filter.mention === undefined || typeof value.filter.mention === 'string');
41
+ }
42
+ function validateRecord(value, item) {
43
+ return isObject(value) && Object.entries(value).every(([key, candidate]) => key.length > 0 && item(candidate));
44
+ }
45
+ function validateRuntime(value) {
46
+ return isObject(value) && hasExactKeys(value, ['nextActIndex', 'observations', 'leases'])
47
+ && isNonNegativeInteger(value.nextActIndex)
48
+ && validateRecord(value.observations, (candidate) => isObject(candidate)
49
+ && Object.entries(candidate).every(([id, observation]) => parseActivityId(id) !== undefined && validateObservation(observation)))
50
+ && validateRecord(value.leases, validateWatchLease);
51
+ }
52
+ function validateAssignedRuntimeReferences(runtime) {
53
+ for (const observations of Object.values(runtime.observations)) {
54
+ for (const id of Object.keys(observations)) {
55
+ const index = parseActivityId(id);
56
+ if (index === undefined)
57
+ return 'malformed';
58
+ if (index >= runtime.nextActIndex)
59
+ return 'future';
60
+ }
61
+ }
62
+ return 'ok';
63
+ }
64
+ function validateActor(value, required) {
65
+ return required ? isNonblankString(value) : value === undefined || isNonblankString(value);
66
+ }
67
+ function validateStoredAct(value) {
68
+ if (!isObject(value) || typeof value.kind !== 'string' || !isNonNegativeInteger(value.index) || !isFiniteNumber(value.at))
69
+ return false;
70
+ switch (value.kind) {
71
+ case 'join': return hasExactKeys(value, ['kind', 'actor', 'at', 'index']) && validateActor(value.actor, true);
72
+ case 'done': return hasExactKeys(value, ['kind', 'actor', 'at', 'index'], ['body']) && validateActor(value.actor, true) && (value.body === undefined || typeof value.body === 'string');
73
+ case 'say': return hasExactKeys(value, ['kind', 'actor', 'at', 'body', 'index'], ['mentions', 'reach', 'reply'])
74
+ && validateActor(value.actor, true) && typeof value.body === 'string'
75
+ && (value.mentions === undefined || isStringArray(value.mentions)) && (value.reach === undefined || value.reach === 'bell')
76
+ && (value.reply === undefined || isNonNegativeInteger(value.reply));
77
+ case 'hold': return hasExactKeys(value, ['kind', 'at', 'index'], ['actor', 'body']) && validateActor(value.actor, false) && (value.body === undefined || typeof value.body === 'string');
78
+ case 'resume': return hasExactKeys(value, ['kind', 'at', 'index'], ['actor']) && validateActor(value.actor, false);
79
+ case 'read': return hasExactKeys(value, ['kind', 'actor', 'at', 'through', 'index']) && validateActor(value.actor, true) && isNonNegativeInteger(value.through);
80
+ case 'listen':
81
+ case 'ignore': return hasExactKeys(value, ['kind', 'actor', 'target', 'at', 'index']) && validateActor(value.actor, true) && validateActor(value.target, true);
82
+ default: return false;
83
+ }
84
+ }
85
+ function validateActs(value) {
86
+ if (!Array.isArray(value) || !value.every(validateStoredAct))
87
+ return false;
88
+ let previous = -1;
89
+ for (const act of value) {
90
+ if (act.index <= previous || (act.kind === 'say' && act.reply !== undefined && act.reply >= act.index))
91
+ return false;
92
+ previous = act.index;
93
+ }
94
+ return true;
95
+ }
96
+ export function validateSquareState(value) {
97
+ if (!isObject(value) || !hasExactKeys(value, ['hardCap', 'preamble', 'warmup', 'acts', 'runtime'], ['throttlePerMinute', 'routes'])
98
+ || !(value.hardCap === null || (Number.isSafeInteger(value.hardCap) && value.hardCap > 0))
99
+ || (value.throttlePerMinute !== undefined && (!Number.isSafeInteger(value.throttlePerMinute) || value.throttlePerMinute <= 0))
100
+ || !isStringArray(value.preamble) || !isStringArray(value.warmup) || !validateActs(value.acts)
101
+ || (value.routes !== undefined && (!Array.isArray(value.routes) || !value.routes.every((route) => isObject(route) && isWakeRouteKind(route.kind)
102
+ && typeof route.location === 'string' && typeof route.participant === 'string' && typeof route.sessionId === 'string'
103
+ && typeof route.channel === 'string' && isObject(route.address) && Object.values(route.address).every((item) => typeof item === 'string')
104
+ && typeof route.updatedAt === 'number')))
105
+ || !validateRuntime(value.runtime))
106
+ throw invalidArtifact('snapshot schema is malformed.');
107
+ const historyBoundary = value.acts.at(-1)?.index ?? -1;
108
+ if (value.runtime.nextActIndex <= historyBoundary)
109
+ throw invalidArtifact('nextActIndex is behind the activity history.');
110
+ const references = validateAssignedRuntimeReferences(value.runtime);
111
+ if (references === 'malformed')
112
+ throw invalidArtifact('snapshot schema is malformed.');
113
+ if (references === 'future')
114
+ throw invalidArtifact('runtime references an unassigned activity index.');
115
+ return value;
116
+ }
117
+ export function emptyRuntimeState(nextActIndex = 0) {
118
+ return { nextActIndex, observations: {}, leases: {} };
119
+ }
120
+ function normalizedLines(value) {
121
+ const normalized = value.replace(/\r\n/g, '\n').trim();
122
+ return normalized === '' ? [] : normalized.split('\n');
123
+ }
124
+ function readGuide(name) {
125
+ const guide = guideContents.get(name);
126
+ if (guide === undefined)
127
+ throw new SquareError('invalid_args', `Unknown square guide: ${name}`);
128
+ return guide;
129
+ }
130
+ export function createSquareState(options, snippet) {
131
+ const guides = [readGuide('participant')];
132
+ if (options.template !== undefined)
133
+ guides.push(readGuide(options.template));
134
+ return {
135
+ hardCap: options.hardCap,
136
+ ...(options.throttlePerMinute === undefined ? {} : { throttlePerMinute: options.throttlePerMinute }),
137
+ preamble: normalizedLines(snippet),
138
+ warmup: normalizedLines(guides.join('\n\n')),
139
+ acts: [], routes: [], runtime: emptyRuntimeState(),
140
+ };
141
+ }
@@ -1,20 +1,17 @@
1
1
  import { createSquareState, diagnoseSquareFile as diagnoseArtifactFile } from './artifact.js';
2
+ import type { SquareState } from './model.js';
2
3
  import type { StateCell } from './state-cell.js';
3
- import { type SquareState } from './model.js';
4
- /**
5
- * The only production module allowed to cross the .square byte boundary.
6
- * Consumers receive a SquareState and never need to know which codec or lock
7
- * protects it.
8
- */
9
- export { createSquareState, };
4
+ export { createSquareState };
10
5
  export declare function readSquareFile(squarePath: string): Promise<SquareState>;
11
6
  export declare function probeSquareFile(squarePath: string): Promise<SquareState | undefined>;
12
7
  export declare function diagnoseSquareFile(squarePath: string): ReturnType<typeof diagnoseArtifactFile>;
8
+ /** Test-helper snapshot replacement backed by one SQLite artifact. */
13
9
  export declare function writeSquareSnapshot(squarePath: string, squareState: SquareState): Promise<void>;
14
- export declare function withSquareFileLock<T>(squarePath: string, fn: () => T | Promise<T>): Promise<T>;
10
+ /** Race-safe non-force creation for the file adapter. */
11
+ export declare function createSquareSnapshot(squarePath: string, squareState: SquareState): Promise<boolean>;
15
12
  /** In-process cell for fast behavior tests and embedded consumers. */
16
13
  export declare function createMemoryCell(initial: SquareState): StateCell;
17
- /** File-backed cell retaining the existing .square codec and lock boundary. */
14
+ /** SQLite-backed cell. Revisions are read from the authoritative database, not file metadata. */
18
15
  export declare function createFileCell(squarePath: string): StateCell;
19
- /** Consumer-facing file cell factory; keeps storage choice behind this module. */
16
+ /** Consumer-facing file cell factory; keeps SQLite framing behind this module. */
20
17
  export declare function openSquareCell(squarePath: string): StateCell;
@@ -1,15 +1,9 @@
1
- import { setTimeout as sleep } from 'node:timers/promises';
2
1
  import fs from 'node:fs';
3
2
  import path from 'node:path';
4
- import { createSquareState, diagnoseSquareFile as diagnoseArtifactFile, loadSquare, probeSquare, writeSquareFile, } from './artifact.js';
5
- import { withFileLock } from './file-lock.js';
6
- import { LOCK_RETRY_MS } from './runtime.js';
7
- /**
8
- * The only production module allowed to cross the .square byte boundary.
9
- * Consumers receive a SquareState and never need to know which codec or lock
10
- * protects it.
11
- */
12
- export { createSquareState, };
3
+ import { setTimeout as sleep } from 'node:timers/promises';
4
+ import { isDeepStrictEqual } from 'node:util';
5
+ import { createSquareFile, createSquareState, diagnoseSquareFile as diagnoseArtifactFile, loadSquare, probeSquare, readSquareSnapshot, transactSquareSnapshot, writeSquareFile, } from './artifact.js';
6
+ export { createSquareState };
13
7
  async function canonicalPath(squarePath) {
14
8
  const absolute = path.resolve(squarePath);
15
9
  try {
@@ -22,49 +16,24 @@ async function canonicalPath(squarePath) {
22
16
  return path.join(parent, path.basename(absolute));
23
17
  }
24
18
  }
25
- async function squareFileExists(squarePath) {
26
- try {
27
- await fs.promises.access(squarePath);
28
- return true;
29
- }
30
- catch (error) {
31
- if (error.code === 'ENOENT')
32
- return false;
33
- throw error;
34
- }
35
- }
36
- async function withSquareFileReadLock(squarePath, fn) {
37
- if (!await squareFileExists(squarePath))
38
- return fn();
39
- try {
40
- return await withFileLock(`${squarePath}.lock`, { retryMs: LOCK_RETRY_MS, createParent: false }, fn);
41
- }
42
- catch (error) {
43
- if (!await squareFileExists(squarePath))
44
- return fn();
45
- throw error;
46
- }
47
- }
48
19
  export async function readSquareFile(squarePath) {
49
- const canonical = await canonicalPath(squarePath);
50
- return withSquareFileReadLock(canonical, () => loadSquare(canonical));
20
+ return loadSquare(await canonicalPath(squarePath));
51
21
  }
52
22
  export async function probeSquareFile(squarePath) {
53
23
  if (!squarePath.endsWith('.square'))
54
24
  return undefined;
55
- const canonical = await canonicalPath(squarePath);
56
- return withSquareFileReadLock(canonical, () => probeSquare(canonical));
25
+ return probeSquare(await canonicalPath(squarePath));
57
26
  }
58
27
  export async function diagnoseSquareFile(squarePath) {
59
- const canonical = await canonicalPath(squarePath);
60
- return withSquareFileReadLock(canonical, () => diagnoseArtifactFile(canonical));
28
+ return diagnoseArtifactFile(await canonicalPath(squarePath));
61
29
  }
30
+ /** Test-helper snapshot replacement backed by one SQLite artifact. */
62
31
  export async function writeSquareSnapshot(squarePath, squareState) {
63
32
  await writeSquareFile(await canonicalPath(squarePath), squareState);
64
33
  }
65
- export async function withSquareFileLock(squarePath, fn) {
66
- const canonical = await canonicalPath(squarePath);
67
- return withFileLock(`${canonical}.lock`, { retryMs: LOCK_RETRY_MS }, fn);
34
+ /** Race-safe non-force creation for the file adapter. */
35
+ export async function createSquareSnapshot(squarePath, squareState) {
36
+ return createSquareFile(await canonicalPath(squarePath), squareState);
68
37
  }
69
38
  function cloneState(squareState) {
70
39
  return structuredClone(squareState);
@@ -73,6 +42,16 @@ function assertCellOpen(closed) {
73
42
  if (closed)
74
43
  throw new Error('StateCell is closed');
75
44
  }
45
+ function isThenable(value) {
46
+ return (typeof value === 'object' || typeof value === 'function') && value !== null
47
+ && typeof value.then === 'function';
48
+ }
49
+ function discardThenable(value) {
50
+ void Promise.resolve(value).catch(() => undefined);
51
+ }
52
+ function isBusy(error) {
53
+ return error instanceof Error && /database is locked|database is busy|SQLITE_BUSY/i.test(error.message);
54
+ }
76
55
  /** In-process cell for fast behavior tests and embedded consumers. */
77
56
  export function createMemoryCell(initial) {
78
57
  let state = cloneState(initial);
@@ -89,15 +68,21 @@ export function createMemoryCell(initial) {
89
68
  waiter.resolve(true);
90
69
  }
91
70
  }
92
- const cell = {
71
+ return {
93
72
  transact(fn) {
94
73
  assertCellOpen(closed);
95
- let operation;
96
- operation = tail.then(() => {
74
+ const operation = tail.then(() => {
97
75
  assertCellOpen(closed);
98
- const working = cloneState(state);
99
- const outcome = fn(working, version);
100
- if (outcome.state !== undefined) {
76
+ const current = cloneState(state);
77
+ const outcome = fn(current, version);
78
+ if (isThenable(outcome)) {
79
+ discardThenable(outcome);
80
+ throw new TypeError('Square artifact transitions must be synchronous.');
81
+ }
82
+ if (typeof outcome !== 'object' || outcome === null || !Object.hasOwn(outcome, 'result')) {
83
+ throw new TypeError('Square artifact transition must return { state?, result }.');
84
+ }
85
+ if (outcome.state !== undefined && !isDeepStrictEqual(outcome.state, state)) {
101
86
  state = cloneState(outcome.state);
102
87
  version += 1;
103
88
  publish();
@@ -110,6 +95,7 @@ export function createMemoryCell(initial) {
110
95
  async read() {
111
96
  assertCellOpen(closed);
112
97
  await tail;
98
+ assertCellOpen(closed);
113
99
  return { state: cloneState(state), version };
114
100
  },
115
101
  changed(sinceVersion, timeoutMs) {
@@ -122,10 +108,7 @@ export function createMemoryCell(initial) {
122
108
  const waiter = {
123
109
  since: sinceVersion,
124
110
  resolve,
125
- timer: setTimeout(() => {
126
- waiters.delete(waiter);
127
- resolve(false);
128
- }, timeoutMs),
111
+ timer: setTimeout(() => { waiters.delete(waiter); resolve(false); }, timeoutMs),
129
112
  };
130
113
  waiters.add(waiter);
131
114
  });
@@ -142,93 +125,79 @@ export function createMemoryCell(initial) {
142
125
  }
143
126
  },
144
127
  };
145
- return cell;
146
- }
147
- async function fileFingerprint(squarePath) {
148
- try {
149
- const stat = await fs.promises.stat(squarePath);
150
- return `${stat.ino}:${stat.size}:${stat.mtimeMs}:${stat.ctimeMs}`;
151
- }
152
- catch {
153
- return 'missing';
154
- }
155
128
  }
156
- /** File-backed cell retaining the existing .square codec and lock boundary. */
129
+ /** SQLite-backed cell. Revisions are read from the authoritative database, not file metadata. */
157
130
  export function createFileCell(squarePath) {
158
131
  let closed = false;
159
- let version = 0;
160
- let fingerprint;
161
- let cached;
162
- let canonicalSquarePath;
163
- async function storagePath() {
164
- canonicalSquarePath ??= await canonicalPath(squarePath);
165
- return canonicalSquarePath;
166
- }
167
- async function observe() {
168
- const next = await fileFingerprint(await storagePath());
169
- if (fingerprint === undefined) {
170
- fingerprint = next;
171
- }
172
- else if (next !== fingerprint) {
173
- fingerprint = next;
174
- cached = undefined;
175
- version += 1;
176
- }
177
- return fingerprint;
132
+ let storage;
133
+ let tail = Promise.resolve();
134
+ const cancel = new AbortController();
135
+ function storagePath() {
136
+ storage ??= canonicalPath(squarePath);
137
+ return storage;
178
138
  }
179
- async function currentStateUnderLock() {
180
- const observed = await observe();
181
- if (cached?.fingerprint === observed)
182
- return cloneState(cached.state);
183
- const decoded = await loadSquare(await storagePath());
184
- cached = { fingerprint: observed, state: cloneState(decoded) };
185
- return cloneState(cached.state);
139
+ function abortError() {
140
+ return new Error('StateCell is closed');
186
141
  }
187
142
  return {
188
- async transact(fn) {
143
+ transact(fn) {
189
144
  assertCellOpen(closed);
190
- const canonical = await storagePath();
191
- return withFileLock(`${canonical}.lock`, { retryMs: LOCK_RETRY_MS }, async () => {
145
+ const operation = tail.then(async () => {
192
146
  assertCellOpen(closed);
193
- const current = await currentStateUnderLock();
194
- const working = cloneState(current);
195
- const outcome = fn(working, version);
196
- if (outcome.state !== undefined) {
197
- await writeSquareFile(canonical, outcome.state);
198
- fingerprint = await fileFingerprint(canonical);
199
- cached = { fingerprint, state: cloneState(outcome.state) };
200
- version += 1;
201
- }
202
- return outcome.result;
147
+ const result = await transactSquareSnapshot(await storagePath(), fn, cancel.signal);
148
+ return result.result;
203
149
  });
150
+ tail = operation.then(() => undefined, () => undefined);
151
+ return operation;
204
152
  },
205
153
  async read() {
206
154
  assertCellOpen(closed);
207
- return withSquareFileReadLock(await storagePath(), async () => {
208
- assertCellOpen(closed);
209
- return { state: await currentStateUnderLock(), version };
210
- });
155
+ await tail;
156
+ assertCellOpen(closed);
157
+ const snapshot = await readSquareSnapshot(await storagePath(), cancel.signal);
158
+ return { state: cloneState(snapshot.state), version: snapshot.revision };
211
159
  },
212
160
  async changed(sinceVersion, timeoutMs) {
213
161
  assertCellOpen(closed);
214
162
  const deadline = Date.now() + Math.max(0, timeoutMs);
215
- while (true) {
216
- await observe();
217
- if (version > sinceVersion)
218
- return true;
163
+ while (!closed) {
219
164
  const remaining = deadline - Date.now();
220
- if (remaining <= 0)
165
+ const timeout = AbortSignal.timeout(Math.max(1, remaining));
166
+ const readSignal = AbortSignal.any([cancel.signal, timeout]);
167
+ try {
168
+ if ((await readSquareSnapshot(await storagePath(), readSignal)).revision > sinceVersion)
169
+ return true;
170
+ }
171
+ catch (error) {
172
+ if (closed || cancel.signal.aborted || timeout.aborted)
173
+ return false;
174
+ if (!isBusy(error))
175
+ throw error;
176
+ }
177
+ const nextRemaining = deadline - Date.now();
178
+ if (nextRemaining <= 0)
221
179
  return false;
222
- await sleep(Math.min(25, remaining));
223
- assertCellOpen(closed);
180
+ try {
181
+ await sleep(Math.min(25, nextRemaining), undefined, { signal: cancel.signal });
182
+ }
183
+ catch (error) {
184
+ if (closed || cancel.signal.aborted)
185
+ return false;
186
+ throw error;
187
+ }
224
188
  }
189
+ return false;
225
190
  },
226
191
  async close() {
192
+ if (closed)
193
+ return;
227
194
  closed = true;
195
+ cancel.abort(abortError());
196
+ await tail;
228
197
  },
229
198
  };
230
199
  }
231
- /** Consumer-facing file cell factory; keeps storage choice behind this module. */
200
+ /** Consumer-facing file cell factory; keeps SQLite framing behind this module. */
232
201
  export function openSquareCell(squarePath) {
233
202
  return createFileCell(squarePath);
234
203
  }
@@ -4,10 +4,7 @@ export interface StateCell {
4
4
  transact<R>(fn: (state: SquareState, version: number) => {
5
5
  state?: SquareState;
6
6
  result: R;
7
- } | Promise<{
8
- state?: SquareState;
9
- result: R;
10
- }>): Promise<R>;
7
+ }): Promise<R>;
11
8
  read(): Promise<{
12
9
  state: SquareState;
13
10
  version: number;
package/dist/watch.js CHANGED
@@ -126,6 +126,7 @@ async function cmdWatchNow(squarePath, name, opts) {
126
126
  const facade = await openParticipant({ path: squarePath, clock: nowMs }, name);
127
127
  try {
128
128
  const caught = await facade.participant.catch({
129
+ ...(opts.id === undefined ? {} : { id: opts.id }),
129
130
  ...(opts.participants === undefined ? {} : { from: opts.participants }),
130
131
  ...(opts.mention === undefined ? {} : { mention: true }),
131
132
  ...(opts.limit === undefined ? {} : { limit: opts.limit }),
@@ -46,15 +46,35 @@ export default function squarePiExtension(pi) {
46
46
  let retryWait;
47
47
  const present = (deliver, signal) => sessionId === undefined ? undefined : presentPendingAtBoundary(sessionId, deliver, undefined, undefined, signal);
48
48
 
49
- const presentAtBoundary = (deliver) => {
49
+ let boundaryPending;
50
+ const boundaryControllers = new Set();
51
+ const presentAtBoundary = async (deliver) => {
52
+ // One slow Square operation must neither hold Pi's prompt nor accumulate
53
+ // another unresolved lookup on every prompt while the first unwinds.
54
+ if (boundaryPending !== undefined || sessionId === undefined) return undefined;
50
55
  const controller = new AbortController();
51
- const timer = setTimeout(() => controller.abort(new Error('Pi boundary presentation timed out')), piBoundaryTimeoutMs());
52
- const pending = present(deliver, controller.signal);
53
- if (pending === undefined) {
56
+ boundaryControllers.add(controller);
57
+ let timer;
58
+ const expired = new Promise((resolve) => {
59
+ controller.signal.addEventListener('abort', () => resolve(undefined), { once: true });
60
+ timer = setTimeout(() => controller.abort(new Error('Pi boundary presentation timed out')), piBoundaryTimeoutMs());
61
+ });
62
+ try {
63
+ const pending = present((context) => {
64
+ // A lookup may finish after the deadline or after session replacement.
65
+ // Refuse before rendering so that late work cannot acknowledge anything.
66
+ controller.signal.throwIfAborted();
67
+ return deliver(context);
68
+ }, controller.signal);
69
+ boundaryPending = pending;
70
+ void pending?.finally(() => {
71
+ if (boundaryPending === pending) boundaryPending = undefined;
72
+ }).catch(() => undefined);
73
+ return await Promise.race([pending, expired]);
74
+ } finally {
54
75
  clearTimeout(timer);
55
- return undefined;
76
+ boundaryControllers.delete(controller);
56
77
  }
57
- return pending.finally(() => clearTimeout(timer));
58
78
  };
59
79
 
60
80
  const waitForSettled = (serial, signal) => {
@@ -73,6 +93,7 @@ export default function squarePiExtension(pi) {
73
93
  // A transport may keep its promise pending while Pi is shutting down or
74
94
  // replacing a session. Never make a lifecycle hook wait for that transport.
75
95
  const stopWatcher = () => {
96
+ for (const controller of boundaryControllers) controller.abort(new Error('Pi session ended'));
76
97
  watcherAbort?.abort();
77
98
  watcher = undefined;
78
99
  watcherAbort = undefined;
@@ -110,7 +110,7 @@ row.
110
110
  1. Resolve the requested name and collect the currently active bindings for
111
111
  that name. The current session is excluded from the old-owner set only when
112
112
  it is already the owner and this is a reconnect, not a kick.
113
- 2. Under the artifact lock, commit two lifecycle activities as one transaction:
113
+ 2. In one artifact transaction, commit two lifecycle activities:
114
114
  `done` for the standing participant, followed immediately by `join` for the
115
115
  reclaimed name. If this transaction cannot commit, nothing is kicked.
116
116
  3. In the same artifact transaction, remove every route for the kicked
@@ -152,8 +152,8 @@ owner. It must not append a second `done` for the new owner.
152
152
  ## Concurrency and Recovery
153
153
 
154
154
  The artifact transaction serializes participant lifecycle activities. The host
155
- ledger lock serializes binding rows. These locks protect different facts and
156
- must not be replaced with a copied sidecar or a display-level mutex.
155
+ ledger lock serializes binding rows. They protect different facts and must not
156
+ be replaced with a copied sidecar or a display-level mutex.
157
157
 
158
158
  The safe ordering is:
159
159
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrosheep/square",
3
- "version": "0.3.45",
3
+ "version": "0.3.47",
4
4
  "description": "A shared public square where agents join, catch activity, express, and step out when done.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -37,7 +37,7 @@
37
37
  "author": "astrosheep",
38
38
  "license": "MIT",
39
39
  "engines": {
40
- "node": ">=22.5.0"
40
+ "node": "^22.16.0 || >=24.0.0"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@getpaseo/client": "^0.3.1",
@@ -6,6 +6,8 @@ allowed-tools: Bash(square *)
6
6
 
7
7
  # Square
8
8
 
9
+ Don't treat Square as a public dumping ground. Speak here only when you genuinely think someone else needs to know; otherwise you are needlessly interrupting them.
10
+
9
11
  A square is a physical place where participants catch up and express thoughts or actions. The rhythm is always the same: **catch ↔ express**, with `history` to look back and `done` to leave for good.
10
12
 
11
13
  ```text
@@ -74,13 +76,16 @@ Every human-CLI `express` must choose exactly one reach mode: one or more `--men
74
76
  `catch` takes in the directed activity addressed to you since you last looked: mentions, bells, and bare activities from participants you are listening to.
75
77
 
76
78
  ```bash
79
+ square --location <square> --as <name> catch --id act/12 # take in exactly this activity, with its full body
77
80
  square --location <square> --as <name> catch --now # take in what is pending
78
81
  square --location <square> --as <name> catch --idle 30m # wait until something relevant lands, or 30m of quiet
79
82
  square --location <square> --as <name> catch --now --mention # take in pending mentions
80
83
  square --location <square> --as <name> catch --now --from <names> # take in pending activity from named participants
81
84
  ```
82
85
 
83
- Every catch needs exactly one mode: `--now` or `--idle <duration>`. `--mention` and `--from` filter either mode; they do not replace it. Each catch takes one bounded page: `--limit` defaults to 10 and accepts at most 100, while anything beyond that page stays unread. Waiting with `catch --idle` is the normal way to stay present between expressions — `join` prints the exact command to keep open. Do not build a polling loop.
86
+ `catch --id act/12` immediately returns that activity if it was addressed to you and marks only it seen, leaving other unread activity pending. Repeating it returns the same full body. It accepts optional `--now`, but not `--idle`, `--from`, `--mention`, `--limit` or `--replace`. Unlike read-only `history`, it consumes the selected activity.
87
+
88
+ Without `--id`, every catch needs exactly one mode: `--now` or `--idle <duration>`. `--mention` and `--from` filter either mode; they do not replace it. Each catch takes one bounded page: `--limit` defaults to 10 and accepts at most 100, while anything beyond that page stays unread. Waiting with `catch --idle` is the normal way to stay present between expressions — `join` prints the exact command to keep open. Do not build a polling loop.
84
89
 
85
90
  ## Listen
86
91
 
package/dist/landing.d.ts DELETED
@@ -1,26 +0,0 @@
1
- import type { SquareArtifactPort } from './ports.js';
2
- import type { OpenSquare } from './open-square.js';
3
- import { type ListenerChangeResult } from './square-actions.js';
4
- import type { Activity, ExpressOptions, ExpressResult } from './square-facade.js';
5
- /** Transitional adapter for existing internal callers until they migrate to SquareArtifactPort. */
6
- type LegacySquare = {
7
- readonly cell: SquareArtifactPort;
8
- readonly clock: () => number;
9
- };
10
- export declare function join(square: OpenSquare | LegacySquare, name: string): Promise<{
11
- readonly name: string;
12
- readonly activity: Activity | null;
13
- }>;
14
- export declare function implicitJoin(square: OpenSquare | LegacySquare, name: string): Promise<{
15
- readonly name: string;
16
- readonly state: "joined" | "active" | "done";
17
- readonly activity: Activity | null;
18
- }>;
19
- export declare function express(square: OpenSquare | LegacySquare, name: string, body: string, options?: ExpressOptions): Promise<ExpressResult>;
20
- export declare function listen(square: OpenSquare | LegacySquare, actor: string, target: string): Promise<ListenerChangeResult>;
21
- export declare function ignore(square: OpenSquare | LegacySquare, actor: string, target: string): Promise<ListenerChangeResult>;
22
- export declare function listening(square: OpenSquare | LegacySquare, actor: string): Promise<readonly string[]>;
23
- export declare function done(square: OpenSquare | LegacySquare, name: string, body?: string): Promise<ExpressResult>;
24
- export declare function hold(square: OpenSquare | LegacySquare, name: string, reason?: string): Promise<ExpressResult>;
25
- export declare function resume(square: OpenSquare | LegacySquare, name: string): Promise<ExpressResult>;
26
- export {};