@astrosheep/square 0.3.55 → 0.3.56

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "square",
3
- "version": "0.3.55",
3
+ "version": "0.3.56",
4
4
  "description": "Native Claude Code turn-boundary delivery for Square participants",
5
5
  "author": {
6
6
  "name": "Square"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "square",
3
- "version": "0.3.55",
3
+ "version": "0.3.56",
4
4
  "description": "Shared Square activity with reliable participant attention at Codex boundaries.",
5
5
  "author": {
6
6
  "name": "Square"
@@ -3,4 +3,4 @@ import type { InboxMembership } from './model.js';
3
3
  export declare function pendingAtBoundary(inbox: InboxMembership[]): InboxMembership[];
4
4
  export declare function renderPendingAtBoundary(inbox: InboxMembership[]): string;
5
5
  /** Serialize a boundary across hook processes so one stale inbox cannot render twice. */
6
- export declare function presentPendingAtBoundary<T>(sessionId: string, present: (context: string) => T | Promise<T>, lookup?: (sessionId: string, env?: NodeJS.ProcessEnv) => Promise<InboxMembership[]> | InboxMembership[], env?: NodeJS.ProcessEnv, signal?: AbortSignal): Promise<T | undefined>;
6
+ export declare function presentPendingAtBoundary<T>(sessionId: string, present: (context: string) => T | Promise<T>, lookup?: (sessionId: string, env?: NodeJS.ProcessEnv, signal?: AbortSignal) => Promise<InboxMembership[]> | InboxMembership[], env?: NodeJS.ProcessEnv, signal?: AbortSignal): Promise<T | undefined>;
@@ -12,6 +12,29 @@ import { presentationSuppressesWake, projectPresentationEvidence } from './squar
12
12
  import { formatActivityId } from './square-core.js';
13
13
  const CONTEXT_MAX = 1200;
14
14
  const presentationLocks = new Map();
15
+ /** One settled promise loses to an abort so an in-process presentation wait stays bounded too. */
16
+ async function awaitWithSignal(promise, signal) {
17
+ if (signal === undefined)
18
+ return promise;
19
+ let onAbort;
20
+ try {
21
+ await Promise.race([
22
+ promise,
23
+ new Promise((_, reject) => {
24
+ if (signal.aborted) {
25
+ reject(signal.reason ?? new Error('Presentation aborted'));
26
+ return;
27
+ }
28
+ onAbort = () => reject(signal.reason ?? new Error('Presentation aborted'));
29
+ signal.addEventListener('abort', onAbort, { once: true });
30
+ }),
31
+ ]);
32
+ }
33
+ finally {
34
+ if (onAbort !== undefined)
35
+ signal.removeEventListener('abort', onAbort);
36
+ }
37
+ }
15
38
  function pendingCount(inbox) {
16
39
  return inbox.reduce((total, membership) => total + membership.notifications.length, 0);
17
40
  }
@@ -71,7 +94,7 @@ function renderBoundary(inbox) {
71
94
  };
72
95
  }
73
96
  async function presentPendingAtBoundaryUnlocked(sessionId, present, lookup = sessionInbox, env = process.env, signal) {
74
- const inbox = await lookup(sessionId, env);
97
+ const inbox = await lookup(sessionId, env, signal);
75
98
  if (signal?.aborted)
76
99
  return undefined;
77
100
  const hostLedger = hostLedgerForEnv(env);
@@ -97,7 +120,7 @@ async function presentPendingAtBoundaryUnlocked(sessionId, present, lookup = ses
97
120
  let square;
98
121
  try {
99
122
  try {
100
- square = await openSquare(entry.membership.squarePath, { env });
123
+ square = await openSquare(entry.membership.squarePath, { env, signal });
101
124
  }
102
125
  catch {
103
126
  continue;
@@ -106,16 +129,18 @@ async function presentPendingAtBoundaryUnlocked(sessionId, present, lookup = ses
106
129
  const key = `${entry.membership.squarePath}\u0000${entry.membership.name.toLocaleLowerCase()}\u0000${index}`;
107
130
  const prior = presentationLocks.get(key);
108
131
  if (prior !== undefined) {
109
- await prior;
132
+ await awaitWithSignal(prior, signal);
133
+ if (signal?.aborted)
134
+ return undefined;
110
135
  if (!rendered)
111
136
  return undefined;
112
137
  continue;
113
138
  }
114
- const work = presentPending({ artifact: square.artifact, location: entry.membership.squarePath, participant: entry.membership.name, activity: index, hostLedger: square.hostLedger, session: sessionId, sink: { present: renderOnce }, markSeen: entry.markSeen, now: Date.now() }).then(async (outcome) => {
139
+ const work = presentPending({ artifact: square.artifact, location: entry.membership.squarePath, participant: entry.membership.name, activity: index, hostLedger: square.hostLedger, session: sessionId, sink: { present: renderOnce }, markSeen: entry.markSeen, now: Date.now(), signal }).then(async (outcome) => {
115
140
  // A stale projection can outlive its activity; still surface the bounded preview,
116
141
  // but there is no artifact observation to commit.
117
142
  if (!outcome.presented) {
118
- const snapshot = await square.artifact.read().catch(() => undefined);
143
+ const snapshot = await square.artifact.read(signal).catch(() => undefined);
119
144
  if (snapshot?.state.acts.every((activity) => activity.index !== index))
120
145
  await renderOnce();
121
146
  }
@@ -6,6 +6,10 @@ export interface CodexHookInput {
6
6
  cwd?: unknown;
7
7
  source?: unknown;
8
8
  }
9
- export declare function codexHookResponse(input: CodexHookInput, lookup?: (sessionId: string, env?: NodeJS.ProcessEnv) => Promise<InboxMembership[]> | InboxMembership[], env?: NodeJS.ProcessEnv, deliveryAdapters?: WakeAdapter[]): Promise<object | undefined>;
9
+ export type HookTrace = (line: string) => void;
10
+ /** Overall hook budget: Codex kills the hook at its configured 5s timeout, so the hook must
11
+ * bound every wait/retry itself and exit with a readable stage trace before that. */
12
+ export declare const CODEX_HOOK_BUDGET_MS = 4500;
13
+ export declare function codexHookResponse(input: CodexHookInput, lookup?: (sessionId: string, env?: NodeJS.ProcessEnv, signal?: AbortSignal) => Promise<InboxMembership[]> | InboxMembership[], env?: NodeJS.ProcessEnv, deliveryAdapters?: WakeAdapter[], trace?: HookTrace): Promise<object | undefined>;
10
14
  export declare function runCodexHook(inputText: string, env?: NodeJS.ProcessEnv): Promise<string>;
11
15
  export declare function runCodexHookAsync(inputText: string, env?: NodeJS.ProcessEnv): Promise<string>;
@@ -3,12 +3,30 @@ import { sessionInbox } from './inbox.js';
3
3
  import { automaticSessionEnd, automaticSessionStart } from './automatic-session.js';
4
4
  import { clearCodexBoundary, recordCodexBoundary } from './codex-boundary-state.js';
5
5
  import { PRIVILEGED_HOOK_BUDGET_MS, sweepPrivilegedPending } from './notifications.js';
6
+ /** Overall hook budget: Codex kills the hook at its configured 5s timeout, so the hook must
7
+ * bound every wait/retry itself and exit with a readable stage trace before that. */
8
+ export const CODEX_HOOK_BUDGET_MS = 4500;
6
9
  const CODEX_HOOK_EVENTS = {
7
10
  PostToolUse: 'PostToolUse',
8
11
  Stop: 'Stop',
9
12
  };
10
- export async function codexHookResponse(input, lookup = sessionInbox, env = process.env, deliveryAdapters) {
11
- const sweepDeadline = Date.now() + PRIVILEGED_HOOK_BUDGET_MS;
13
+ const defaultTrace = (line) => { process.stderr.write(`${line}\n`); };
14
+ /** Per-stage timings, emitted as each stage completes so a killed hook still leaves a trace. */
15
+ function stageTracer(trace) {
16
+ const startedAt = Date.now();
17
+ let mark = startedAt;
18
+ return (stage, extra = '') => {
19
+ const now = Date.now();
20
+ const durationMs = now - mark;
21
+ mark = now;
22
+ trace(`square-codex-hook: stage=${stage} durationMs=${durationMs} totalMs=${now - startedAt}${extra === '' ? '' : ` ${extra}`}`);
23
+ };
24
+ }
25
+ function hookBudgetMs(env) {
26
+ const configured = Number(env.SQUARE_CODEX_HOOK_BUDGET_MS ?? CODEX_HOOK_BUDGET_MS);
27
+ return Number.isFinite(configured) && configured > 0 ? configured : CODEX_HOOK_BUDGET_MS;
28
+ }
29
+ export async function codexHookResponse(input, lookup = sessionInbox, env = process.env, deliveryAdapters, trace = defaultTrace) {
12
30
  if (typeof input.session_id !== 'string' || input.session_id === '')
13
31
  return undefined;
14
32
  if (typeof input.hook_event_name !== 'string')
@@ -16,13 +34,28 @@ export async function codexHookResponse(input, lookup = sessionInbox, env = proc
16
34
  const hookEventName = CODEX_HOOK_EVENTS[input.hook_event_name];
17
35
  if (hookEventName === undefined)
18
36
  return undefined;
37
+ const stage = stageTracer(trace);
38
+ const signal = AbortSignal.timeout(hookBudgetMs(env));
39
+ const sweepDeadline = Date.now() + Math.min(PRIVILEGED_HOOK_BUDGET_MS, hookBudgetMs(env));
40
+ const cwd = typeof input.cwd === 'string' ? input.cwd : process.cwd();
19
41
  await recordCodexBoundary(input.session_id, hookEventName === 'Stop' ? 'Stop' : 'non-stop', env);
42
+ stage('boundary-record');
20
43
  if (hookEventName === 'Stop') {
21
- await sweepPrivilegedPending(typeof input.cwd === 'string' ? input.cwd : process.cwd(), env, deliveryAdapters, sweepDeadline).catch(() => undefined);
44
+ await sweepPrivilegedPending(cwd, env, deliveryAdapters, sweepDeadline, signal).catch(() => undefined);
45
+ stage('sweep', signal.aborted ? 'aborted=true' : '');
22
46
  return undefined;
23
47
  }
24
- const response = await presentPendingAtBoundary(input.session_id, (context) => ({ hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: context } }), lookup, env);
25
- await sweepPrivilegedPending(typeof input.cwd === 'string' ? input.cwd : process.cwd(), env, deliveryAdapters, sweepDeadline).catch(() => undefined);
48
+ let response;
49
+ try {
50
+ response = await presentPendingAtBoundary(input.session_id, (context) => ({ hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: context } }), lookup, env, signal);
51
+ stage('presentation', signal.aborted ? 'aborted=true' : '');
52
+ }
53
+ catch (error) {
54
+ stage('presentation', `error=${error instanceof Error ? error.name : String(error)}`);
55
+ response = undefined;
56
+ }
57
+ await sweepPrivilegedPending(cwd, env, deliveryAdapters, sweepDeadline, signal).catch(() => undefined);
58
+ stage('sweep', signal.aborted ? 'aborted=true' : '');
26
59
  return response;
27
60
  }
28
61
  export async function runCodexHook(inputText, env = process.env) {
package/dist/inbox.d.ts CHANGED
@@ -9,6 +9,6 @@ export interface PendingWaitOptions {
9
9
  /** Reports whether a change wait has been established for a deferred retry. */
10
10
  onChangeArmed?: (armed: boolean) => void;
11
11
  }
12
- export declare function sessionInbox(sessionId: string, env?: NodeJS.ProcessEnv): Promise<InboxMembership[]>;
12
+ export declare function sessionInbox(sessionId: string, env?: NodeJS.ProcessEnv, signal?: AbortSignal): Promise<InboxMembership[]>;
13
13
  /** Wait for a bound square to produce a new pending notification without consuming it. */
14
14
  export declare function waitForSessionPending(sessionId: string, timeoutMs: number, options?: PendingWaitOptions, env?: NodeJS.ProcessEnv): Promise<InboxMembership[]>;
package/dist/inbox.js CHANGED
@@ -24,13 +24,13 @@ function withoutExcluded(inbox, excludeKeys) {
24
24
  }))
25
25
  .filter((membership) => membership.notifications.length > 0);
26
26
  }
27
- export async function sessionInbox(sessionId, env = process.env) {
27
+ export async function sessionInbox(sessionId, env = process.env, signal) {
28
28
  const inbox = [];
29
29
  const hostLedger = hostLedgerForEnv(env);
30
30
  for (const binding of await projectSessionBindings({ hostLedger, sessionId })) {
31
31
  let square;
32
32
  try {
33
- square = await openSquare(binding.location, { env, hostLedger });
33
+ square = await openSquare(binding.location, { env, hostLedger, signal });
34
34
  const projection = await projectPresentation({ artifact: square.artifact, binding, now: square.clock() });
35
35
  if (!projection.joined)
36
36
  continue;
@@ -23,7 +23,7 @@ export declare function createDefaultWakeTransport(hostLedger: import('./host-le
23
23
  export declare function createWakeTransport(adapters: readonly WakeAdapter[], hostLedger: import('./host-ledger.js').HostLedgerPort, clock: () => number): WakeTransportPort;
24
24
  export declare function processActNotificationsOnce(squarePath: string, actIndex: number, opts?: ProcessNotificationOptions): Promise<import("./ports.js").DeliveryResult>;
25
25
  /** Privileged hook fallback: sweep indexed squares plus the current cwd's local squares within one boundary budget. */
26
- export declare function sweepPrivilegedPending(cwd: string, env?: NodeJS.ProcessEnv, suppliedAdapters?: WakeAdapter[], deadline?: number): Promise<void>;
26
+ export declare function sweepPrivilegedPending(cwd: string, env?: NodeJS.ProcessEnv, suppliedAdapters?: WakeAdapter[], deadline?: number, signal?: AbortSignal): Promise<void>;
27
27
  export interface SweepPendingNotificationsOptions {
28
28
  env?: NodeJS.ProcessEnv;
29
29
  now?: number;
@@ -192,9 +192,9 @@ export async function processActNotificationsOnce(squarePath, actIndex, opts = {
192
192
  }
193
193
  }
194
194
  /** Privileged hook fallback: sweep indexed squares plus the current cwd's local squares within one boundary budget. */
195
- export async function sweepPrivilegedPending(cwd, env = process.env, suppliedAdapters, deadline = Date.now() + PRIVILEGED_HOOK_BUDGET_MS) {
195
+ export async function sweepPrivilegedPending(cwd, env = process.env, suppliedAdapters, deadline = Date.now() + PRIVILEGED_HOOK_BUDGET_MS, signal) {
196
196
  const remainingMs = () => Math.max(0, deadline - Date.now());
197
- if (remainingMs() === 0)
197
+ if (remainingMs() === 0 || signal?.aborted)
198
198
  return;
199
199
  const root = env.SQUARE_REGISTRY === undefined ? undefined : path.dirname(env.SQUARE_REGISTRY);
200
200
  const hostLedger = createHostLedgerPort({ userPath: env.SQUARE_HOST_LEDGER_USER ?? root, localPath: env.SQUARE_HOST_LEDGER_LOCAL ?? root, readableScopes: ['user'], writableScope: 'user' });
@@ -211,17 +211,17 @@ export async function sweepPrivilegedPending(cwd, env = process.env, suppliedAda
211
211
  }
212
212
  }
213
213
  catch { /* no local square directory */ }
214
- if (remainingMs() === 0)
214
+ if (remainingMs() === 0 || signal?.aborted)
215
215
  return;
216
216
  const adapters = suppliedAdapters ?? await defaultWakeAdapters();
217
217
  for (const squarePath of paths) {
218
- if (remainingMs() === 0)
218
+ if (remainingMs() === 0 || signal?.aborted)
219
219
  break;
220
220
  try {
221
- const square = await openSquare(squarePath, { hostLedger, env });
221
+ const square = await openSquare(squarePath, { hostLedger, env, signal });
222
222
  try {
223
223
  await hostLedger.reconcileBinding({ artifact: square.artifact, scopes: ['user'], now: Date.now() }).catch(() => undefined);
224
- if (remainingMs() === 0)
224
+ if (remainingMs() === 0 || signal?.aborted)
225
225
  break;
226
226
  const limit = Number.parseInt(env.SQUARE_NOTIFY_SWEEP_LIMIT ?? '8', 10);
227
227
  const graceMs = 0;
@@ -229,7 +229,7 @@ export async function sweepPrivilegedPending(cwd, env = process.env, suppliedAda
229
229
  const transport = createWakeTransport(adapters, hostLedger, Date.now);
230
230
  for (const actIndex of selected) {
231
231
  const remaining = remainingMs();
232
- if (remaining === 0)
232
+ if (remaining === 0 || signal?.aborted)
233
233
  break;
234
234
  const configured = Number(env.SQUARE_NOTIFY_DELIVERY_WAIT_MS ?? 5000);
235
235
  const timeoutMs = Math.max(1, Math.min(Number.isFinite(configured) && configured > 0 ? configured : 5000, remaining));
package/dist/ports.d.ts CHANGED
@@ -4,14 +4,14 @@ export type { ClaimResult, EvidenceClaim, EvidenceRelease, EvidenceGc, EvidenceL
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 {
7
- read(): Promise<{
7
+ read(signal?: AbortSignal): Promise<{
8
8
  state: SquareState;
9
9
  version: number;
10
10
  }>;
11
11
  transact<R>(fn: (state: SquareState, version: number) => {
12
12
  state?: SquareState;
13
13
  result: R;
14
- }): Promise<R>;
14
+ }, signal?: AbortSignal): Promise<R>;
15
15
  changed(sinceVersion: number, timeoutMs: number): Promise<boolean>;
16
16
  close(): Promise<void>;
17
17
  }
@@ -134,6 +134,8 @@ export interface PresentPendingInput {
134
134
  readonly session?: string;
135
135
  readonly now?: number;
136
136
  readonly timeoutMs?: number;
137
+ /** One hook deadline propagated into artifact reads/transitions so busy retries stay bounded. */
138
+ readonly signal?: AbortSignal;
137
139
  /** Boundary previews that were clipped remain pending for a later full presentation. */
138
140
  readonly markSeen?: boolean;
139
141
  }
@@ -8,7 +8,7 @@ export async function presentPending(input) {
8
8
  return { presented: false };
9
9
  let before;
10
10
  try {
11
- before = await input.artifact.read();
11
+ before = await input.artifact.read(input.signal);
12
12
  }
13
13
  catch {
14
14
  return { presented: false };
@@ -25,7 +25,7 @@ export async function presentPending(input) {
25
25
  claimToken = claim.claimToken;
26
26
  let current;
27
27
  try {
28
- current = await input.artifact.read();
28
+ current = await input.artifact.read(input.signal);
29
29
  }
30
30
  catch {
31
31
  await input.hostLedger.releaseEvidence({ location: input.location, participant: input.participant, session: input.session, activity: formatActivityId(index), kind: 'presentation', claimToken }).catch(() => undefined);
@@ -46,7 +46,7 @@ export async function presentPending(input) {
46
46
  else
47
47
  await input.sink.present(item);
48
48
  if (input.markSeen !== false)
49
- await input.artifact.transact((state) => { const changed = recordObservation(state, input.participant, index, 'seen', input.now ?? Date.now()); return changed ? { state, result: undefined } : { result: undefined }; });
49
+ await input.artifact.transact((state) => { const changed = recordObservation(state, input.participant, index, 'seen', input.now ?? Date.now()); return changed ? { state, result: undefined } : { result: undefined }; }, input.signal);
50
50
  if (input.hostLedger !== undefined && input.session !== undefined && claimToken !== undefined)
51
51
  await input.hostLedger.appendEvidence({ location: input.location, participant: input.participant, session: input.session, activity: formatActivityId(index), kind: 'presentation', outcome: input.markSeen === false ? 'clipped' : 'presented', ...(input.markSeen === false ? { message: 'presentation clipped' } : {}), claimToken });
52
52
  return { presented: true, activity: item };
@@ -13,8 +13,10 @@ export interface SquareBuildOptions {
13
13
  hostLedger?: HostLedgerPort;
14
14
  wakeTransport?: import('./ports.js').WakeTransportPort;
15
15
  env?: NodeJS.ProcessEnv;
16
+ /** One caller deadline (e.g. a native hook budget) propagated into artifact busy retries. */
17
+ signal?: AbortSignal;
16
18
  }
17
- export declare function openSquare(squarePath: string, options?: Pick<SquareBuildOptions, 'clock' | 'hostLedger' | 'wakeTransport' | 'env'>): Promise<OpenSquare>;
19
+ export declare function openSquare(squarePath: string, options?: Pick<SquareBuildOptions, 'clock' | 'hostLedger' | 'wakeTransport' | 'env' | 'signal'>): Promise<OpenSquare>;
18
20
  export declare function probeSquare(squarePath: string): Promise<OpenSquare | undefined>;
19
21
  export declare function buildSquare(squarePath: string, options: SquareBuildOptions): Promise<OpenSquare>;
20
22
  export declare function buildMemorySquare(options: SquareBuildOptions): OpenSquare;
@@ -27,10 +27,10 @@ function validateBuildOptions(options) {
27
27
  export async function openSquare(squarePath, options = {}) {
28
28
  const env = options.env ?? process.env;
29
29
  const ledgerRoot = env.SQUARE_REGISTRY === undefined ? undefined : path.dirname(env.SQUARE_REGISTRY);
30
- const cell = openSquareCell(squarePath);
30
+ const cell = openSquareCell(squarePath, options.signal);
31
31
  try {
32
32
  await cell.read();
33
- const artifact = { read: () => cell.read(), transact: (fn) => cell.transact(fn), changed: (since, timeout) => cell.changed(since, timeout), close: () => cell.close() };
33
+ const artifact = { read: (signal) => cell.read(signal), transact: (fn, signal) => cell.transact(fn, signal), changed: (since, timeout) => cell.changed(since, timeout), close: () => cell.close() };
34
34
  return {
35
35
  artifact,
36
36
  clock: options.clock ?? Date.now,
@@ -82,7 +82,7 @@ export function buildMemorySquare(options) {
82
82
  return { artifact: memoryArtifact(createMemoryCell(squareState)), clock: options.clock ?? Date.now, location: 'memory', hostLedger: options.hostLedger, wakeTransport: options.wakeTransport };
83
83
  }
84
84
  function memoryArtifact(cell) {
85
- return { read: () => cell.read(), transact: (fn) => cell.transact(fn), changed: (since, timeout) => cell.changed(since, timeout), close: () => cell.close() };
85
+ return { read: (signal) => cell.read(signal), transact: (fn, signal) => cell.transact(fn, signal), changed: (since, timeout) => cell.changed(since, timeout), close: () => cell.close() };
86
86
  }
87
87
  export async function waitForSquareChanges(squarePaths, timeoutMs, signal, afterReady, onArmed) {
88
88
  if (timeoutMs <= 0 || signal?.aborted || squarePaths.length === 0) {
@@ -12,6 +12,6 @@ export declare function createSquareSnapshot(squarePath: string, squareState: Sq
12
12
  /** In-process cell for fast behavior tests and embedded consumers. */
13
13
  export declare function createMemoryCell(initial: SquareState): StateCell;
14
14
  /** SQLite-backed cell. Revisions are read from the authoritative database, not file metadata. */
15
- export declare function createFileCell(squarePath: string): StateCell;
15
+ export declare function createFileCell(squarePath: string, externalSignal?: AbortSignal): StateCell;
16
16
  /** Consumer-facing file cell factory; keeps SQLite framing behind this module. */
17
- export declare function openSquareCell(squarePath: string): StateCell;
17
+ export declare function openSquareCell(squarePath: string, signal?: AbortSignal): StateCell;
@@ -69,10 +69,14 @@ export function createMemoryCell(initial) {
69
69
  }
70
70
  }
71
71
  return {
72
- transact(fn) {
72
+ transact(fn, signal) {
73
73
  assertCellOpen(closed);
74
+ if (signal?.aborted)
75
+ throw signal.reason ?? new Error('StateCell operation aborted');
74
76
  const operation = tail.then(() => {
75
77
  assertCellOpen(closed);
78
+ if (signal?.aborted)
79
+ throw signal.reason ?? new Error('StateCell operation aborted');
76
80
  const current = cloneState(state);
77
81
  const outcome = fn(current, version);
78
82
  if (isThenable(outcome)) {
@@ -92,10 +96,14 @@ export function createMemoryCell(initial) {
92
96
  tail = operation.then(() => undefined, () => undefined);
93
97
  return operation;
94
98
  },
95
- async read() {
99
+ async read(signal) {
96
100
  assertCellOpen(closed);
101
+ if (signal?.aborted)
102
+ throw signal.reason ?? new Error('StateCell operation aborted');
97
103
  await tail;
98
104
  assertCellOpen(closed);
105
+ if (signal?.aborted)
106
+ throw signal.reason ?? new Error('StateCell operation aborted');
99
107
  return { state: cloneState(state), version };
100
108
  },
101
109
  changed(sinceVersion, timeoutMs) {
@@ -127,7 +135,7 @@ export function createMemoryCell(initial) {
127
135
  };
128
136
  }
129
137
  /** SQLite-backed cell. Revisions are read from the authoritative database, not file metadata. */
130
- export function createFileCell(squarePath) {
138
+ export function createFileCell(squarePath, externalSignal) {
131
139
  let closed = false;
132
140
  let storage;
133
141
  let tail = Promise.resolve();
@@ -139,22 +147,29 @@ export function createFileCell(squarePath) {
139
147
  function abortError() {
140
148
  return new Error('StateCell is closed');
141
149
  }
150
+ /** Close wins over every operation; a per-call deadline bounds busy retries alongside it. */
151
+ function operationSignal(signal) {
152
+ if (signal === undefined && externalSignal === undefined)
153
+ return cancel.signal;
154
+ return AbortSignal.any([cancel.signal, ...(externalSignal === undefined ? [] : [externalSignal]), ...(signal === undefined ? [] : [signal])]);
155
+ }
142
156
  return {
143
- transact(fn) {
157
+ transact(fn, signal) {
144
158
  assertCellOpen(closed);
159
+ const opSignal = operationSignal(signal);
145
160
  const operation = tail.then(async () => {
146
161
  assertCellOpen(closed);
147
- const result = await transactSquareSnapshot(await storagePath(), fn, cancel.signal);
162
+ const result = await transactSquareSnapshot(await storagePath(), fn, opSignal);
148
163
  return result.result;
149
164
  });
150
165
  tail = operation.then(() => undefined, () => undefined);
151
166
  return operation;
152
167
  },
153
- async read() {
168
+ async read(signal) {
154
169
  assertCellOpen(closed);
155
170
  await tail;
156
171
  assertCellOpen(closed);
157
- const snapshot = await readSquareSnapshot(await storagePath(), cancel.signal);
172
+ const snapshot = await readSquareSnapshot(await storagePath(), operationSignal(signal));
158
173
  return { state: cloneState(snapshot.state), version: snapshot.revision };
159
174
  },
160
175
  async changed(sinceVersion, timeoutMs) {
@@ -198,6 +213,6 @@ export function createFileCell(squarePath) {
198
213
  };
199
214
  }
200
215
  /** Consumer-facing file cell factory; keeps SQLite framing behind this module. */
201
- export function openSquareCell(squarePath) {
202
- return createFileCell(squarePath);
216
+ export function openSquareCell(squarePath, signal) {
217
+ return createFileCell(squarePath, signal);
203
218
  }
@@ -4,8 +4,8 @@ export interface StateCell {
4
4
  transact<R>(fn: (state: SquareState, version: number) => {
5
5
  state?: SquareState;
6
6
  result: R;
7
- }): Promise<R>;
8
- read(): Promise<{
7
+ }, signal?: AbortSignal): Promise<R>;
8
+ read(signal?: AbortSignal): Promise<{
9
9
  state: SquareState;
10
10
  version: number;
11
11
  }>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrosheep/square",
3
- "version": "0.3.55",
3
+ "version": "0.3.56",
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": {