@astrosheep/square 0.3.47 → 0.3.48

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.48",
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.48",
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
  }
@@ -4,18 +4,17 @@ import { 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,87 @@ 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 landingAcks = new Map();
45
41
  let retryAfterChange = false;
46
42
  let retryWait;
47
- const present = (deliver, signal) => sessionId === undefined ? undefined : presentPendingAtBoundary(sessionId, deliver, undefined, undefined, signal);
43
+ let turnIndex = 0;
44
+ let activeTurn;
45
+ let currentRunSignal;
46
+ const deferredSteers = new Set();
48
47
 
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
- }
48
+ const waitForAgentSettled = (signal) => new Promise((resolve, reject) => {
49
+ const deferred = {
50
+ release() {
51
+ deferredSteers.delete(deferred);
52
+ signal.removeEventListener('abort', abort);
53
+ resolve();
54
+ },
55
+ };
56
+ const abort = () => {
57
+ deferredSteers.delete(deferred);
58
+ signal.removeEventListener('abort', abort);
59
+ reject(signal.reason || new Error('Pi native injection aborted'));
60
+ };
61
+ deferredSteers.add(deferred);
62
+ if (signal.aborted) abort();
63
+ else signal.addEventListener('abort', abort, { once: true });
64
+ });
65
+
66
+ const releaseDeferredSteers = () => {
67
+ for (const deferred of [...deferredSteers]) deferred.release();
78
68
  };
79
69
 
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 });
70
+ const failAcks = (error) => {
71
+ const acks = [...landingAcks.values()].flatMap((waiters) => [...waiters]);
72
+ for (const ack of acks) ack.settle(error);
73
+ };
74
+
75
+ const acknowledgeLanding = (message) => {
76
+ if (message?.role !== 'custom' || message.customType !== 'square' || typeof message.content !== 'string') return;
77
+ const acks = landingAcks.get(message.content);
78
+ if (acks === undefined) return;
79
+ for (const ack of [...acks]) ack.settle();
80
+ };
81
+
82
+ const waitForLanding = (content, signal, kind) => {
83
+ let ack;
84
+ const promise = new Promise((resolve, reject) => {
85
+ const abort = () => ack.settle(signal.reason || new Error('Pi native injection aborted'));
86
+ ack = {
87
+ kind,
88
+ sentAfterTurn: turnIndex,
89
+ emptyTurnWindows: 0,
90
+ dropAfterSettled: false,
91
+ settle(error) {
92
+ if (ack.settled) return;
93
+ ack.settled = true;
94
+ signal.removeEventListener('abort', abort);
95
+ const acks = landingAcks.get(content);
96
+ if (acks?.delete(ack) && acks.size === 0) landingAcks.delete(content);
97
+ if (error === undefined) resolve();
98
+ else reject(error);
99
+ },
100
+ };
101
+ let acks = landingAcks.get(content);
102
+ if (acks === undefined) {
103
+ acks = new Set();
104
+ landingAcks.set(content, acks);
105
+ }
106
+ acks.add(ack);
107
+ if (signal.aborted) abort();
108
+ else signal.addEventListener('abort', abort, { once: true });
90
109
  });
110
+ return { promise, ack };
91
111
  };
92
112
 
93
113
  // A transport may keep its promise pending while Pi is shutting down or
94
114
  // replacing a session. Never make a lifecycle hook wait for that transport.
95
115
  const stopWatcher = () => {
96
- for (const controller of boundaryControllers) controller.abort(new Error('Pi session ended'));
97
116
  watcherAbort?.abort();
98
117
  watcher = undefined;
99
118
  watcherAbort = undefined;
@@ -109,7 +128,7 @@ export default function squarePiExtension(pi) {
109
128
  signal.addEventListener('abort', finish, { once: true });
110
129
  });
111
130
 
112
- const wake = async (piContext, token, signal) => {
131
+ const wake = async (token, signal) => {
113
132
  const armDeferredRetry = () => {
114
133
  const controller = new AbortController();
115
134
  let resolveArmed;
@@ -129,14 +148,26 @@ export default function squarePiExtension(pi) {
129
148
  };
130
149
 
131
150
  while (sessionId !== undefined && token === generation && !signal.aborted) {
132
- if ((await sessionBindings(sessionId)).length === 0) {
151
+ try {
152
+ if ((await sessionBindings(sessionId)).length === 0) {
153
+ await pause(signal, 1_000);
154
+ continue;
155
+ }
156
+ } catch {
133
157
  await pause(signal, 1_000);
134
158
  continue;
135
159
  }
160
+
136
161
  const deferredRetry = retryAfterChange;
137
- const pending = deferredRetry && retryWait !== undefined
138
- ? await retryWait
139
- : await waitForSessionPending(sessionId, 30_000, { signal, excludeKeys: handledPending });
162
+ let pending;
163
+ try {
164
+ pending = deferredRetry && retryWait !== undefined
165
+ ? await retryWait
166
+ : await waitForSessionPending(sessionId, 30_000, { signal, excludeKeys: handledPending });
167
+ } catch {
168
+ await pause(signal, 1_000);
169
+ continue;
170
+ }
140
171
  if (sessionId === undefined || token !== generation || signal.aborted) return;
141
172
  if (pending.length === 0) {
142
173
  if (deferredRetry) {
@@ -147,112 +178,136 @@ export default function squarePiExtension(pi) {
147
178
  }
148
179
  retryAfterChange = false;
149
180
  retryWait = undefined;
150
- if (!piContext.isIdle()) {
151
- const serial = settledSerial;
152
- await waitForSettled(serial, signal);
153
- continue;
154
- }
155
- if (presenting) continue;
181
+
156
182
  const keys = inboxKeys(pending);
157
- presenting = true;
158
183
  const deferred = armDeferredRetry();
159
184
  const retryArmed = await deferred.armed;
160
185
  if (!retryArmed) {
161
186
  deferred.cancel();
162
- presenting = false;
163
187
  continue;
164
188
  }
165
189
  let keepDeferred = false;
166
190
  try {
167
- const delivered = await presentPendingAtBoundary(
191
+ await presentPendingAtBoundary(
168
192
  sessionId,
169
193
  async (content) => {
170
- const send = Promise.resolve(pi.sendMessage(
171
- { customType: 'square', content, display: true },
172
- { deliverAs: 'steer', triggerTurn: true },
173
- ));
174
- let timer;
194
+ if (currentRunSignal?.aborted) await waitForAgentSettled(signal);
195
+ const landing = waitForLanding(content, signal, 'steer');
175
196
  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);
197
+ Promise.resolve(pi.sendMessage(
198
+ { customType: 'square', content, display: true },
199
+ { deliverAs: 'steer', triggerTurn: true },
200
+ )).catch((error) => landing.ack.settle(error));
201
+ } catch (error) {
202
+ landing.ack.settle(error);
191
203
  }
192
- return true;
204
+ return landing.promise;
193
205
  },
194
206
  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 (error instanceof PiDeliveryDroppedError) {
213
+ deferred.cancel();
214
+ continue;
200
215
  }
201
- } catch {
202
216
  // The next state edge is already being observed before native injection starts.
203
217
  retryAfterChange = true;
204
218
  retryWait = deferred.pending;
205
219
  keepDeferred = true;
206
220
  } finally {
207
221
  if (!keepDeferred) deferred.cancel();
208
- presenting = false;
209
222
  }
210
223
  }
211
224
  };
212
225
 
213
226
  pi.on('session_start', async (_event, ctx) => {
214
227
  generation += 1;
228
+ failAcks(new Error('Pi session replaced'));
215
229
  stopWatcher();
216
230
  handledPending.clear();
217
231
  retryAfterChange = false;
218
232
  retryWait = undefined;
233
+ turnIndex = 0;
234
+ activeTurn = undefined;
235
+ currentRunSignal = undefined;
219
236
  sessionId = ctx.sessionManager.getSessionId();
220
237
  sessionCwd = ctx.cwd || process.cwd();
221
238
  previousSessionId = process.env.SQUARE_PI_SESSION_ID;
222
239
  process.env.SQUARE_PI_SESSION_ID = sessionId;
223
- joiningContext = undefined;
224
- void automaticSessionStart('pi', sessionId, sessionCwd).catch(() => undefined);
225
- watcherAbort = new AbortController();
226
240
  const token = generation;
227
- watcher = wake(ctx, token, watcherAbort.signal).catch(() => undefined);
241
+ watcherAbort = new AbortController();
242
+ const signal = watcherAbort.signal;
243
+ void automaticSessionStart('pi', sessionId, sessionCwd).then((context) => {
244
+ if (context === undefined || sessionId === undefined || token !== generation) return;
245
+ const landing = waitForLanding(context, signal, 'nextTurn');
246
+ try {
247
+ Promise.resolve(pi.sendMessage(
248
+ { customType: 'square', content: context, display: true },
249
+ { deliverAs: 'nextTurn' },
250
+ )).catch((error) => landing.ack.settle(error));
251
+ } catch {
252
+ // Joining context is advisory; an unavailable Pi transport does not block startup.
253
+ landing.ack.settle(new Error('Pi joining-context injection failed'));
254
+ }
255
+ void landing.promise.catch(() => undefined);
256
+ }).catch(() => undefined);
257
+ watcher = wake(token, signal).catch(() => undefined);
228
258
  });
229
259
 
230
- pi.on('agent_settled', async () => {
231
- settledSerial += 1;
232
- const waiters = settledWaiters;
233
- settledWaiters = [];
234
- for (const waiter of waiters) waiter.resolve();
260
+ pi.on('agent_start', async (_event, ctx) => {
261
+ currentRunSignal = ctx?.getSignal?.();
235
262
  });
236
263
 
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 } };
264
+ pi.on('message_end', async (event) => {
265
+ if (activeTurn !== undefined && (event.message?.role === 'user' || event.message?.role === 'custom')) {
266
+ activeTurn.hasInput = true;
267
+ }
268
+ acknowledgeLanding(event.message);
269
+ });
270
+
271
+ pi.on('turn_start', async () => {
272
+ turnIndex += 1;
273
+ activeTurn = { index: turnIndex, hasInput: false, signal: currentRunSignal };
274
+ });
275
+
276
+ pi.on('turn_end', async (event, ctx) => {
277
+ const turn = activeTurn;
278
+ activeTurn = undefined;
279
+ const acks = [...landingAcks.values()].flatMap((waiters) => [...waiters]);
280
+ if (event.message?.stopReason === 'aborted' && ctx?.mode === 'tui' && turn?.signal !== undefined) {
281
+ for (const ack of acks) {
282
+ if (ack.kind === 'steer') ack.dropAfterSettled = true;
243
283
  }
244
- if (presenting) return undefined;
245
- return await presentAtBoundary((context) => ({ message: { customType: 'square', content: context, display: true } }));
246
- } catch {
247
- return undefined;
284
+ return;
285
+ }
286
+ if (turn === undefined) return;
287
+ for (const ack of acks) {
288
+ if (ack.kind !== 'steer' || ack.dropAfterSettled || turn.index <= ack.sentAfterTurn) continue;
289
+ if (turn.hasInput) {
290
+ ack.emptyTurnWindows = 0;
291
+ } else {
292
+ ack.emptyTurnWindows += 1;
293
+ if (ack.emptyTurnWindows >= 2) ack.settle(new PiDeliveryDroppedError());
294
+ }
295
+ }
296
+ });
297
+
298
+ pi.on('agent_settled', async () => {
299
+ currentRunSignal = undefined;
300
+ const acks = [...landingAcks.values()].flatMap((waiters) => [...waiters]);
301
+ for (const ack of acks) {
302
+ if (ack.dropAfterSettled) ack.settle(new PiDeliveryDroppedError());
248
303
  }
304
+ releaseDeferredSteers();
249
305
  });
250
306
 
251
307
  pi.on('session_shutdown', async () => {
252
308
  generation += 1;
309
+ failAcks(new Error('Pi session ended'));
253
310
  stopWatcher();
254
- for (const waiter of settledWaiters) waiter.resolve();
255
- settledWaiters = [];
256
311
  if (sessionId && sessionCwd) void automaticSessionEnd('pi', sessionId, sessionCwd).catch(() => undefined);
257
312
  if (process.env.SQUARE_PI_SESSION_ID === sessionId) {
258
313
  if (previousSessionId === undefined) delete process.env.SQUARE_PI_SESSION_ID;
@@ -260,8 +315,8 @@ export default function squarePiExtension(pi) {
260
315
  }
261
316
  sessionId = undefined;
262
317
  sessionCwd = undefined;
263
- joiningContext = undefined;
264
- presenting = false;
265
318
  retryWait = undefined;
319
+ activeTurn = undefined;
320
+ currentRunSignal = undefined;
266
321
  });
267
322
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrosheep/square",
3
- "version": "0.3.47",
3
+ "version": "0.3.48",
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": {