@astrosheep/square 0.3.5 → 0.3.7

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.
Files changed (57) hide show
  1. package/codex-plugin/.codex-plugin/plugin.json +3 -2
  2. package/codex-plugin/hooks/hooks.json +3 -14
  3. package/dist/activity-feed.js +26 -18
  4. package/dist/activity.js +10 -10
  5. package/dist/artifact.js +138 -203
  6. package/dist/boundary-presentation.js +77 -0
  7. package/dist/claude-hook.js +4 -94
  8. package/dist/cli/context.js +7 -7
  9. package/dist/cli/maintenance-commands.js +10 -26
  10. package/dist/cli/meta-commands.js +3 -6
  11. package/dist/cli/observation-commands.js +48 -53
  12. package/dist/cli/program.js +4 -4
  13. package/dist/cli/registry.js +5 -5
  14. package/dist/cli/square-commands.js +27 -20
  15. package/dist/cmd/notify-once.js +23 -21
  16. package/dist/codex-hook.js +22 -0
  17. package/dist/compact.js +1 -1
  18. package/dist/decisions.js +61 -88
  19. package/dist/delivery-health.js +104 -210
  20. package/dist/delivery.js +68 -18
  21. package/dist/doctor.js +9 -8
  22. package/dist/harness-claude.js +38 -245
  23. package/dist/harness-codex.js +82 -616
  24. package/dist/harness-stage.js +36 -0
  25. package/dist/harness.js +3 -5
  26. package/dist/help.js +43 -35
  27. package/dist/inbox.js +12 -11
  28. package/dist/index.js +10 -121
  29. package/dist/list.js +1 -1
  30. package/dist/model.js +0 -6
  31. package/dist/notification-failures.js +54 -0
  32. package/dist/notifications.js +47 -62
  33. package/dist/paseo-delivery.js +160 -0
  34. package/dist/paseo-state.js +31 -0
  35. package/dist/paseo-timeline.js +58 -188
  36. package/dist/presentation.js +57 -64
  37. package/dist/presented.js +9 -8
  38. package/dist/registry.js +55 -45
  39. package/dist/runtime.js +27 -84
  40. package/dist/square-application.js +135 -130
  41. package/dist/square-core.js +3 -11
  42. package/dist/stream.js +27 -126
  43. package/dist/wake-sink.js +3 -214
  44. package/dist/watch.js +65 -122
  45. package/extensions/square-opencode.js +8 -73
  46. package/extensions/square-pi.js +8 -132
  47. package/guides/architect.md +3 -3
  48. package/guides/participant.md +25 -16
  49. package/package.json +2 -2
  50. package/skills/brainstorm/SKILL.md +25 -32
  51. package/skills/square/.claude-plugin/plugin.json +1 -1
  52. package/skills/square/SKILL.md +39 -107
  53. package/skills/square/hooks/hooks.json +2 -13
  54. package/skills/square-feedback/SKILL.md +4 -4
  55. package/dist/harness-lifecycle.js +0 -102
  56. package/dist/square-store.js +0 -111
  57. package/dist/terminal.js +0 -125
package/dist/wake-sink.js CHANGED
@@ -1,219 +1,8 @@
1
- /**
2
- * wake-sink.ts shared paseo wake dispatch.
3
- *
4
- * This is the single canonical implementation of "wake a paseo agent
5
- * when they receive an undelivered @mention or --bell in a square."
6
- *
7
- * The detached one-shot notification worker uses this module after the
8
- * natural-delivery grace period. The WakeSink is stateless; the worker owns
9
- * receipt checks, timing, and failure isolation.
10
- */
11
- import { execSync, spawnSync } from 'node:child_process';
12
- import { setTimeout as sleep } from 'node:timers/promises';
13
- import { homedir } from 'node:os';
14
- import { loadSquare } from './artifact.js';
15
- import { SquareError } from './model.js';
16
- import { leaseOwnsNotification } from './delivery.js';
17
- import { sessionInbox } from './inbox.js';
18
- import { waitForPaseoToolBoundary } from './paseo-timeline.js';
19
- import { hasPresentedAttention, presentOnce } from './presented.js';
20
- import { lookupParticipant } from './registry.js';
21
- import { quoteShell } from './presentation.js';
22
- import { isDeliveryDelivered, resolveRosterName } from './runtime.js';
23
- async function waitForCatchDelivery(squarePath, recipient, notification, ownerId) {
24
- const deadline = Date.now() + 180_000;
25
- while (Date.now() < deadline) {
26
- const doc = loadSquare(squarePath);
27
- const known = resolveRosterName(doc, recipient) ?? recipient;
28
- if (isDeliveryDelivered(doc, known, notification.item.index))
29
- return true;
30
- const lease = lookupParticipant(squarePath, known).find((binding) => binding.ownerId === ownerId);
31
- const activeLease = lease === undefined
32
- ? undefined
33
- : sessionInbox(lease.sessionId).find((membership) => membership.name === known)?.catchLease;
34
- if (activeLease === undefined || activeLease.expiresAt <= Date.now())
35
- return false;
36
- await sleep(Math.min(250, Math.max(1, activeLease.expiresAt - Date.now())));
37
- }
38
- return false;
39
- }
40
- const DEFAULT_PASEO_WAKE_TIMEOUT_MS = 90_000;
41
- function paseoWakeTimeoutMs() {
42
- const raw = process.env['SQUARE_PASEO_WAKE_TIMEOUT_MS'];
43
- if (raw === undefined)
44
- return DEFAULT_PASEO_WAKE_TIMEOUT_MS;
45
- const value = Number.parseInt(raw, 10);
46
- if (!Number.isFinite(value) || value <= 0) {
47
- throw new SquareError('invalid_args', 'Invalid SQUARE_PASEO_WAKE_TIMEOUT_MS: expected a positive integer.');
48
- }
49
- return value;
50
- }
51
- // ── helpers ──────────────────────────────────────────────────────
52
- function previewBody(body) {
53
- return body.length > 200 ? body.slice(0, 197) + '...' : body;
54
- }
55
- function wakeNowCommand(squarePath, recipient) {
56
- return `square --as ${quoteShell(recipient)} --square-path ${quoteShell(squarePath)} catch --now`;
57
- }
58
- function nativeWakePrompt(squarePath, recipient) {
59
- return [
60
- '<system-reminder source="square">',
61
- `Square activity is waiting for @${recipient} in ${squarePath}.`,
62
- 'The native adapter will present it at the next boundary. If it does not:',
63
- `\`${wakeNowCommand(squarePath, recipient)}\``,
64
- '</system-reminder>',
65
- ].join('\n');
66
- }
67
- function fullWakePrompt(notification, squarePath) {
68
- const { item, recipient } = notification;
69
- const home = homedir();
70
- const displayPath = squarePath.startsWith(home) ? `~${squarePath.slice(home.length)}` : squarePath;
71
- return [
72
- '<system-reminder source="square">',
73
- `Mentioned by @${item.act.actor} in \`${displayPath}\``,
74
- '',
75
- `> ${previewBody(item.act.body).replace(/\n/g, '\n> ')}`,
76
- '',
77
- 'To catch up and keep presence current:',
78
- `\`${wakeNowCommand(squarePath, recipient)}\``,
79
- '',
80
- '*(Async notification from Square.)*',
81
- '</system-reminder>',
82
- ].join('\n');
83
- }
84
- function hasNativeGuarantee(bindings, ownerId) {
85
- return bindings.some((binding) => binding.ownerId === ownerId &&
86
- (binding.channel === 'claude-code' ||
87
- binding.channel === 'codex' ||
88
- binding.channel === 'opencode' ||
89
- binding.channel === 'pi'));
90
- }
91
- function sendPaseoPrompt(agentId, prompt) {
92
- const result = spawnSync('paseo', ['send', agentId, '--prompt', prompt, '--no-wait'], {
93
- stdio: 'ignore',
94
- timeout: 5000,
95
- });
1
+ import { spawnSync } from 'node:child_process';
2
+ export function sendPaseoWake({ agentId, prompt }) {
3
+ const result = spawnSync(process.env.SQUARE_PASEO_BIN || 'paseo', ['send', agentId, '--prompt', prompt, '--no-wait'], { stdio: 'ignore', timeout: 5000, env: process.env });
96
4
  if (result.error)
97
5
  throw result.error;
98
6
  if (result.status !== 0)
99
7
  throw new Error(`paseo send exited with ${result.status ?? 'no status'}`);
100
8
  }
101
- // ── paseo agent discovery ────────────────────────────────────────
102
- /** List all paseo agents. Returns empty array if paseo is unavailable. */
103
- export function listPaseoAgents() {
104
- try {
105
- return JSON.parse(execSync('paseo ls --json', {
106
- encoding: 'utf8',
107
- stdio: ['ignore', 'pipe', 'ignore'],
108
- }));
109
- }
110
- catch {
111
- return [];
112
- }
113
- }
114
- /**
115
- * Wait for the tool calls that are running now to finish. Calls that start
116
- * afterward are deliberately ignored: the transitional wake policy allows
117
- * `paseo send` to replace the next call.
118
- */
119
- export async function waitForToolEnd(agentId, timeoutMs) {
120
- return waitForPaseoToolBoundary(agentId, {
121
- timeoutMs: timeoutMs ?? paseoWakeTimeoutMs(),
122
- });
123
- }
124
- // ── paseo wake dispatch ──────────────────────────────────────────
125
- /**
126
- * Dispatch a paseo send wake prompt for a single notification.
127
- *
128
- * Resolves exact Paseo agent ids from the machine-local participant registry.
129
- * Idle agents receive the wake immediately. Running agents wait for the
130
- * current tool call boundary; the following `paseo send` may replace the next
131
- * call by design.
132
- */
133
- export function selectPaseoWakeAgents(squarePath, recipient, agents = listPaseoAgents()) {
134
- const ids = new Set(lookupParticipant(squarePath, recipient)
135
- .map((binding) => binding.paseoAgentId)
136
- .filter((id) => id !== undefined));
137
- return agents.filter((agent) => ids.has(agent.id) && (agent.status === 'running' || agent.status === 'idle'));
138
- }
139
- export async function dispatchPaseoWake(notification, ctx) {
140
- const { item, recipient } = notification;
141
- const bindings = lookupParticipant(ctx.squarePath, recipient);
142
- const activeAgents = selectPaseoWakeAgents(ctx.squarePath, recipient);
143
- if (activeAgents.length === 0)
144
- return;
145
- const wokenOwners = new Set();
146
- for (const active of activeAgents) {
147
- if (active.status === 'running') {
148
- const reachedBoundary = await waitForToolEnd(active.id);
149
- if (!reachedBoundary)
150
- continue;
151
- }
152
- // Re-read after the tool wait: harness inject may have presented during the wait.
153
- try {
154
- const doc = loadSquare(ctx.squarePath);
155
- const known = resolveRosterName(doc, recipient) ?? recipient;
156
- if (isDeliveryDelivered(doc, known, item.index))
157
- return;
158
- if (hasPresentedAttention(ctx.squarePath, known, item.index))
159
- return;
160
- }
161
- catch {
162
- // Receipt re-read failure fails open toward wake.
163
- }
164
- const binding = bindings.find((candidate) => candidate.paseoAgentId === active.id);
165
- if (binding === undefined || wokenOwners.has(binding.ownerId))
166
- continue;
167
- try {
168
- const catchMembership = sessionInbox(binding.sessionId).find((membership) => membership.name === (resolveRosterName(loadSquare(ctx.squarePath), recipient) ?? recipient) &&
169
- membership.catchLease !== undefined &&
170
- leaseOwnsNotification(membership.catchLease, {
171
- actor: item.act.actor,
172
- body: item.act.body,
173
- via: notification.via === 'bell' ? 'bell' : 'mention',
174
- }));
175
- if (catchMembership?.catchLease !== undefined) {
176
- if (await waitForCatchDelivery(ctx.squarePath, recipient, notification, binding.ownerId))
177
- return;
178
- const current = lookupParticipant(ctx.squarePath, recipient).find((candidate) => candidate.paseoAgentId === active.id);
179
- if (current?.ownerId !== binding.ownerId)
180
- continue;
181
- const doc = loadSquare(ctx.squarePath);
182
- const known = resolveRosterName(doc, recipient) ?? recipient;
183
- if (isDeliveryDelivered(doc, known, item.index) ||
184
- hasPresentedAttention(ctx.squarePath, known, item.index)) {
185
- return;
186
- }
187
- }
188
- if (hasNativeGuarantee(bindings, binding.ownerId)) {
189
- sendPaseoPrompt(active.id, nativeWakePrompt(ctx.squarePath, recipient));
190
- wokenOwners.add(binding.ownerId);
191
- continue;
192
- }
193
- presentOnce(binding.sessionId, (sessionId) => sessionInbox(sessionId)
194
- .map((membership) => ({
195
- ...membership,
196
- notifications: membership.notifications.filter((candidate) => candidate.actIndex === item.index),
197
- }))
198
- .filter((membership) => membership.notifications.length > 0), () => sendPaseoPrompt(active.id, fullWakePrompt(notification, ctx.squarePath)));
199
- wokenOwners.add(binding.ownerId);
200
- }
201
- catch {
202
- // Paseo unavailable: notification remains unpresented for a later retry.
203
- }
204
- }
205
- }
206
- // ── WakeSink factory ─────────────────────────────────────────────
207
- /** Create the default paseo WakeSink used by both sync and async paths. */
208
- export function paseoWakeSink() {
209
- return {
210
- name: 'paseo',
211
- dispatch: dispatchPaseoWake,
212
- };
213
- }
214
- /** Default sink list, respecting SQUARE_DISABLE_PASEO_WAKE. */
215
- export function defaultWakeSinks() {
216
- if (process.env.SQUARE_DISABLE_PASEO_WAKE === '1')
217
- return [];
218
- return [paseoWakeSink()];
219
- }
package/dist/watch.js CHANGED
@@ -1,23 +1,16 @@
1
1
  import { setTimeout as sleep } from 'node:timers/promises';
2
2
  import { loadSquare } from './artifact.js';
3
3
  import { SquareError, nameKey, } from './model.js';
4
- import { deriveDeliveryModel } from './delivery.js';
5
- import { SLEEP_MS, STALE_MS, WATCH_HEARTBEAT_MS, WATCH_STALE_MS, countSays, currentHold, doneNames, freshWatchLease, hasQuorum, inSquareCount, markDeliveredMentions, nowMs, readCursor, touchPresenceCursor, } from './runtime.js';
6
- import { withSquareLock, writeSquareDoc } from './square-store.js';
7
- import { renderWatchForceTakeover, renderWatchAlreadyActive, renderWatchInterrupted, renderWatchOutput, renderWatchReplaced, renderWatchStatus, participantCommandPrefix, withPathOutput, withWatchNextOutput, } from './presentation.js';
8
- import { ackPeerDelta, filteredPeerActivities, filteredRoomChanges, indexedDelta, matchesFeedFilter, peerPublicActs, peerRoomChanges, } from './activity-feed.js';
4
+ import { markDeliveredNotifications } from './delivery.js';
5
+ import { SLEEP_MS, STALE_MS, WATCH_HEARTBEAT_MS, WATCH_STALE_MS, countSays, currentHold, doneNames, freshWatchLease, hasQuorum, inSquareCount, nowMs, touchPresenceCursor, writeWatchLease, } from './runtime.js';
6
+ import { withSquareLock, writeSquareDoc } from './square-application.js';
7
+ import { renderWatchForceTakeover, renderWatchAlreadyActive, renderWatchOutput, renderWatchReplaced, renderWatchStatus, participantCommandPrefix, withPathOutput, } from './presentation.js';
8
+ import { ackPeerDelta, deliveryDelta, filteredPeerActivities, filteredRoomChanges, matchesFeedFilter, peerPublicActs, peerRoomChanges, } from './activity-feed.js';
9
9
  import { coreParticipants, resolveKnownName } from './decisions.js';
10
- import { hasAutomaticDeliveryIdentity } from './registry.js';
10
+ import { hasAutomaticDeliveryIdentity, localParticipantOwner } from './registry.js';
11
11
  import { execute } from './square-application.js';
12
- /** Notification receipts are stronger than the public-feed cursor, which self activity may advance. */
13
12
  function catchDelta(doc, name) {
14
- const items = indexedDelta(doc.acts, readCursor(doc, name));
15
- const seen = new Set(items.map((item) => item.index));
16
- for (const notification of deriveDeliveryModel(doc).pendingFor(name)) {
17
- if (!seen.has(notification.item.index))
18
- items.push(notification.item);
19
- }
20
- return items.sort((a, b) => a.index - b.index);
13
+ return deliveryDelta(doc, name);
21
14
  }
22
15
  function watchStatusExitCode(status) {
23
16
  return status === 'capped' ? 1 : 0;
@@ -32,21 +25,22 @@ function leaseFilter(opts) {
32
25
  };
33
26
  return Object.keys(filter).length === 0 ? undefined : filter;
34
27
  }
35
- function setLease(doc, name, id, at, opts) {
28
+ function setLease(doc, name, id, at, opts, ownerId) {
36
29
  const filter = leaseFilter(opts);
37
- doc.runtime.leases[name] = {
30
+ writeWatchLease(doc, name, {
38
31
  leaseId: id,
32
+ ...(ownerId === undefined ? {} : { ownerId }),
39
33
  heartbeatAt: at,
40
34
  expiresAt: at + WATCH_STALE_MS,
41
35
  ...(filter ? { filter } : {}),
42
- };
36
+ });
43
37
  }
44
38
  function sameLease(doc, name, id, at = nowMs()) {
45
39
  return freshWatchLease(doc, name, at)?.leaseId === id;
46
40
  }
47
41
  function consumeDelta(doc, name, delta, delivered, at) {
48
42
  const consumed = ackPeerDelta(doc, name, delta);
49
- const receipts = markDeliveredMentions(doc, name, delivered, at);
43
+ const receipts = markDeliveredNotifications(doc, name, delivered, at);
50
44
  return consumed || receipts;
51
45
  }
52
46
  function watchOutputResult(squarePath, doc, name, delta, opts = {}) {
@@ -69,7 +63,7 @@ function loadPresence(squarePath) {
69
63
  try {
70
64
  const doc = loadSquare(squarePath);
71
65
  const now = nowMs();
72
- return { participants: coreParticipants(doc, now).participants, now };
66
+ return { participants: coreParticipants(doc, now), now };
73
67
  }
74
68
  catch {
75
69
  return undefined;
@@ -94,18 +88,54 @@ function writeWatchOutput(squarePath, name, stdout, status, idleMs) {
94
88
  const fallback = showCatchHint
95
89
  ? `» ${participantCommandPrefix(squarePath, name)} catch --idle 30m\n stay available for new activity`
96
90
  : '';
97
- process.stdout.write(withWatchNextOutput(squarePath, [stdout.trimEnd(), fallback].filter(Boolean).join('\n\n'), headerOpts));
91
+ process.stdout.write(withPathOutput(squarePath, [stdout.trimEnd(), fallback].filter(Boolean).join('\n\n').trimEnd(), headerOpts));
92
+ }
93
+ function writeWatchTerminal(squarePath, name, status, idleMs) {
94
+ const presence = loadPresence(squarePath);
95
+ process.stdout.write(withPathOutput(squarePath, renderWatchStatus({
96
+ status,
97
+ squarePath,
98
+ name,
99
+ ...(idleMs === undefined ? {} : { idleMs }),
100
+ presence,
101
+ showCatchHint: !hasAutomaticDeliveryIdentity(),
102
+ }), { participantCount: loadHeaderCount(squarePath) }));
103
+ }
104
+ function writeWatchReplaced(squarePath, name) {
105
+ process.stdout.write(withPathOutput(squarePath, renderWatchReplaced({ squarePath, name }), { participantCount: loadHeaderCount(squarePath) }));
106
+ }
107
+ async function finishWatchResult(squarePath, name, result, leaseId, idleMs) {
108
+ if (result.type === 'output') {
109
+ await endWatch(squarePath, name, leaseId);
110
+ writeWatchOutput(squarePath, name, result.stdout, result.status);
111
+ process.exitCode = watchStatusExitCode(result.status);
112
+ return true;
113
+ }
114
+ if (result.type === 'terminal') {
115
+ await endWatch(squarePath, name, leaseId);
116
+ writeWatchTerminal(squarePath, name, result.status, idleMs);
117
+ process.exitCode = watchStatusExitCode(result.status);
118
+ return true;
119
+ }
120
+ if (result.type === 'replaced') {
121
+ writeWatchReplaced(squarePath, name);
122
+ process.exitCode = 0;
123
+ return true;
124
+ }
125
+ return false;
98
126
  }
99
127
  async function beginWatch(squarePath, name, opts) {
100
128
  const at = nowMs();
101
129
  const id = leaseId();
130
+ const ownerId = localParticipantOwner(squarePath, name);
102
131
  const committed = await execute(squarePath, {
103
132
  type: 'lease',
104
133
  name,
105
134
  leaseId: id,
135
+ ...(ownerId === undefined ? {} : { ownerId }),
106
136
  at,
107
137
  expiresAt: at + WATCH_STALE_MS,
108
- force: opts.force,
138
+ force: opts.replace,
109
139
  filter: leaseFilter(opts),
110
140
  });
111
141
  if (committed.result.type === 'active')
@@ -121,7 +151,7 @@ function installWatchInterruptHandler(squarePath, name, currentLeaseId) {
121
151
  const onInterrupt = () => {
122
152
  void (async () => {
123
153
  await endWatch(squarePath, name, currentLeaseId());
124
- process.stdout.write(withPathOutput(squarePath, renderWatchInterrupted({ squarePath, name })));
154
+ process.stdout.write(withPathOutput(squarePath, '✕ catch stopped'));
125
155
  process.exit(130);
126
156
  })().catch((error) => {
127
157
  process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
@@ -147,7 +177,7 @@ async function cmdWatchNow(squarePath, name, opts) {
147
177
  const result = await withSquareLock(squarePath, () => {
148
178
  const doc = loadSquare(squarePath);
149
179
  const at = nowMs();
150
- const touched = touchPresenceCursor(doc, name, at, 'watch');
180
+ const touched = touchPresenceCursor(doc, name, at);
151
181
  const delta = catchDelta(doc, name);
152
182
  const peerPublic = peerPublicActs(delta, name);
153
183
  const roomChanges = peerRoomChanges(delta, name);
@@ -175,25 +205,7 @@ async function cmdWatchNow(squarePath, name, opts) {
175
205
  return { type: 'terminal', status };
176
206
  return { type: 'terminal', status: 'empty-now' };
177
207
  });
178
- if (result.type === 'output') {
179
- writeWatchOutput(squarePath, name, result.stdout, result.status);
180
- process.exitCode = watchStatusExitCode(result.status);
181
- return;
182
- }
183
- if (result.type === 'terminal') {
184
- const presence = loadPresence(squarePath);
185
- process.stdout.write(withPathOutput(squarePath, renderWatchStatus({
186
- status: result.status,
187
- squarePath,
188
- name,
189
- presence,
190
- showCatchHint: !hasAutomaticDeliveryIdentity(),
191
- }), {
192
- participantCount: loadHeaderCount(squarePath),
193
- }));
194
- process.exitCode = watchStatusExitCode(result.status);
195
- return;
196
- }
208
+ await finishWatchResult(squarePath, name, result, undefined);
197
209
  }
198
210
  export async function cmdWatch(squarePath, name, opts) {
199
211
  let initialDoc;
@@ -214,14 +226,11 @@ export async function cmdWatch(squarePath, name, opts) {
214
226
  throw err;
215
227
  }
216
228
  if (opts.now) {
217
- if (opts.activityCount !== 1)
218
- process.stderr.write('--count is ignored with --now\n');
219
229
  await cmdWatchNow(squarePath, name, opts);
220
230
  return;
221
231
  }
222
232
  const start = await beginWatch(squarePath, name, opts);
223
233
  if (start.type === 'active') {
224
- const presence = loadPresence(squarePath);
225
234
  process.stdout.write(withPathOutput(squarePath, renderWatchAlreadyActive({ squarePath, name }), { participantCount: loadHeaderCount(squarePath) }));
226
235
  process.exit(1);
227
236
  }
@@ -229,7 +238,6 @@ export async function cmdWatch(squarePath, name, opts) {
229
238
  let currentLeaseId = start.leaseId;
230
239
  let nextHeartbeatAt = start.heartbeatAt + WATCH_HEARTBEAT_MS;
231
240
  if (start.replaced) {
232
- const presence = loadPresence(squarePath);
233
241
  process.stdout.write(withPathOutput(squarePath, renderWatchForceTakeover({ squarePath, name }), { participantCount: loadHeaderCount(squarePath) }));
234
242
  }
235
243
  const idleMs = opts.idleMs ?? STALE_MS;
@@ -239,12 +247,13 @@ export async function cmdWatch(squarePath, name, opts) {
239
247
  const result = await withSquareLock(squarePath, () => {
240
248
  const doc = loadSquare(squarePath);
241
249
  const at = nowMs();
242
- if (!sameLease(doc, name, currentLeaseId, at))
250
+ const lease = freshWatchLease(doc, name, at);
251
+ if (lease === undefined || lease.leaseId !== currentLeaseId)
243
252
  return { type: 'replaced' };
244
253
  let mutated = false;
245
254
  if (at >= nextHeartbeatAt) {
246
- setLease(doc, name, currentLeaseId, at, opts);
247
- mutated = touchPresenceCursor(doc, name, at, 'watch') || mutated;
255
+ setLease(doc, name, currentLeaseId, at, opts, lease.ownerId);
256
+ mutated = touchPresenceCursor(doc, name, at) || mutated;
248
257
  nextHeartbeatAt = at + WATCH_HEARTBEAT_MS;
249
258
  mutated = true;
250
259
  }
@@ -263,7 +272,7 @@ export async function cmdWatch(squarePath, name, opts) {
263
272
  }
264
273
  if (hasDeliverable &&
265
274
  hasFilteredDeliverable &&
266
- (filteredActivities.length >= opts.activityCount || matchingRoomChanges.length > 0 || status !== undefined)) {
275
+ (filteredActivities.length > 0 || matchingRoomChanges.length > 0 || status !== undefined)) {
267
276
  return watchOutputResult(squarePath, doc, name, delta, {
268
277
  participants: opts.participants,
269
278
  mention: opts.mention,
@@ -281,49 +290,12 @@ export async function cmdWatch(squarePath, name, opts) {
281
290
  writeSquareDoc(squarePath, doc);
282
291
  return { type: 'sleep' };
283
292
  });
284
- switch (result.type) {
285
- case 'output': {
286
- const status = result.status;
287
- if (opts.follow === true && status === undefined) {
288
- writeWatchOutput(squarePath, name, result.stdout);
289
- staleSince = nowMs();
290
- break;
291
- }
292
- await endWatch(squarePath, name, currentLeaseId);
293
- currentLeaseId = undefined;
294
- writeWatchOutput(squarePath, name, result.stdout, status);
295
- process.exitCode = watchStatusExitCode(status);
296
- return;
297
- }
298
- case 'terminal':
299
- await endWatch(squarePath, name, currentLeaseId);
300
- currentLeaseId = undefined;
301
- const presence = loadPresence(squarePath);
302
- process.stdout.write(withPathOutput(squarePath, renderWatchStatus({
303
- status: result.status,
304
- squarePath,
305
- name,
306
- presence,
307
- showCatchHint: !hasAutomaticDeliveryIdentity(),
308
- }), {
309
- participantCount: loadHeaderCount(squarePath),
310
- }));
311
- process.exitCode = watchStatusExitCode(result.status);
312
- return;
313
- case 'replaced':
314
- currentLeaseId = undefined;
315
- {
316
- const presence = loadPresence(squarePath);
317
- process.stdout.write(withPathOutput(squarePath, renderWatchReplaced({ squarePath, name }), { participantCount: loadHeaderCount(squarePath) }));
318
- }
319
- process.exitCode = 0;
320
- return;
321
- case 'sleep':
322
- break;
323
- case 'held':
324
- staleSince = nowMs();
325
- break;
293
+ if (await finishWatchResult(squarePath, name, result, currentLeaseId)) {
294
+ currentLeaseId = undefined;
295
+ return;
326
296
  }
297
+ if (result.type === 'held')
298
+ staleSince = nowMs();
327
299
  if (nowMs() - staleSince >= idleMs) {
328
300
  const result = await withSquareLock(squarePath, () => {
329
301
  const doc = loadSquare(squarePath);
@@ -341,37 +313,8 @@ export async function cmdWatch(squarePath, name, opts) {
341
313
  }
342
314
  return { type: 'terminal', status: 'stale' };
343
315
  });
344
- if (result.type === 'replaced') {
345
- currentLeaseId = undefined;
346
- {
347
- const presence = loadPresence(squarePath);
348
- process.stdout.write(withPathOutput(squarePath, renderWatchReplaced({ squarePath, name }), { participantCount: loadHeaderCount(squarePath) }));
349
- }
350
- process.exitCode = 0;
351
- return;
352
- }
353
- if (result.type === 'output') {
354
- await endWatch(squarePath, name, currentLeaseId);
355
- currentLeaseId = undefined;
356
- writeWatchOutput(squarePath, name, result.stdout, result.status);
357
- process.exitCode = 0;
358
- return;
359
- }
360
- if (result.type === 'terminal') {
361
- await endWatch(squarePath, name, currentLeaseId);
316
+ if (await finishWatchResult(squarePath, name, result, currentLeaseId, idleMs)) {
362
317
  currentLeaseId = undefined;
363
- const presence = loadPresence(squarePath);
364
- process.stdout.write(withPathOutput(squarePath, renderWatchStatus({
365
- status: result.status,
366
- squarePath,
367
- name,
368
- idleMs,
369
- presence,
370
- showCatchHint: !hasAutomaticDeliveryIdentity(),
371
- }), {
372
- participantCount: loadHeaderCount(squarePath),
373
- }));
374
- process.exitCode = watchStatusExitCode(result.status);
375
318
  return;
376
319
  }
377
320
  }
@@ -1,87 +1,22 @@
1
- import {
2
- deferToActiveCatch,
3
- opencodeHookResponse,
4
- renderClaudeInboxContext,
5
- } from '../dist/claude-hook.js';
6
- import { sessionInbox } from '../dist/inbox.js';
7
- import { presentOnce } from '../dist/presented.js';
8
-
9
- function pendingSignature(sessionId) {
10
- const keys = sessionInbox(sessionId).flatMap((membership) =>
11
- membership.notifications.map(
12
- (notification) =>
13
- `${membership.squarePath}\u0000${membership.name.toLocaleLowerCase()}\u0000${notification.actIndex}`
14
- )
15
- );
16
- return keys.sort().join('\n');
17
- }
18
-
19
- const IDLE_WAKE = [
20
- '<system-reminder source="square">',
21
- 'Square activity is waiting. Process the Square context injected into this turn, then run its catch command.',
22
- '</system-reminder>',
23
- ].join('\n');
24
-
25
- export default async function squareOpenCodePlugin({ client }) {
26
- const handledAtIdle = new Map();
1
+ import { presentPendingAtBoundary } from '../dist/boundary-presentation.js';
27
2
 
3
+ export default async function squareOpenCodePlugin() {
28
4
  return {
29
5
  'shell.env': async (input, output) => {
30
6
  if (input.sessionID) output.env.OPENCODE_SESSION_ID = input.sessionID;
31
7
  },
32
8
 
33
- 'experimental.chat.system.transform': async (input, output) => {
34
- if (!input.sessionID) return;
9
+ 'tool.execute.after': async (input, output) => {
35
10
  try {
36
- // Membership comes only from explicit join/act/catch claims, never env inheritance.
37
- presentOnce(
11
+ presentPendingAtBoundary(
38
12
  input.sessionID,
39
- (sessionId) => deferToActiveCatch(sessionInbox(sessionId)),
40
- (inbox) => output.system.push(renderClaudeInboxContext(inbox))
13
+ (context) => {
14
+ output.output = `${output.output}${output.output === '' ? '' : '\n\n'}${context}`;
15
+ }
41
16
  );
42
-
43
- const signature = pendingSignature(input.sessionID);
44
- if (signature === '') handledAtIdle.delete(input.sessionID);
45
- else handledAtIdle.set(input.sessionID, signature);
46
- } catch {
47
- // Adapter failures leave attention unpresented for a later boundary.
48
- }
49
- },
50
-
51
- event: async ({ event }) => {
52
- if (event.type !== 'session.idle') return;
53
- const sessionId = event.properties.sessionID;
54
- try {
55
- const signature = pendingSignature(sessionId);
56
- if (signature === '') {
57
- handledAtIdle.delete(sessionId);
58
- return;
59
- }
60
- if (handledAtIdle.get(sessionId) === signature) return;
61
-
62
- const response = opencodeHookResponse({
63
- session_id: sessionId,
64
- hook_event_name: 'Stop',
65
- stop_hook_active: false,
66
- });
67
- if (response?.decision !== 'block') return;
68
-
69
- handledAtIdle.set(sessionId, signature);
70
- try {
71
- await client.session.promptAsync({
72
- path: { id: sessionId },
73
- body: { parts: [{ type: 'text', text: IDLE_WAKE }] },
74
- });
75
- } catch {
76
- handledAtIdle.delete(sessionId);
77
- }
78
17
  } catch {
79
- // Idle acceleration is best-effort and must not break the host session.
18
+ // A failed admission remains available at a later boundary.
80
19
  }
81
20
  },
82
-
83
- dispose: async () => {
84
- handledAtIdle.clear();
85
- },
86
21
  };
87
22
  }