@astrosheep/square 0.3.64 → 0.3.65

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.64",
3
+ "version": "0.3.65",
4
4
  "description": "Native Claude Code inbox delivery for Square participants",
5
5
  "types": "./types.d.ts",
6
6
  "author": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "square",
3
- "version": "0.3.64",
3
+ "version": "0.3.65",
4
4
  "description": "Shared Square activity with reliable participant attention at Codex boundaries.",
5
5
  "author": {
6
6
  "name": "Square"
@@ -23,7 +23,7 @@ export declare function readSquareSnapshot(squarePath: string, signal?: AbortSig
23
23
  /** Check the artifact revision without loading or parsing the historical state. */
24
24
  export declare function readSquareRevision(squarePath: string, signal?: AbortSignal): Promise<number>;
25
25
  /** One reference-counted revision detector per canonical artifact in this process. */
26
- export declare function observeSquareRevision(squarePath: string): Promise<import('./file-changes.js').VersionObserver<number>>;
26
+ export declare function observeSquareRevision(squarePath: string): Promise<import('./file-changes.js').VersionObserver<number | null>>;
27
27
  export type SquareTransition<R> = (state: SquareState, revision: number) => {
28
28
  state?: SquareState;
29
29
  result: R;
package/dist/artifact.js CHANGED
@@ -251,7 +251,18 @@ export function readSquareRevision(squarePath, signal) {
251
251
  }
252
252
  /** One reference-counted revision detector per canonical artifact in this process. */
253
253
  export function observeSquareRevision(squarePath) {
254
- return observeFileVersion(squarePath, 'square-revision', readSquareRevision);
254
+ return observeFileVersion(squarePath, 'square-revision', async (file, signal) => {
255
+ try {
256
+ return await readSquareRevision(file, signal);
257
+ }
258
+ catch (error) {
259
+ if (signal?.aborted)
260
+ throw signal.reason ?? error;
261
+ if (error instanceof InternalSquareError && error.code === 'not_found')
262
+ return null;
263
+ throw error;
264
+ }
265
+ });
255
266
  }
256
267
  async function readWithBusyRetry(read, signal) {
257
268
  while (true) {
@@ -4,5 +4,5 @@ export interface CodexBoundary {
4
4
  }
5
5
  export declare function readCodexBoundary(threadId: string, env?: NodeJS.ProcessEnv): Promise<CodexBoundary | undefined>;
6
6
  export declare function codexQueueEligible(threadId: string, env?: NodeJS.ProcessEnv): Promise<boolean>;
7
- export declare function recordCodexBoundary(threadId: string, event: 'Stop' | 'non-stop', env?: NodeJS.ProcessEnv): Promise<void>;
8
- export declare function clearCodexBoundary(threadId: string, env?: NodeJS.ProcessEnv): Promise<void>;
7
+ export declare function recordCodexBoundary(threadId: string, event: 'Stop' | 'non-stop', env?: NodeJS.ProcessEnv, signal?: AbortSignal): Promise<void>;
8
+ export declare function clearCodexBoundary(threadId: string, env?: NodeJS.ProcessEnv, signal?: AbortSignal): Promise<void>;
@@ -59,11 +59,11 @@ export async function codexQueueEligible(threadId, env = process.env) {
59
59
  const boundary = await readCodexBoundary(threadId, env);
60
60
  return boundary !== undefined && boundary.lastStop > boundary.lastNonStop;
61
61
  }
62
- export async function recordCodexBoundary(threadId, event, env = process.env) {
62
+ export async function recordCodexBoundary(threadId, event, env = process.env, signal) {
63
63
  if (!threadId)
64
64
  return;
65
65
  const filePath = statePath(env);
66
- await withFileLock(lockPath(filePath), { retryMs: 10 }, async () => {
66
+ await withFileLock(lockPath(filePath), { retryMs: 10, signal }, async () => {
67
67
  const value = await readFile(filePath);
68
68
  value.nextSequence += 1;
69
69
  const current = value.threads[threadId] ?? { lastStop: 0, lastNonStop: 0 };
@@ -73,11 +73,11 @@ export async function recordCodexBoundary(threadId, event, env = process.env) {
73
73
  await writeFile(filePath, value);
74
74
  });
75
75
  }
76
- export async function clearCodexBoundary(threadId, env = process.env) {
76
+ export async function clearCodexBoundary(threadId, env = process.env, signal) {
77
77
  if (!threadId)
78
78
  return;
79
79
  const filePath = statePath(env);
80
- await withFileLock(lockPath(filePath), { retryMs: 10 }, async () => {
80
+ await withFileLock(lockPath(filePath), { retryMs: 10, signal }, async () => {
81
81
  const value = await readFile(filePath);
82
82
  if (!(threadId in value.threads))
83
83
  return;
@@ -35,7 +35,7 @@ export async function codexHookResponse(input, lookup = sessionInbox, env = proc
35
35
  const signal = AbortSignal.timeout(hookBudgetMs(env));
36
36
  const sweepDeadline = Date.now() + Math.min(PRIVILEGED_HOOK_BUDGET_MS, hookBudgetMs(env));
37
37
  const cwd = typeof input.cwd === 'string' ? input.cwd : process.cwd();
38
- await recordCodexBoundary(input.session_id, hookEventName === 'Stop' ? 'Stop' : 'non-stop', env);
38
+ await recordCodexBoundary(input.session_id, hookEventName === 'Stop' ? 'Stop' : 'non-stop', env, signal);
39
39
  stage('boundary-record');
40
40
  if (hookEventName === 'Stop') {
41
41
  await sweepPrivilegedPending(cwd, env, deliveryAdapters, sweepDeadline, signal).catch(() => undefined);
@@ -82,7 +82,7 @@ export async function runCodexHookAsync(inputText, env = process.env) {
82
82
  if (typeof value.session_id !== 'string')
83
83
  return runCodexHook(inputText, env);
84
84
  if (value.hook_event_name === 'SessionStart' || value.hook_event_name === 'SessionResume') {
85
- await recordCodexBoundary(value.session_id, 'non-stop', env);
85
+ await recordCodexBoundary(value.session_id, 'non-stop', env, AbortSignal.timeout(hookBudgetMs(env)));
86
86
  const cwd = typeof value.cwd === 'string' ? value.cwd : process.cwd();
87
87
  try {
88
88
  const context = await automaticSessionStart('codex', value.session_id, cwd, env);
@@ -93,7 +93,7 @@ export async function runCodexHookAsync(inputText, env = process.env) {
93
93
  }
94
94
  }
95
95
  if (value.hook_event_name === 'SessionEnd') {
96
- await clearCodexBoundary(value.session_id, env);
96
+ await clearCodexBoundary(value.session_id, env, AbortSignal.timeout(hookBudgetMs(env)));
97
97
  const cwd = typeof value.cwd === 'string' ? value.cwd : process.cwd();
98
98
  try {
99
99
  await automaticSessionEnd('codex', value.session_id, cwd, env);
package/dist/inbox.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { nameKey } from './model.js';
1
2
  import { openSquareArtifact, observeSquareChanges } from './square-file-adapter.js';
2
3
  import { hostLedgerForEnv } from './registry.js';
3
4
  import { canonicalPath } from './canonical-path.js';
@@ -6,29 +7,36 @@ import { WATCH_STALE_MS } from './runtime.js';
6
7
  function notificationKey(membership, actIndex) {
7
8
  return membership.squarePath + '\0' + membership.name.toLocaleLowerCase() + '\0' + actIndex;
8
9
  }
10
+ async function readInboxMembership(binding, signal) {
11
+ const artifact = openSquareArtifact(binding.location, signal);
12
+ try {
13
+ const projection = await projectPresentation({ artifact, binding, now: Date.now() });
14
+ if (!projection.joined)
15
+ return undefined;
16
+ return { name: projection.binding.participant, squarePath: projection.binding.location,
17
+ notifications: [...projection.notifications],
18
+ ...(projection.catchLease === undefined ? {} : { catchLease: projection.catchLease }) };
19
+ }
20
+ finally {
21
+ await artifact.close();
22
+ }
23
+ }
9
24
  export async function sessionInbox(sessionId, env = process.env, signal) {
10
25
  const inbox = [];
11
26
  const hostLedger = hostLedgerForEnv(env);
12
27
  for (const binding of await projectSessionBindings({ hostLedger, sessionId })) {
13
28
  if (signal?.aborted)
14
29
  throw signal.reason;
15
- const artifact = openSquareArtifact(binding.location, signal);
16
30
  try {
17
- const projection = await projectPresentation({ artifact, binding, now: Date.now() });
18
- if (!projection.joined)
19
- continue;
20
- inbox.push({ name: projection.binding.participant, squarePath: projection.binding.location,
21
- notifications: [...projection.notifications],
22
- ...(projection.catchLease === undefined ? {} : { catchLease: projection.catchLease }) });
31
+ const membership = await readInboxMembership(binding, signal);
32
+ if (membership !== undefined)
33
+ inbox.push(membership);
23
34
  }
24
35
  catch (error) {
25
36
  if (signal?.aborted)
26
37
  throw signal.reason ?? error;
27
38
  // A stale or unreadable membership cannot disable delivery for other memberships.
28
39
  }
29
- finally {
30
- await artifact.close();
31
- }
32
40
  }
33
41
  return inbox;
34
42
  }
@@ -36,13 +44,28 @@ async function capture(observer, kind, signal) {
36
44
  const generation = observer.generation;
37
45
  try {
38
46
  const value = await observer.read(signal);
39
- return { kind, wait: (timeout, abort) => observer.changed(value, timeout, abort) };
47
+ return { kind, value, wait: (timeout, abort) => observer.changed(value, timeout, abort) };
40
48
  }
41
49
  catch (error) {
42
50
  if (signal.aborted)
43
51
  throw signal.reason ?? error;
44
- // Keep watching an unreadable/missing artifact so repair can restore delivery.
45
- return { kind, wait: (timeout, abort) => observer.hinted(generation, timeout, abort) };
52
+ // Failed revision reads are not changes; only a successful retry wakes projection.
53
+ return { kind, wait: async (timeout, abort) => {
54
+ const deadline = Date.now() + Math.max(0, timeout);
55
+ let since = generation;
56
+ while (await observer.hinted(since, Math.max(0, deadline - Date.now()), abort)) {
57
+ since = observer.generation;
58
+ try {
59
+ await observer.read(abort);
60
+ return true;
61
+ }
62
+ catch (error) {
63
+ if (abort.aborted)
64
+ throw abort.reason ?? error;
65
+ }
66
+ }
67
+ return false;
68
+ } };
46
69
  }
47
70
  }
48
71
  /** Session-scoped projection over process-shared storage change detectors. */
@@ -66,11 +89,11 @@ export async function observeSessionPending(sessionId, suppliedEnv = process.env
66
89
  while (!signal.aborted) {
67
90
  // Subscribe/capture the ledger BEFORE discovering bindings; a concurrent join cannot fall in a gap.
68
91
  const sources = await Promise.all([capture(ledger.presence, 'presence', signal), capture(ledger.evidence, 'evidence', signal)]);
69
- const bindings = await projectSessionBindings({ hostLedger, sessionId });
70
- const paths = new Set(await Promise.all(bindings.map((binding) => canonicalPath(binding.location))));
71
- for (const [file, observer] of artifacts) {
92
+ const bindings = await Promise.all((await projectSessionBindings({ hostLedger, sessionId })).map(async (binding) => ({ ...binding, location: await canonicalPath(binding.location) })));
93
+ const paths = new Set(bindings.map((binding) => binding.location));
94
+ for (const [file, entry] of artifacts) {
72
95
  if (!paths.has(file)) {
73
- observer.close();
96
+ entry.observer.close();
74
97
  artifacts.delete(file);
75
98
  }
76
99
  }
@@ -82,10 +105,42 @@ export async function observeSessionPending(sessionId, suppliedEnv = process.env
82
105
  observer.close();
83
106
  return [];
84
107
  }
85
- artifacts.set(file, observer);
108
+ artifacts.set(file, { observer, inbox: new Map() });
109
+ }
110
+ for (const [file, entry] of artifacts) {
111
+ const source = await capture(entry.observer, 'artifact', signal);
112
+ sources.push(source);
113
+ if (source.value === undefined || source.value !== entry.revision)
114
+ entry.inbox.clear();
115
+ entry.revision = source.value;
116
+ const names = new Set(bindings.filter((binding) => binding.location === file).map((binding) => nameKey(binding.participant)));
117
+ for (const name of entry.inbox.keys())
118
+ if (!names.has(name))
119
+ entry.inbox.delete(name);
120
+ }
121
+ const inbox = [];
122
+ for (const binding of bindings) {
123
+ if (signal.aborted)
124
+ return [];
125
+ const entry = artifacts.get(binding.location);
126
+ const name = nameKey(binding.participant);
127
+ try {
128
+ if (!entry.inbox.has(name))
129
+ entry.inbox.set(name, await readInboxMembership(binding, signal));
130
+ const membership = entry.inbox.get(name);
131
+ if (membership === undefined)
132
+ continue;
133
+ const { catchLease, ...rest } = membership;
134
+ const now = Date.now();
135
+ inbox.push(catchLease !== undefined && catchLease.expiresAt > now && now - catchLease.heartbeatAt <= WATCH_STALE_MS
136
+ ? membership : rest);
137
+ }
138
+ catch (error) {
139
+ if (signal.aborted)
140
+ throw signal.reason ?? error;
141
+ // Unreadable projections are retried, never cached as current.
142
+ }
86
143
  }
87
- sources.push(...await Promise.all([...artifacts.values()].map((observer) => capture(observer, 'artifact', signal))));
88
- const inbox = await sessionInbox(sessionId, env, signal);
89
144
  const eligible = await projectBoundaryEligibility({ hostLedger, sessionId, inbox });
90
145
  const excludeKeys = options.excludeKeys;
91
146
  const pending = excludeKeys === undefined ? eligible : eligible.flatMap((membership) => {
@@ -154,8 +209,8 @@ export async function observeSessionPending(sessionId, suppliedEnv = process.env
154
209
  lifetime.abort();
155
210
  ledger.presence.close();
156
211
  ledger.evidence.close();
157
- for (const observer of artifacts.values())
158
- observer.close();
212
+ for (const entry of artifacts.values())
213
+ entry.observer.close();
159
214
  artifacts.clear();
160
215
  },
161
216
  };
package/dist/mcp.js CHANGED
@@ -96,5 +96,10 @@ export function createSquareMcpServer(options) {
96
96
  return server;
97
97
  }
98
98
  export async function runSquareMcpStdio(options) {
99
- await createSquareMcpServer(options).connect(new StdioServerTransport());
99
+ const server = createSquareMcpServer(options);
100
+ const transport = new StdioServerTransport();
101
+ const close = () => { void server.close(); };
102
+ process.stdin.once('end', close);
103
+ process.stdin.once('close', close);
104
+ await server.connect(transport);
100
105
  }
package/dist/opencode.js CHANGED
@@ -61,12 +61,12 @@ export default {
61
61
  stop(sessionId);
62
62
  continue;
63
63
  }
64
- if (receivers.has(sessionId))
64
+ if (receivers.get(sessionId)?.settled === false)
65
65
  continue;
66
66
  const abort = new AbortController();
67
67
  const signal = AbortSignal.any([lifetime.signal, abort.signal]);
68
- const running = receiveOpenCodePending(target, scopedEnv, signal).catch(() => { });
69
- receivers.set(sessionId, { abort, running });
68
+ const receiver = { abort, settled: false, running: receiveOpenCodePending(target, scopedEnv, signal).catch(() => { }).finally(() => { receiver.settled = true; }) };
69
+ receivers.set(sessionId, receiver);
70
70
  }
71
71
  for (const sessionId of receivers.keys())
72
72
  if (!candidates.has(sessionId))
@@ -10,4 +10,4 @@ export interface ClaudeAuthOptions {
10
10
  * Explicit token, then the environment token bound to this exact endpoint, then
11
11
  * the newest readable peer key for it. Windows requires a token; POSIX does not.
12
12
  */
13
- export declare function resolveClaudeToken(options: ClaudeAuthOptions): Promise<string | undefined>;
13
+ export declare function resolveClaudeToken(options: ClaudeAuthOptions, signal?: AbortSignal): Promise<string | undefined>;
@@ -11,7 +11,8 @@ function validToken(value) { return typeof value === 'string' && TOKEN.test(valu
11
11
  * Explicit token, then the environment token bound to this exact endpoint, then
12
12
  * the newest readable peer key for it. Windows requires a token; POSIX does not.
13
13
  */
14
- export async function resolveClaudeToken(options) {
14
+ export async function resolveClaudeToken(options, signal) {
15
+ signal?.throwIfAborted();
15
16
  const endpoint = typeof options.endpoint === 'string' ? parseLocalEndpoint(options.endpoint) : options.endpoint;
16
17
  const platform = options.platform ?? process.platform;
17
18
  const env = options.env ?? process.env;
@@ -32,6 +33,7 @@ export async function resolveClaudeToken(options) {
32
33
  const keys = [];
33
34
  try {
34
35
  for (const name of await readdir(join(home, 'sessions'))) {
36
+ signal?.throwIfAborted();
35
37
  const match = KEY.exec(name);
36
38
  if (!match || match[2] !== digest)
37
39
  continue;
@@ -42,16 +44,19 @@ export async function resolveClaudeToken(options) {
42
44
  }
43
45
  }
44
46
  catch { /* no registry is not an error; it only means no peer key */ }
47
+ signal?.throwIfAborted();
45
48
  keys.sort((a, b) => Number(isAlive(b.pid)) - Number(isAlive(a.pid)) || b.mtimeMs - a.mtimeMs);
46
49
  for (const key of keys) {
50
+ signal?.throwIfAborted();
47
51
  try {
48
- const value = JSON.parse(await readFile(join(home, 'sessions', key.name), 'utf8'));
52
+ const value = JSON.parse(await readFile(join(home, 'sessions', key.name), { encoding: 'utf8', signal }));
49
53
  if (value && typeof value === 'object' && validToken(value.peerToken)) {
50
54
  return value.peerToken;
51
55
  }
52
56
  }
53
57
  catch { /* the next readable matching key wins */ }
54
58
  }
59
+ signal?.throwIfAborted();
55
60
  if (platform === 'win32')
56
61
  throw new DeliveryError('authentication_failed', false);
57
62
  return undefined;
@@ -1,5 +1,6 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { deadline } from './deadline.js';
3
+ import { DeliveryError } from './errors.js';
3
4
  import { connectLocal, LocalEndpointError, parseLocalEndpoint, writeFrames } from './local-endpoint.js';
4
5
  import { resolveClaudeToken } from './claude-auth.js';
5
6
  const detail = (code) => code === 'aborted' ? 'Native inbox write aborted.'
@@ -26,23 +27,22 @@ export async function writeClaudeNative(target, text, control) {
26
27
  return { outcome: 'unavailable', code: 'aborted', message: detail('aborted') };
27
28
  if (remaining <= 0)
28
29
  return { outcome: 'unavailable', code: 'timeout', message: detail('timeout') };
29
- let token;
30
- try {
31
- token = await resolveClaudeToken({ endpoint, token: target.token, claudeHome: target.claudeHome, env: target.env, platform: target.platform });
32
- }
33
- catch {
34
- return { outcome: 'unavailable', code: 'authentication_failed', message: detail('authentication_failed') };
35
- }
36
- const frames = [
37
- ...(token === undefined ? [] : [{ type: 'auth', token }]),
38
- { msgV: 1, msg_id: target.messageId ?? randomUUID(), type: 'user', session_id: target.sessionId, message: { role: 'user', content: text }, priority: 'next' },
39
- ];
40
- const scope = deadline(Math.min(remaining, 2_147_483_647), control.signal);
30
+ const scope = deadline(control.deadline - Date.now(), control.signal);
41
31
  let socket;
42
32
  try {
43
33
  scope.check();
44
- socket = await connectLocal(endpoint, { signal: scope.signal, timeoutMs: Math.max(1, remaining) });
34
+ const token = await scope.wait(resolveClaudeToken({ endpoint, token: target.token, claudeHome: target.claudeHome, env: target.env, platform: target.platform }, scope.signal));
35
+ const frames = [
36
+ ...(token === undefined ? [] : [{ type: 'auth', token }]),
37
+ { msgV: 1, msg_id: target.messageId ?? randomUUID(), type: 'user', session_id: target.sessionId, message: { role: 'user', content: text }, priority: 'next' },
38
+ ];
39
+ scope.check();
40
+ if (Date.now() >= control.deadline)
41
+ throw new LocalEndpointError('timeout');
42
+ socket = await connectLocal(endpoint, { signal: scope.signal });
45
43
  scope.check();
44
+ if (Date.now() >= control.deadline)
45
+ throw new LocalEndpointError('timeout');
46
46
  // A receiver that never drains must not hold the caller past its deadline.
47
47
  await scope.wait(writeFrames(socket, frames));
48
48
  scope.check();
@@ -50,7 +50,10 @@ export async function writeClaudeNative(target, text, control) {
50
50
  }
51
51
  catch (error) {
52
52
  const local = error instanceof LocalEndpointError ? error : undefined;
53
- const code = scope.code ?? local?.code;
53
+ const code = scope.code ?? (error instanceof DeliveryError ? error.code : local?.code);
54
+ if (code === 'authentication_failed') {
55
+ return { outcome: 'unavailable', code, message: detail(code) };
56
+ }
54
57
  if (code === 'aborted' || code === 'timeout') {
55
58
  return { outcome: socket ? 'unknown' : 'unavailable', code, message: detail(code) };
56
59
  }
@@ -27,7 +27,7 @@ export async function connectExisting(options) {
27
27
  try {
28
28
  scope.check();
29
29
  // The ambient environment and platform are the only auth coordinates here.
30
- const token = await resolveClaudeToken({ endpoint, token: options.token, claudeHome: options.claudeHome });
30
+ const token = await scope.wait(resolveClaudeToken({ endpoint, token: options.token, claudeHome: options.claudeHome }, scope.signal));
31
31
  scope.check();
32
32
  const target = Object.freeze({ harness: 'claude', sessionId: options.sessionId });
33
33
  coordinates.set(target, { endpoint: options.endpoint, token, claudeHome: options.claudeHome });
@@ -32,14 +32,14 @@ export function createSquareApplication(context) {
32
32
  const env = { ...context.env };
33
33
  const location = () => requireLocation({ ...context, env });
34
34
  const hostLedger = context.hostLedger ?? hostLedgerForEnv(env);
35
- const open = () => Square.at({ path: location(), clock: context.clock, env, hostLedger, wakeTransport: context.wakeTransport });
35
+ const open = (control) => Square.at({ path: location(), clock: context.clock, env, hostLedger, wakeTransport: context.wakeTransport }, control);
36
36
  async function existing(operation, control) {
37
37
  checkControl(control);
38
38
  const squarePath = location();
39
39
  const participantName = await requireParticipant(context, squarePath, hostLedger, env);
40
- const square = await openSquare(squarePath, { clock: context.clock, env, hostLedger, wakeTransport: context.wakeTransport });
40
+ const square = await openSquare(squarePath, { clock: context.clock, env, hostLedger, wakeTransport: context.wakeTransport }, control);
41
41
  try {
42
- const { name } = await resolveParticipant(square, participantName);
42
+ const { name } = await resolveParticipant(square, participantName, control);
43
43
  checkControl(control);
44
44
  return await operation(square, name);
45
45
  }
@@ -49,7 +49,7 @@ export function createSquareApplication(context) {
49
49
  }
50
50
  async function joined(operation, control) {
51
51
  checkControl(control);
52
- const square = await open();
52
+ const square = await open(control);
53
53
  try {
54
54
  return await operation(await square.join(await requireParticipant(context, square.location, hostLedger, env), control));
55
55
  }
@@ -61,10 +61,10 @@ export function createSquareApplication(context) {
61
61
  async join(options = {}, control = {}) {
62
62
  checkControl(control);
63
63
  const squarePath = location();
64
- const square = await open();
64
+ const square = await open(control);
65
65
  try {
66
66
  const participantName = await requireParticipant(context, squarePath, hostLedger, env);
67
- const before = await square.snapshot();
67
+ const before = await square.snapshot(control);
68
68
  const standing = before.participants.some((item) => item.name.toLocaleLowerCase() === participantName.toLocaleLowerCase() && item.state === 'joined');
69
69
  const identity = localSessionIdentities(env)[0];
70
70
  const owner = standing ? await readParticipantOwner(squarePath, participantName, hostLedger).catch(() => undefined) : undefined;
@@ -113,7 +113,7 @@ export function createSquareApplication(context) {
113
113
  }
114
114
  else {
115
115
  const waitStarted = Date.now();
116
- const square = await openSquare(location(), { clock: context.clock, env, hostLedger, signal: control.signal });
116
+ const square = await openSquare(location(), { clock: context.clock, env, hostLedger }, control);
117
117
  try {
118
118
  const snapshot = await square.artifact.read(control.signal);
119
119
  if (currentHold(snapshot.state.acts).active)
@@ -132,7 +132,7 @@ export function createSquareApplication(context) {
132
132
  checkControl(control);
133
133
  if (context.participant !== undefined || env.SQUARE_PARTICIPANT_NAME !== undefined)
134
134
  return existing((square, name) => participantHistory(square, name, query, control), control);
135
- const square = await open();
135
+ const square = await open(control);
136
136
  try {
137
137
  checkControl(control);
138
138
  return await square.history(query, control);
@@ -153,7 +153,7 @@ export function createSquareApplication(context) {
153
153
  const identity = localSessionIdentities(env)[0];
154
154
  const owner = identity === undefined ? undefined : await readParticipantOwner(squarePath, participantName, hostLedger).catch(() => undefined);
155
155
  if (identity !== undefined && owner?.session === identity.sessionId && presenceEpoch(owner) > 0) {
156
- const square = await open();
156
+ const square = await open(control);
157
157
  try {
158
158
  return await square.doneOwnedSession(participantName, body ?? '', presenceEpoch(owner), control);
159
159
  }
@@ -1,6 +1,7 @@
1
1
  export { observeSquareRevision as observeSquareChanges } from './artifact.js';
2
2
  import { type BuildOptions, type HardCap } from './model.js';
3
3
  import type { OpenSquare } from './open-square.js';
4
+ import type { OperationControl } from './square-facade.js';
4
5
  import type { HostLedgerPort, SquareArtifactPort } from './ports.js';
5
6
  /** File-owned artifact creation for the CLI and path-backed public facade. */
6
7
  export declare function createSquare(squarePath: string, options: BuildOptions & {
@@ -17,7 +18,7 @@ export interface SquareBuildOptions {
17
18
  /** One caller deadline (e.g. a native hook budget) propagated into artifact busy retries. */
18
19
  signal?: AbortSignal;
19
20
  }
20
- export declare function openSquare(squarePath: string, options?: Pick<SquareBuildOptions, 'clock' | 'hostLedger' | 'wakeTransport' | 'env' | 'signal'>): Promise<OpenSquare>;
21
+ export declare function openSquare(squarePath: string, options?: Pick<SquareBuildOptions, 'clock' | 'hostLedger' | 'wakeTransport' | 'env' | 'signal'>, control?: OperationControl): Promise<OpenSquare>;
21
22
  export declare function probeSquare(squarePath: string): Promise<OpenSquare | undefined>;
22
23
  export declare function buildSquare(squarePath: string, options: SquareBuildOptions): Promise<OpenSquare>;
23
24
  export declare function buildMemorySquare(options: SquareBuildOptions): OpenSquare;
@@ -24,12 +24,12 @@ function validateBuildOptions(options) {
24
24
  throw new SquareError('invalid_args', 'throttlePerMinute must be a positive integer');
25
25
  }
26
26
  }
27
- export async function openSquare(squarePath, options = {}) {
27
+ export async function openSquare(squarePath, options = {}, control) {
28
28
  const env = options.env ?? process.env;
29
29
  const ledgerRoot = hostLedgerRoot(env);
30
30
  const artifact = openSquareArtifact(squarePath, options.signal);
31
31
  try {
32
- await artifact.read();
32
+ await artifact.read(control?.signal);
33
33
  return {
34
34
  artifact,
35
35
  clock: options.clock ?? Date.now,
@@ -21,10 +21,25 @@ export function pendingAtBoundary(inbox) {
21
21
  * ledger errors stay exactly as the boundary callers read them.
22
22
  */
23
23
  export async function projectBoundaryEligibility(input) {
24
+ const memberships = pendingAtBoundary([...input.inbox]);
25
+ if (memberships.length === 0)
26
+ return [];
27
+ const evidence = await projectPresentationEvidence({ hostLedger: input.hostLedger, sessionId: input.sessionId });
28
+ const presented = new Map();
29
+ for (const row of evidence) {
30
+ if (row.outcome !== 'presented' && row.outcome !== 'clipped')
31
+ continue;
32
+ const key = JSON.stringify([row.location, nameKey(row.participant)]);
33
+ let activities = presented.get(key);
34
+ if (activities === undefined)
35
+ presented.set(key, activities = new Set());
36
+ activities.add(row.activity);
37
+ }
24
38
  const pending = [];
25
- for (const membership of pendingAtBoundary([...input.inbox])) {
26
- const evidence = await projectPresentationEvidence({ hostLedger: input.hostLedger, location: membership.squarePath, participant: membership.name, sessionId: input.sessionId });
27
- const notifications = membership.notifications.filter((notification) => !presentationSuppressesWake(evidence.filter((row) => row.activity === formatActivityId(notification.actIndex))));
39
+ for (const membership of memberships) {
40
+ const key = JSON.stringify([await canonicalPath(membership.squarePath), nameKey(membership.name)]);
41
+ const activities = presented.get(key);
42
+ const notifications = membership.notifications.filter((notification) => !activities?.has(formatActivityId(notification.actIndex)));
28
43
  if (notifications.length > 0)
29
44
  pending.push({ ...membership, notifications });
30
45
  }
@@ -78,7 +93,8 @@ export async function projectWakeEvidenceFromState(input) {
78
93
  const canonicalLocation = await canonicalPath(input.location);
79
94
  const delivery = input.delivery ?? deriveDeliveryModel(input.state);
80
95
  const owners = await input.hostLedger.listPresence({ location: canonicalLocation, now: input.now });
81
- const wakeRecords = await input.hostLedger.listEvidence({ location: canonicalLocation, kind: 'wake', now: input.now });
82
- const presentations = await projectPresentationEvidence({ hostLedger: input.hostLedger, location: canonicalLocation, now: input.now });
96
+ const evidence = await input.hostLedger.listEvidence({ location: canonicalLocation, now: input.now });
97
+ const wakeRecords = evidence.filter((row) => row.kind === 'wake');
98
+ const presentations = evidence.filter((row) => row.kind === 'presentation').map((row) => ({ location: row.location, participant: row.participant, sessionId: row.session, activity: row.activity, outcome: row.outcome, ...(row.at === undefined ? {} : { at: row.at }) }));
83
99
  return projectWakeEligibility({ state: input.state, location: canonicalLocation, owners, wakeRecords, presentations, now: input.now, delivery });
84
100
  }
@@ -5,7 +5,7 @@ export declare class Square {
5
5
  private readonly square;
6
6
  private readonly context;
7
7
  private constructor();
8
- static at(input: SquareAtInput): Promise<Square>;
8
+ static at(input: SquareAtInput, control?: OperationControl): Promise<Square>;
9
9
  static build(input: SquareBuildInput): Promise<Square>;
10
10
  static inMemory(input: Omit<SquareBuildInput, 'path'>): Square;
11
11
  join(name: string, control?: OperationControl): Promise<Participant>;
@@ -24,7 +24,7 @@ export declare class Square {
24
24
  readonly participant?: Participant;
25
25
  }>;
26
26
  participants(): Promise<ParticipantStatus[]>;
27
- snapshot(): Promise<SquareSnapshot>;
27
+ snapshot(control?: OperationControl): Promise<SquareSnapshot>;
28
28
  history(query?: HistoryQuery, control?: OperationControl): Promise<Activity[]>;
29
29
  recognize(env: NodeJS.ProcessEnv): Promise<Participant | null>;
30
30
  close(): Promise<void>;
@@ -51,8 +51,8 @@ export class Square {
51
51
  this.square = square;
52
52
  this.context = { ...square, location };
53
53
  }
54
- static async at(input) {
55
- return new Square(input.path, await openSquare(input.path, input));
54
+ static async at(input, control) {
55
+ return new Square(input.path, await openSquare(input.path, input, control));
56
56
  }
57
57
  static async build(input) {
58
58
  return new Square(input.path, await buildSquare(input.path, input));
@@ -85,7 +85,7 @@ export class Square {
85
85
  : { state: joined.state, participant: new ParticipantHandle(joined.name, this.square, this.context) };
86
86
  }
87
87
  participants() { return participants(this.square); }
88
- snapshot() { return snapshot(this.square); }
88
+ snapshot(control) { return snapshot(this.square, control); }
89
89
  history(query, control) { return history(this.square, query, control); }
90
90
  async recognize(env) {
91
91
  if (this.square.hostLedger === undefined)
package/dist/views.d.ts CHANGED
@@ -69,13 +69,13 @@ export declare function toPublicActivity(stored: StoredAct): Activity;
69
69
  export declare function parseRequiredActivityId(id: ActivityId): number;
70
70
  export declare function history(square: OpenSquare, query?: HistoryQuery, control?: OperationControl): Promise<Activity[]>;
71
71
  export declare function participantHistory(square: OpenSquare, _name: string, query?: HistoryQuery, control?: OperationControl): Promise<Activity[]>;
72
- export declare function resolveParticipant(square: OpenSquare, name: string): Promise<{
72
+ export declare function resolveParticipant(square: OpenSquare, name: string, control?: OperationControl): Promise<{
73
73
  readonly name: string;
74
74
  readonly roster: readonly string[];
75
75
  }>;
76
76
  export declare function currentParticipant(square: OpenSquare, name: string): Promise<string | undefined>;
77
77
  export declare function participants(square: OpenSquare): Promise<ParticipantStatus[]>;
78
- export declare function snapshot(square: OpenSquare): Promise<SquareSnapshot>;
78
+ export declare function snapshot(square: OpenSquare, control?: OperationControl): Promise<SquareSnapshot>;
79
79
  export declare function activityPresentation(square: OpenSquare, name: string): Promise<ActivityPresentation>;
80
80
  export declare function entryPresentation(square: OpenSquare, name: string, lastN?: number | null): Promise<EntryPresentation>;
81
81
  export declare function historyPresentation(square: OpenSquare, options: ActivitiesOptions): Promise<HistoryPresentation>;
package/dist/views.js CHANGED
@@ -62,10 +62,10 @@ function sayNumbers(state) {
62
62
  }
63
63
  export async function history(square, query = {}, control) { const { state } = await square.artifact.read(control?.signal); return selectHistory(coreActivities(state, historyOptions(query)), query).map(toPublicActivity); }
64
64
  export async function participantHistory(square, _name, query = {}, control) { const { state } = await square.artifact.read(control?.signal); const effective = query.limit !== undefined ? query : { ...query, limit: 10 }; return selectHistory(coreActivities(state, historyOptions(effective)), effective).map(toPublicActivity); }
65
- export async function resolveParticipant(square, name) { const { state } = await square.artifact.read(); return { name: resolveKnownName(state, name), roster: rosterNames(state) }; }
65
+ export async function resolveParticipant(square, name, control) { const { state } = await square.artifact.read(control?.signal); return { name: resolveKnownName(state, name), roster: rosterNames(state) }; }
66
66
  export async function currentParticipant(square, name) { const { state } = await square.artifact.read(); const known = resolveRosterName(state, name); return known !== undefined && isCurrentlyJoined(state.acts, known) ? known : undefined; }
67
67
  export async function participants(square) { const { state } = await square.artifact.read(); return statuses(square, state); }
68
- export async function snapshot(square) { const { state } = await square.artifact.read(); const folded = foldedState(state); return { context: [...state.preamble, ...state.warmup].join('\n'), actCount: state.acts.filter((activity) => activity.kind !== 'read').length, hardCap: state.hardCap, ...(state.throttlePerMinute === undefined ? {} : { throttlePerMinute: state.throttlePerMinute }), held: folded.hold.active && folded.hold.actor !== undefined ? { by: folded.hold.actor, ...(folded.hold.reason === undefined ? {} : { reason: folded.hold.reason }) } : null, participants: statuses(square, state), delivered(name, id) { return isActivitySeen(state, name, parseRequiredActivityId(id)); } }; }
68
+ export async function snapshot(square, control) { const { state } = await square.artifact.read(control?.signal); const folded = foldedState(state); return { context: [...state.preamble, ...state.warmup].join('\n'), actCount: state.acts.filter((activity) => activity.kind !== 'read').length, hardCap: state.hardCap, ...(state.throttlePerMinute === undefined ? {} : { throttlePerMinute: state.throttlePerMinute }), held: folded.hold.active && folded.hold.actor !== undefined ? { by: folded.hold.actor, ...(folded.hold.reason === undefined ? {} : { reason: folded.hold.reason }) } : null, participants: statuses(square, state), delivered(name, id) { return isActivitySeen(state, name, parseRequiredActivityId(id)); } }; }
69
69
  export async function activityPresentation(square, name) { const { state } = await square.artifact.read(); const known = resolveKnownName(state, name); const delivery = deriveDeliveryModel(state); const delta = deliveryDelta(state, known, delivery); const hold = currentHold(state.acts); return { name: known, roster: rosterNames(state), pendingPublic: directedPeerSays(state, delta, known, delivery), activities: state.acts, state, participantCount: inSquareCount(state), held: hold.active, ...(hold.reason === undefined ? {} : { holdReason: hold.reason }), ownActivityCount: countSays(state.acts, known), hardCap: state.hardCap }; }
70
70
  export async function entryPresentation(square, name, lastN = 10) { const { state } = await square.artifact.read(); const known = resolveRosterName(state, name) ?? name; const publicActivities = state.acts.filter((activity) => activity.kind === 'say' || activity.kind === 'done'); return { joined: isCurrentlyJoined(state.acts, known), scene: state.warmup.join('\n').trim(), context: state.preamble.join('\n').trim(), joinContext: (state.preamble.at(-1) === '---' ? state.preamble.slice(0, -1) : state.preamble).join('\n').trim(), recentActivities: lastN === null ? publicActivities : publicActivities.slice(-lastN), state, sayNumbers: sayNumbers(state), participantCount: inSquareCount(state) }; }
71
71
  export async function historyPresentation(square, options) { const { state } = await square.artifact.read(); const delivery = deriveDeliveryModel(state); return { activities: coreActivities(state, options, delivery), sayNumbers: sayNumbers(state), presenceAnchors: anchors(state, delivery), participantCount: inSquareCount(state) }; }
@@ -120,12 +120,12 @@ export default function squarePiExtension(pi) {
120
120
  // Pi owns PI_SESSION_ID and several SDK sessions may coexist in one process.
121
121
  const sessionEnv = (id) => ({ ...process.env, ...(id === undefined ? {} : { PI_SESSION_ID: id }) });
122
122
 
123
- // A transport may keep its promise pending while Pi is shutting down or
124
- // replacing a session. Never make a lifecycle hook wait for that transport.
123
+ // Aborting releases native landing waits; retain cleanup until the observer
124
+ // has closed, including when cancellation precedes a lifecycle hook.
125
125
  const stopWatcher = () => {
126
126
  watcherAbort?.abort();
127
- watcher = undefined;
128
127
  watcherAbort = undefined;
128
+ return watcher;
129
129
  };
130
130
 
131
131
  const pause = (signal, delayMs) => new Promise((resolve) => {
@@ -229,6 +229,7 @@ export default function squarePiExtension(pi) {
229
229
  env,
230
230
  signal,
231
231
  );
232
+ if (signal.aborted || token !== generation) return;
232
233
  for (const key of keys) handledPending.add(key);
233
234
  } catch (error) {
234
235
  if (signal.aborted || token !== generation) return;
@@ -252,7 +253,7 @@ export default function squarePiExtension(pi) {
252
253
  cancelledRun = false;
253
254
  generation += 1;
254
255
  failAcks(new Error('Pi session replaced'));
255
- stopWatcher();
256
+ const stopped = stopWatcher();
256
257
  handledPending.clear();
257
258
  observedPending.clear();
258
259
  retryAfterChange = false;
@@ -264,9 +265,11 @@ export default function squarePiExtension(pi) {
264
265
  sessionId = ctx.sessionManager.getSessionId();
265
266
  sessionCwd = ctx.cwd || process.cwd();
266
267
  const token = generation;
268
+ await stopped;
269
+ if (token !== generation) return;
267
270
  watcherAbort = new AbortController();
268
271
  const signal = watcherAbort.signal;
269
- void automaticSessionStart('pi', sessionId, sessionCwd, sessionEnv(sessionId)).then((context) => {
272
+ const startup = automaticSessionStart('pi', sessionId, sessionCwd, sessionEnv(sessionId)).then((context) => {
270
273
  if (context === undefined || sessionId === undefined || token !== generation) return;
271
274
  const messageContent = framePiMessage(context);
272
275
  const landing = waitForLanding(messageContent, signal, 'nextTurn');
@@ -281,7 +284,7 @@ export default function squarePiExtension(pi) {
281
284
  }
282
285
  void landing.promise.catch(() => undefined);
283
286
  }).catch(() => undefined);
284
- watcher = wake(token, signal).catch(() => undefined);
287
+ watcher = Promise.allSettled([startup, wake(token, signal)]).then(() => undefined);
285
288
  });
286
289
 
287
290
  pi.on('agent_start', async (_event, ctx) => {
@@ -334,6 +337,8 @@ export default function squarePiExtension(pi) {
334
337
  const token = generation;
335
338
  // Cancel the entire pending batch, including entries not yet sent to Pi.
336
339
  // This is local suppression, never evidence that the model saw the activity.
340
+ await stopWatcher();
341
+ if (token !== generation || sessionId === undefined) return;
337
342
  const cancelled = await sessionInbox(sessionId, sessionEnv(sessionId));
338
343
  if (token !== generation || sessionId === undefined) return;
339
344
  for (const key of inboxKeys(cancelled)) handledPending.add(key);
@@ -348,13 +353,17 @@ export default function squarePiExtension(pi) {
348
353
  cancelledRun = false;
349
354
  generation += 1;
350
355
  failAcks(new Error('Pi session ended'));
351
- stopWatcher();
356
+ const stopped = stopWatcher();
352
357
  ui = undefined;
353
- if (sessionId && sessionCwd) void automaticSessionEnd('pi', sessionId, sessionCwd, sessionEnv(sessionId)).catch(() => undefined);
358
+ const endedId = sessionId;
359
+ const endedCwd = sessionCwd;
360
+ const endedEnv = sessionEnv(endedId);
354
361
  sessionId = undefined;
355
362
  sessionCwd = undefined;
356
363
  retryWait = undefined;
357
364
  activeTurn = undefined;
358
365
  currentRunSignal = undefined;
366
+ await stopped;
367
+ if (endedId && endedCwd) await automaticSessionEnd('pi', endedId, endedCwd, endedEnv).catch(() => undefined);
359
368
  });
360
369
  }
@@ -4,7 +4,7 @@ Square's artifact and host-ledger adapters own change detection. Notifications a
4
4
 
5
5
  - A process shares a revision reader per canonical artifact and a filesystem watcher per containing directory. Subscribers have independent baselines, deadlines, and cancellation. The final release closes the watcher, reconciliation timer, and any outstanding read.
6
6
  - Directory notifications invalidate lightweight reads. A two-second reconciliation checks revisions/ledger metadata even when notifications are missing. Missing directories, unsupported watches, and watcher failures retain that fallback; directory replacement is detected during reconciliation. Normal notification latency is not a hard guarantee: a missed hint may wait for reconciliation, plus scheduling and lock contention.
7
- - The artifact revision remains authoritative. No new sidecar, broker, or persistent cache is introduced. Only changed projections load historical state. Ledger metadata invalidates bindings and presentation projections; it is never treated as evidence of delivery.
7
+ - The artifact revision remains authoritative. No new sidecar, broker, or persistent cache is introduced. Only changed projections load historical state. A missing artifact is a stable observation, not a repeated change: reconciliation remains quiet while it stays absent and detects its return without deleting its session bindings. Strict artifact reads still report absence as an error. Ledger metadata invalidates bindings and presentation projections; it is never treated as evidence of delivery.
8
8
  - Each Pi session owns a long-lived pending observer, including when it has no bindings yet. Presence and evidence changes update its subscriptions. There is no repeated thirty-second restart and no per-second binding scan. A failed injection waits for a substantive change; its own failed evidence write does not trigger a retry loop.
9
9
  - Blocking CLI catch waits for a revision or its next heartbeat/quiet deadline. A held catch keeps its lease alive and pauses quiet expiry. Core catch, NDJSON stream, and delivery-receipt waits use the same change detector. Held express also waits for changes; throttling still waits for its eligibility time.
10
10
  - Catch lease expiry, ledger retention/claim expiry, and cancellation are independent wake conditions. No filesystem write is required for a time-based eligibility change.
@@ -26,8 +26,14 @@ cross-extension transform compatibility is promised.
26
26
 
27
27
  Standalone extensions can register `createPiReceiver` from the package's `./pi`
28
28
  subpath and use `sendPiMessage` directly. Factory registration creates no
29
- resources; start/shutdown own them. Raw SDK `dispose()` callers must explicitly
30
- close the receiver if their host does not emit `session_shutdown`.
29
+ resources; start/shutdown own them. Session replacement and shutdown await the
30
+ pending observer's retirement, including cleanup already begun by run cancellation.
31
+ Cancellation releases native landing waits; it does not leave the observer running
32
+ behind a completed lifecycle hook. Restart after cancellation waits for the old
33
+ observer before opening its successor. Shutdown waits for in-flight automatic
34
+ startup before ending that session, and awaits the session-end work. Raw SDK hosts
35
+ must emit and await `session_shutdown` before `dispose()`;
36
+ disposal alone does not retire extension-owned observers or receivers.
31
37
 
32
38
  Transport frame/key constraints, client
33
39
  `connectExisting`/`sendText` usage, receipt states, bounds and the exact validated
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrosheep/square",
3
- "version": "0.3.64",
3
+ "version": "0.3.65",
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": {