@astrosheep/square 0.3.47 → 0.3.49

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.47",
3
+ "version": "0.3.49",
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.47",
3
+ "version": "0.3.49",
4
4
  "description": "Shared Square activity with reliable participant attention at Codex boundaries.",
5
5
  "author": {
6
6
  "name": "Square"
@@ -30,6 +30,9 @@ export function pendingAtBoundary(inbox) {
30
30
  export function renderPendingAtBoundary(inbox) {
31
31
  return renderBoundary(inbox).context;
32
32
  }
33
+ function frameBoundary(lines) {
34
+ return `\n${lines.join('\n')}\n`;
35
+ }
33
36
  function renderBoundary(inbox) {
34
37
  const count = pendingCount(inbox);
35
38
  const noun = count === 1 ? 'notification' : 'notifications';
@@ -52,7 +55,7 @@ function renderBoundary(inbox) {
52
55
  }),
53
56
  ].join('\n');
54
57
  const omittedAfter = omitted + queued.length - index - 1;
55
- const prospective = [
58
+ const prospective = frameBoundary([
56
59
  header,
57
60
  ...blocks,
58
61
  block,
@@ -60,7 +63,7 @@ function renderBoundary(inbox) {
60
63
  ? [`… ${omittedAfter} ${omittedAfter === 1 ? 'notification' : 'notifications'} omitted.`]
61
64
  : []),
62
65
  ...footer,
63
- ].join('\n');
66
+ ]);
64
67
  if (prospective.length > CONTEXT_MAX) {
65
68
  omitted += 1;
66
69
  continue;
@@ -73,14 +76,14 @@ function renderBoundary(inbox) {
73
76
  });
74
77
  }
75
78
  return {
76
- context: [
79
+ context: frameBoundary([
77
80
  header,
78
81
  ...blocks,
79
82
  ...(omitted > 0
80
83
  ? [`… ${omitted} ${omitted === 1 ? 'notification' : 'notifications'} omitted.`]
81
84
  : []),
82
85
  ...footer,
83
- ].join('\n'),
86
+ ]),
84
87
  complete,
85
88
  };
86
89
  }
@@ -17,9 +17,11 @@ export async function codexHookResponse(input, lookup = sessionInbox, env = proc
17
17
  if (hookEventName === undefined)
18
18
  return undefined;
19
19
  await recordCodexBoundary(input.session_id, hookEventName === 'Stop' ? 'Stop' : 'non-stop', env);
20
- const response = await presentPendingAtBoundary(input.session_id, (context) => hookEventName === 'Stop'
21
- ? { systemMessage: context }
22
- : { hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: context } }, lookup, env);
20
+ if (hookEventName === 'Stop') {
21
+ await sweepPrivilegedPending(typeof input.cwd === 'string' ? input.cwd : process.cwd(), env, deliveryAdapters, sweepDeadline).catch(() => undefined);
22
+ return undefined;
23
+ }
24
+ const response = await presentPendingAtBoundary(input.session_id, (context) => ({ hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: context } }), lookup, env);
23
25
  await sweepPrivilegedPending(typeof input.cwd === 'string' ? input.cwd : process.cwd(), env, deliveryAdapters, sweepDeadline).catch(() => undefined);
24
26
  return response;
25
27
  }
package/dist/index.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export { Square } from './square-wiring.js';
2
2
  export { SquareError } from './model.js';
3
3
  export { bindCurrentParticipant, squareAssignedParticipantName, unbindCurrentParticipant } from './registry.js';
4
+ export { createDefaultWakeTransport } from './notifications.js';
4
5
  export { createHostLedgerPort, FileHostLedgerPort } from './host-ledger-file-adapter.js';
5
6
  export type { HostLedgerPort, PresenceRecord, EvidenceRecord } from './host-ledger.js';
6
7
  export type { PresentationSinkPort } from './ports.js';
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  export { Square } from './square-wiring.js';
2
2
  export { SquareError } from './model.js';
3
3
  export { bindCurrentParticipant, squareAssignedParticipantName, unbindCurrentParticipant } from './registry.js';
4
+ export { createDefaultWakeTransport } from './notifications.js';
4
5
  export { createHostLedgerPort, FileHostLedgerPort } from './host-ledger-file-adapter.js';
@@ -1,21 +1,20 @@
1
1
  import { presentPendingAtBoundary, renderPendingAtBoundary } from '../dist/boundary-presentation.js';
2
2
  import { automaticSessionEnd, automaticSessionStart } from '../dist/automatic-session.js';
3
- import { waitForSessionPending } from '../dist/inbox.js';
3
+ import { sessionInbox, waitForSessionPending } from '../dist/inbox.js';
4
4
  import { projectSessionBindings } from '../dist/square-projections.js';
5
5
  import { createHostLedgerPort } from '../dist/host-ledger-file-adapter.js';
6
6
 
7
- const PI_SEND_TIMEOUT_MS = 5_000;
8
- const DEFAULT_PI_BOUNDARY_TIMEOUT_MS = 2_000;
7
+ class PiDeliveryDroppedError extends Error {
8
+ constructor() {
9
+ super('Pi discarded the queued Square notification');
10
+ this.name = 'PiDeliveryDroppedError';
11
+ }
12
+ }
9
13
 
10
14
  function sessionBindings(sessionId) {
11
15
  return projectSessionBindings({ hostLedger: createHostLedgerPort(), sessionId });
12
16
  }
13
17
 
14
- function piBoundaryTimeoutMs() {
15
- const configured = Number.parseInt(process.env.SQUARE_PI_BOUNDARY_TIMEOUT_MS || '', 10);
16
- return Number.isFinite(configured) && configured > 0 ? configured : DEFAULT_PI_BOUNDARY_TIMEOUT_MS;
17
- }
18
-
19
18
  export function pendingInbox(inbox) {
20
19
  return inbox.filter((item) => item.notifications?.length > 0);
21
20
  }
@@ -33,67 +32,76 @@ export function renderPiInbox(inbox) {
33
32
  export default function squarePiExtension(pi) {
34
33
  let sessionId;
35
34
  let sessionCwd;
36
- let joiningContext;
37
35
  let previousSessionId;
38
36
  let watcher;
39
37
  let watcherAbort;
40
38
  let generation = 0;
41
- let presenting = false;
42
- let settledSerial = 0;
43
- let settledWaiters = [];
44
39
  const handledPending = new Set();
40
+ const observedPending = new Set();
41
+ const landingAcks = new Map();
45
42
  let retryAfterChange = false;
46
43
  let retryWait;
47
- const present = (deliver, signal) => sessionId === undefined ? undefined : presentPendingAtBoundary(sessionId, deliver, undefined, undefined, signal);
44
+ let turnIndex = 0;
45
+ let activeTurn;
46
+ let currentRunSignal;
47
+ let detachRunAbort;
48
+ let cancelledRun = false;
48
49
 
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;
55
- const controller = new AbortController();
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 {
75
- clearTimeout(timer);
76
- boundaryControllers.delete(controller);
77
- }
50
+ const cancelRunDelivery = () => {
51
+ cancelledRun = true;
52
+ for (const key of observedPending) handledPending.add(key);
53
+ generation += 1;
54
+ stopWatcher();
55
+ failAcks(new Error('Pi notification delivery cancelled'));
56
+ retryAfterChange = false;
57
+ retryWait = undefined;
58
+ };
59
+
60
+ const failAcks = (error) => {
61
+ const acks = [...landingAcks.values()].flatMap((waiters) => [...waiters]);
62
+ for (const ack of acks) ack.settle(error);
78
63
  };
79
64
 
80
- const waitForSettled = (serial, signal) => {
81
- if (settledSerial !== serial) return Promise.resolve();
82
- return new Promise((resolve) => {
83
- const waiter = { serial, resolve };
84
- settledWaiters.push(waiter);
85
- if (signal) signal.addEventListener('abort', () => {
86
- const index = settledWaiters.indexOf(waiter);
87
- if (index >= 0) settledWaiters.splice(index, 1);
88
- resolve();
89
- }, { once: true });
65
+ const acknowledgeLanding = (message) => {
66
+ if (message?.role !== 'custom' || message.customType !== 'square' || typeof message.content !== 'string') return;
67
+ const acks = landingAcks.get(message.content);
68
+ if (acks === undefined) return;
69
+ for (const ack of [...acks]) ack.settle();
70
+ };
71
+
72
+ const waitForLanding = (content, signal, kind) => {
73
+ let ack;
74
+ const promise = new Promise((resolve, reject) => {
75
+ const abort = () => ack.settle(signal.reason || new Error('Pi native injection aborted'));
76
+ ack = {
77
+ kind,
78
+ sentAfterTurn: turnIndex,
79
+ emptyTurnWindows: 0,
80
+ settle(error) {
81
+ if (ack.settled) return;
82
+ ack.settled = true;
83
+ signal.removeEventListener('abort', abort);
84
+ const acks = landingAcks.get(content);
85
+ if (acks?.delete(ack) && acks.size === 0) landingAcks.delete(content);
86
+ if (error === undefined) resolve();
87
+ else reject(error);
88
+ },
89
+ };
90
+ let acks = landingAcks.get(content);
91
+ if (acks === undefined) {
92
+ acks = new Set();
93
+ landingAcks.set(content, acks);
94
+ }
95
+ acks.add(ack);
96
+ if (signal.aborted) abort();
97
+ else signal.addEventListener('abort', abort, { once: true });
90
98
  });
99
+ return { promise, ack };
91
100
  };
92
101
 
93
102
  // A transport may keep its promise pending while Pi is shutting down or
94
103
  // replacing a session. Never make a lifecycle hook wait for that transport.
95
104
  const stopWatcher = () => {
96
- for (const controller of boundaryControllers) controller.abort(new Error('Pi session ended'));
97
105
  watcherAbort?.abort();
98
106
  watcher = undefined;
99
107
  watcherAbort = undefined;
@@ -109,7 +117,7 @@ export default function squarePiExtension(pi) {
109
117
  signal.addEventListener('abort', finish, { once: true });
110
118
  });
111
119
 
112
- const wake = async (piContext, token, signal) => {
120
+ const wake = async (token, signal) => {
113
121
  const armDeferredRetry = () => {
114
122
  const controller = new AbortController();
115
123
  let resolveArmed;
@@ -129,14 +137,26 @@ export default function squarePiExtension(pi) {
129
137
  };
130
138
 
131
139
  while (sessionId !== undefined && token === generation && !signal.aborted) {
132
- if ((await sessionBindings(sessionId)).length === 0) {
140
+ try {
141
+ if ((await sessionBindings(sessionId)).length === 0) {
142
+ await pause(signal, 1_000);
143
+ continue;
144
+ }
145
+ } catch {
133
146
  await pause(signal, 1_000);
134
147
  continue;
135
148
  }
149
+
136
150
  const deferredRetry = retryAfterChange;
137
- const pending = deferredRetry && retryWait !== undefined
138
- ? await retryWait
139
- : await waitForSessionPending(sessionId, 30_000, { signal, excludeKeys: handledPending });
151
+ let pending;
152
+ try {
153
+ pending = deferredRetry && retryWait !== undefined
154
+ ? await retryWait
155
+ : await waitForSessionPending(sessionId, 30_000, { signal, excludeKeys: handledPending });
156
+ } catch {
157
+ await pause(signal, 1_000);
158
+ continue;
159
+ }
140
160
  if (sessionId === undefined || token !== generation || signal.aborted) return;
141
161
  if (pending.length === 0) {
142
162
  if (deferredRetry) {
@@ -147,112 +167,165 @@ export default function squarePiExtension(pi) {
147
167
  }
148
168
  retryAfterChange = false;
149
169
  retryWait = undefined;
150
- if (!piContext.isIdle()) {
151
- const serial = settledSerial;
152
- await waitForSettled(serial, signal);
153
- continue;
154
- }
155
- if (presenting) continue;
170
+
156
171
  const keys = inboxKeys(pending);
157
- presenting = true;
172
+ for (const key of keys) observedPending.add(key);
158
173
  const deferred = armDeferredRetry();
159
174
  const retryArmed = await deferred.armed;
160
175
  if (!retryArmed) {
161
176
  deferred.cancel();
162
- presenting = false;
163
177
  continue;
164
178
  }
165
179
  let keepDeferred = false;
166
180
  try {
167
- const delivered = await presentPendingAtBoundary(
181
+ await presentPendingAtBoundary(
168
182
  sessionId,
169
183
  async (content) => {
170
- const send = Promise.resolve(pi.sendMessage(
171
- { customType: 'square', content, display: true },
172
- { deliverAs: 'steer', triggerTurn: true },
173
- ));
174
- let timer;
184
+ if (signal.aborted || currentRunSignal?.aborted) throw new Error('Pi notification delivery cancelled');
185
+ const landing = waitForLanding(content, signal, 'steer');
175
186
  try {
176
- await Promise.race([
177
- send,
178
- new Promise((_, reject) => {
179
- if (signal.aborted) {
180
- reject(signal.reason || new Error('Pi native injection aborted'));
181
- return;
182
- }
183
- signal.addEventListener('abort', () => reject(signal.reason || new Error('Pi native injection aborted')), { once: true });
184
- }),
185
- new Promise((_, reject) => {
186
- timer = setTimeout(() => reject(new Error('Pi native injection timed out')), PI_SEND_TIMEOUT_MS);
187
- }),
188
- ]);
189
- } finally {
190
- if (timer !== undefined) clearTimeout(timer);
187
+ Promise.resolve(pi.sendMessage(
188
+ { customType: 'square', content, display: true },
189
+ { deliverAs: 'steer', triggerTurn: true },
190
+ )).catch((error) => landing.ack.settle(error));
191
+ } catch (error) {
192
+ landing.ack.settle(error);
191
193
  }
192
- return true;
194
+ return landing.promise;
195
+ },
196
+ async (id, env) => {
197
+ const inbox = await sessionInbox(id, env);
198
+ if (signal.aborted || token !== generation) return [];
199
+ for (const key of inboxKeys(inbox)) observedPending.add(key);
200
+ return inbox.map((membership) => ({
201
+ ...membership,
202
+ notifications: membership.notifications.filter((note) => !handledPending.has(
203
+ `${membership.squarePath}\u0000${membership.name.toLocaleLowerCase()}\u0000${note.actIndex}`,
204
+ )),
205
+ }));
193
206
  },
194
- undefined,
195
207
  undefined,
196
208
  signal,
197
209
  );
198
- if (delivered === true || delivered === undefined) {
199
- for (const key of keys) handledPending.add(key);
210
+ for (const key of keys) handledPending.add(key);
211
+ } catch (error) {
212
+ if (signal.aborted || token !== generation) return;
213
+ if (error instanceof PiDeliveryDroppedError) {
214
+ deferred.cancel();
215
+ continue;
200
216
  }
201
- } catch {
202
217
  // The next state edge is already being observed before native injection starts.
203
218
  retryAfterChange = true;
204
219
  retryWait = deferred.pending;
205
220
  keepDeferred = true;
206
221
  } finally {
207
222
  if (!keepDeferred) deferred.cancel();
208
- presenting = false;
209
223
  }
210
224
  }
211
225
  };
212
226
 
213
227
  pi.on('session_start', async (_event, ctx) => {
228
+ detachRunAbort?.();
229
+ cancelledRun = false;
214
230
  generation += 1;
231
+ failAcks(new Error('Pi session replaced'));
215
232
  stopWatcher();
216
233
  handledPending.clear();
234
+ observedPending.clear();
217
235
  retryAfterChange = false;
218
236
  retryWait = undefined;
237
+ turnIndex = 0;
238
+ activeTurn = undefined;
239
+ currentRunSignal = undefined;
219
240
  sessionId = ctx.sessionManager.getSessionId();
220
241
  sessionCwd = ctx.cwd || process.cwd();
221
242
  previousSessionId = process.env.SQUARE_PI_SESSION_ID;
222
243
  process.env.SQUARE_PI_SESSION_ID = sessionId;
223
- joiningContext = undefined;
224
- void automaticSessionStart('pi', sessionId, sessionCwd).catch(() => undefined);
225
- watcherAbort = new AbortController();
226
244
  const token = generation;
227
- watcher = wake(ctx, token, watcherAbort.signal).catch(() => undefined);
245
+ watcherAbort = new AbortController();
246
+ const signal = watcherAbort.signal;
247
+ void automaticSessionStart('pi', sessionId, sessionCwd).then((context) => {
248
+ if (context === undefined || sessionId === undefined || token !== generation) return;
249
+ const landing = waitForLanding(context, signal, 'nextTurn');
250
+ try {
251
+ Promise.resolve(pi.sendMessage(
252
+ { customType: 'square', content: context, display: true },
253
+ { deliverAs: 'nextTurn' },
254
+ )).catch((error) => landing.ack.settle(error));
255
+ } catch {
256
+ // Joining context is advisory; an unavailable Pi transport does not block startup.
257
+ landing.ack.settle(new Error('Pi joining-context injection failed'));
258
+ }
259
+ void landing.promise.catch(() => undefined);
260
+ }).catch(() => undefined);
261
+ watcher = wake(token, signal).catch(() => undefined);
228
262
  });
229
263
 
230
- pi.on('agent_settled', async () => {
231
- settledSerial += 1;
232
- const waiters = settledWaiters;
233
- settledWaiters = [];
234
- for (const waiter of waiters) waiter.resolve();
264
+ pi.on('agent_start', async (_event, ctx) => {
265
+ detachRunAbort?.();
266
+ currentRunSignal = ctx?.getSignal?.();
267
+ if (ctx?.mode !== 'tui' || currentRunSignal === undefined) return;
268
+ const runSignal = currentRunSignal;
269
+ detachRunAbort = () => runSignal.removeEventListener('abort', cancelRunDelivery);
270
+ if (runSignal.aborted) cancelRunDelivery();
271
+ else runSignal.addEventListener('abort', cancelRunDelivery, { once: true });
235
272
  });
236
273
 
237
- pi.on('before_agent_start', async () => {
238
- try {
239
- if (joiningContext) {
240
- const context = joiningContext;
241
- joiningContext = undefined;
242
- return { message: { customType: 'square', content: context, display: true } };
274
+ pi.on('message_end', async (event) => {
275
+ if (activeTurn !== undefined && (event.message?.role === 'user' || event.message?.role === 'custom')) {
276
+ activeTurn.hasInput = true;
277
+ }
278
+ acknowledgeLanding(event.message);
279
+ });
280
+
281
+ pi.on('turn_start', async () => {
282
+ turnIndex += 1;
283
+ activeTurn = { index: turnIndex, hasInput: false, signal: currentRunSignal };
284
+ });
285
+
286
+ pi.on('turn_end', async (event, ctx) => {
287
+ const turn = activeTurn;
288
+ activeTurn = undefined;
289
+ const acks = [...landingAcks.values()].flatMap((waiters) => [...waiters]);
290
+ if (event.message?.stopReason === 'aborted' && ctx?.mode === 'tui') {
291
+ if (!cancelledRun) cancelRunDelivery();
292
+ return;
293
+ }
294
+ if (turn === undefined) return;
295
+ for (const ack of acks) {
296
+ if (ack.kind !== 'steer' || turn.index <= ack.sentAfterTurn) continue;
297
+ if (turn.hasInput) {
298
+ ack.emptyTurnWindows = 0;
299
+ } else {
300
+ ack.emptyTurnWindows += 1;
301
+ if (ack.emptyTurnWindows >= 2) ack.settle(new PiDeliveryDroppedError());
243
302
  }
244
- if (presenting) return undefined;
245
- return await presentAtBoundary((context) => ({ message: { customType: 'square', content: context, display: true } }));
246
- } catch {
247
- return undefined;
248
303
  }
249
304
  });
250
305
 
306
+ pi.on('agent_settled', async () => {
307
+ detachRunAbort?.();
308
+ detachRunAbort = undefined;
309
+ currentRunSignal = undefined;
310
+ if (!cancelledRun || sessionId === undefined) return;
311
+ const token = generation;
312
+ // Cancel the entire pending batch, including entries not yet sent to Pi.
313
+ // This is local suppression, never evidence that the model saw the activity.
314
+ const cancelled = await sessionInbox(sessionId);
315
+ if (token !== generation || sessionId === undefined) return;
316
+ for (const key of inboxKeys(cancelled)) handledPending.add(key);
317
+ cancelledRun = false;
318
+ watcherAbort = new AbortController();
319
+ watcher = wake(token, watcherAbort.signal).catch(() => undefined);
320
+ });
321
+
251
322
  pi.on('session_shutdown', async () => {
323
+ detachRunAbort?.();
324
+ detachRunAbort = undefined;
325
+ cancelledRun = false;
252
326
  generation += 1;
327
+ failAcks(new Error('Pi session ended'));
253
328
  stopWatcher();
254
- for (const waiter of settledWaiters) waiter.resolve();
255
- settledWaiters = [];
256
329
  if (sessionId && sessionCwd) void automaticSessionEnd('pi', sessionId, sessionCwd).catch(() => undefined);
257
330
  if (process.env.SQUARE_PI_SESSION_ID === sessionId) {
258
331
  if (previousSessionId === undefined) delete process.env.SQUARE_PI_SESSION_ID;
@@ -260,8 +333,8 @@ export default function squarePiExtension(pi) {
260
333
  }
261
334
  sessionId = undefined;
262
335
  sessionCwd = undefined;
263
- joiningContext = undefined;
264
- presenting = false;
265
336
  retryWait = undefined;
337
+ activeTurn = undefined;
338
+ currentRunSignal = undefined;
266
339
  });
267
340
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrosheep/square",
3
- "version": "0.3.47",
3
+ "version": "0.3.49",
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": {