@astrosheep/square 0.3.86 → 0.3.87

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 (141) hide show
  1. package/dist/artifact.d.ts +6 -1
  2. package/dist/artifact.js +79 -10
  3. package/dist/boundary-presentation.d.ts +5 -4
  4. package/dist/boundary-presentation.js +21 -25
  5. package/dist/catch-decisions.js +2 -1
  6. package/dist/claude-delivery-status.d.ts +8 -2
  7. package/dist/claude-delivery-status.js +26 -10
  8. package/dist/claude-delivery.d.ts +8 -4
  9. package/dist/claude-delivery.js +114 -104
  10. package/dist/claude-mcp-context.js +2 -2
  11. package/dist/claude-mod.js +119 -105
  12. package/dist/cli/context.d.ts +3 -1
  13. package/dist/cli/context.js +25 -1
  14. package/dist/cli/express-presentation.d.ts +3 -1
  15. package/dist/cli/express-presentation.js +14 -10
  16. package/dist/cli/mcp-server-command.js +2 -2
  17. package/dist/cli/observation-commands.js +1 -0
  18. package/dist/cli/registry.js +0 -2
  19. package/dist/cli/square-commands.d.ts +4 -2
  20. package/dist/cli/square-commands.js +21 -6
  21. package/dist/codex-hook.d.ts +3 -3
  22. package/dist/codex-hook.js +4 -4
  23. package/dist/codex-queue.d.ts +5 -6
  24. package/dist/codex-queue.js +9 -5
  25. package/dist/decisions.d.ts +3 -2
  26. package/dist/decisions.js +18 -6
  27. package/dist/delivery-health.d.ts +32 -2
  28. package/dist/delivery-health.js +108 -45
  29. package/dist/delivery-operations.d.ts +1 -2
  30. package/dist/delivery-operations.js +155 -196
  31. package/dist/delivery.d.ts +14 -23
  32. package/dist/delivery.js +48 -18
  33. package/dist/file-lock.d.ts +8 -1
  34. package/dist/file-lock.js +2 -0
  35. package/dist/help.js +8 -11
  36. package/dist/inbox.d.ts +35 -9
  37. package/dist/inbox.js +228 -144
  38. package/dist/index.d.ts +3 -4
  39. package/dist/index.js +1 -3
  40. package/dist/list.js +9 -4
  41. package/dist/mcp.js +12 -1
  42. package/dist/model.d.ts +39 -5
  43. package/dist/model.js +84 -0
  44. package/dist/notifications.d.ts +8 -6
  45. package/dist/notifications.js +56 -143
  46. package/dist/operation-presentation.js +3 -4
  47. package/dist/participant-identity.d.ts +0 -3
  48. package/dist/participant-identity.js +0 -31
  49. package/dist/paseo-delivery.d.ts +5 -6
  50. package/dist/paseo-delivery.js +13 -8
  51. package/dist/paseo.d.ts +1 -1
  52. package/dist/paseo.js +1 -1
  53. package/dist/plugins/opencode/delivery.d.ts +8 -3
  54. package/dist/plugins/opencode/delivery.js +187 -132
  55. package/dist/plugins/opencode/index.js +12 -38
  56. package/dist/ports.d.ts +112 -40
  57. package/dist/presentation-operations.d.ts +1 -1
  58. package/dist/presentation-operations.js +51 -35
  59. package/dist/presentation.js +8 -3
  60. package/dist/seats.d.ts +7 -0
  61. package/dist/seats.js +27 -0
  62. package/dist/session-lifecycle.d.ts +2 -2
  63. package/dist/session-lifecycle.js +10 -12
  64. package/dist/sqlite-square.d.ts +20 -9
  65. package/dist/sqlite-square.js +471 -339
  66. package/dist/square-api.d.ts +1 -0
  67. package/dist/square-api.js +3 -4
  68. package/dist/square-core.d.ts +3 -2
  69. package/dist/square-domain.d.ts +12 -5
  70. package/dist/square-domain.js +163 -7
  71. package/dist/square-facade.d.ts +14 -10
  72. package/dist/square-index.d.ts +24 -0
  73. package/dist/square-index.js +131 -0
  74. package/dist/square-legacy-framing.d.ts +9 -0
  75. package/dist/square-legacy-framing.js +56 -0
  76. package/dist/square-migration-host-ledger.d.ts +26 -0
  77. package/dist/square-migration-host-ledger.js +168 -0
  78. package/dist/square-migration.d.ts +5 -5
  79. package/dist/square-migration.js +373 -71
  80. package/dist/square-sqlite.d.ts +9 -9
  81. package/dist/square-sqlite.js +329 -79
  82. package/dist/square-stdio.d.ts +1 -1
  83. package/dist/square-stdio.js +12 -7
  84. package/dist/square-tools.js +29 -2
  85. package/dist/stdio-server.js +4 -2
  86. package/dist/stream.d.ts +3 -2
  87. package/dist/stream.js +9 -10
  88. package/dist/views.d.ts +9 -14
  89. package/dist/views.js +50 -70
  90. package/dist/wakes.d.ts +6 -5
  91. package/dist/wakes.js +20 -17
  92. package/guides/application-facade-delivery-evidence.md +1 -1
  93. package/guides/claude-native.md +9 -9
  94. package/guides/mcp.md +1 -1
  95. package/guides/migration.md +107 -67
  96. package/guides/row-storage-design.md +1 -1
  97. package/guides/square-api.md +61 -28
  98. package/package.json +1 -1
  99. package/plugins/claude/.claude-plugin/plugin.json +1 -1
  100. package/plugins/claude/hooks/register.js +1 -1
  101. package/plugins/claude/skills/square/SKILL.md +1 -1
  102. package/plugins/claude/types.d.ts +1 -1
  103. package/plugins/codex/.codex-plugin/plugin.json +1 -1
  104. package/plugins/opencode/delivery.ts +170 -118
  105. package/plugins/opencode/index.ts +12 -30
  106. package/plugins/pi/square-pi.js +55 -17
  107. package/skills/square/SKILL.md +1 -1
  108. package/dist/cli/ledger-command.d.ts +0 -12
  109. package/dist/cli/ledger-command.js +0 -72
  110. package/dist/host-ledger-file-adapter.d.ts +0 -47
  111. package/dist/host-ledger-file-adapter.js +0 -384
  112. package/dist/host-ledger-migration.d.ts +0 -27
  113. package/dist/host-ledger-migration.js +0 -264
  114. package/dist/host-ledger-root.d.ts +0 -2
  115. package/dist/host-ledger-root.js +0 -9
  116. package/dist/host-ledger-sqlite.d.ts +0 -65
  117. package/dist/host-ledger-sqlite.js +0 -368
  118. package/dist/host-ledger.d.ts +0 -195
  119. package/dist/host-ledger.js +0 -1
  120. package/dist/ledger-maintenance.d.ts +0 -30
  121. package/dist/ledger-maintenance.js +0 -110
  122. package/dist/open-square.d.ts +0 -38
  123. package/dist/open-square.js +0 -81
  124. package/dist/participant-host.d.ts +0 -23
  125. package/dist/participant-host.js +0 -92
  126. package/dist/registry.d.ts +0 -55
  127. package/dist/registry.js +0 -123
  128. package/dist/routes.d.ts +0 -39
  129. package/dist/routes.js +0 -99
  130. package/dist/square-actions.d.ts +0 -56
  131. package/dist/square-actions.js +0 -353
  132. package/dist/square-projections.d.ts +0 -31
  133. package/dist/square-projections.js +0 -64
  134. package/dist/wake-attempts.d.ts +0 -10
  135. package/dist/wake-attempts.js +0 -39
  136. package/dist/wake-eligibility.d.ts +0 -53
  137. package/dist/wake-eligibility.js +0 -123
  138. package/dist/wake-evidence.d.ts +0 -33
  139. package/dist/wake-evidence.js +0 -32
  140. package/guides/host-ledger.md +0 -58
  141. package/guides/indexed-host-ledger-performance.md +0 -39
@@ -1,59 +1,70 @@
1
- import type { EvidenceRecord, NativeDeliveryEvidence, PresenceRecord } from '../../host-ledger.js';
2
- import type { SquareRead, WakeTransportPort } from '../../ports.js';
3
- import { nameKey } from '../../model.js';
4
- import { nativePendingPreview, observeSessionPending, sessionInbox } from '../../inbox.js';
5
- import { hostLedgerForEnv } from '../../registry.js';
6
- import { openSquare } from '../../open-square.js';
7
- import { closeOpenSquare } from '../../open-square.js';
8
- import { presentationSuppressesWake, projectPresentationEvidence } from '../../square-projections.js';
9
- import { presentPending } from '../../presentation-operations.js';
1
+ import { createHash } from 'node:crypto';
2
+ import { createArtifactPort } from '../../artifact.js';
10
3
  import { deliverPending } from '../../delivery-operations.js';
11
- import { createWakeTransport } from '../../notifications.js';
12
- import { publishWakeRoute } from '../../routes.js';
4
+ import { nativePendingPreview, observeSessionPending, projectSessionSeats } from '../../inbox.js';
5
+ import { automaticWakeEligible, presentationBlocksWake } from '../../delivery.js';
6
+ import { sameBody, type Seat } from '../../model.js';
7
+ import type { SquareRead, WakeRequest, WakeTransportPort } from '../../ports.js';
13
8
  import { formatActivityId, parseActivityId } from '../../square-core.js';
9
+ import { indexedSquareEntries } from '../../square-index.js';
10
+ import { asSeatRef } from '../../seats.js';
11
+ import { presentPending } from '../../presentation-operations.js';
14
12
  import { createNativeInputId, sendNativeText, type OpenCodeNativeSession } from '../../packages/agent-delivery/src/opencode-native.js';
15
13
 
16
14
  const routeKind = 'opencode-server' as const;
17
- type NativeRow = EvidenceRecord & { claimToken: string; nativeDelivery: Extract<NativeDeliveryEvidence, { harness: 'opencode' }> };
15
+ const bodyFor = (sessionId: string) => ({ channel: 'opencode', session: sessionId });
16
+ const payloadHash = (payload: string) => createHash('sha256').update(payload).digest('hex');
18
17
  export interface OpenCodeContextMessage { readonly id?: string; readonly role: string; readonly content: readonly { readonly type: string; readonly text?: string }[] }
19
- const key = (location: string, participant: string, index: number) => `${location}\0${nameKey(participant)}\0${index}`;
20
18
 
21
- /** The one native pending preview: the audience/catch/epoch fence before send and inside presentation. */
22
- function preview(facts: SquareRead, owner: PresenceRecord, index: number) {
23
- return nativePendingPreview(facts, { location: owner.location, routeKind, sessionId: owner.session, participant: owner.participant, actIndex: index, epoch: owner.epoch, address: { sessionId: owner.session }, cancelledThrough: owner.cancelledThrough, now: Date.now() });
19
+ function preview(facts: SquareRead, location: string, seat: Seat, index: number) {
20
+ return nativePendingPreview(facts, {
21
+ location, routeKind, sessionId: seat.body.session, participant: seat.participant,
22
+ actIndex: index, address: { sessionId: seat.body.session }, now: Date.now(),
23
+ });
24
+ }
25
+
26
+ export async function indexedOpenCodeSessions(env: NodeJS.ProcessEnv): Promise<readonly string[]> {
27
+ return [...new Set((await indexedSquareEntries({ channel: 'opencode' }, env)).map((entry) => entry.body.session))];
24
28
  }
25
29
 
26
- function createOpenCodeWakeTransport(target: OpenCodeNativeSession, env: NodeJS.ProcessEnv, lifetime: AbortSignal): WakeTransportPort {
27
- const ledger = hostLedgerForEnv(env);
30
+ export async function discoverOpenCodeSeats(sessionId: string, env: NodeJS.ProcessEnv, signal?: AbortSignal) {
31
+ return projectSessionSeats({ body: bodyFor(sessionId), env }, signal);
32
+ }
33
+
34
+ function sameSeat(left: Seat | undefined, right: Seat): boolean {
35
+ return left !== undefined && left.generation === right.generation && sameBody(left.body, right.body);
36
+ }
37
+
38
+ function createOpenCodeWakeTransport(target: OpenCodeNativeSession, lifetime: AbortSignal): WakeTransportPort {
28
39
  return {
29
- probe: async (route) => !lifetime.aborted && route.kind === routeKind && route.sessionId === target.sessionId && route.address.sessionId === target.sessionId,
30
- async attempt(request, timeoutMs, beforeSend) {
40
+ probe: async (route) => !lifetime.aborted && route.kind === routeKind
41
+ && route.sessionId === target.sessionId && route.address.sessionId === target.sessionId,
42
+ async attempt(request: WakeRequest, timeoutMs: number, beforeSend) {
43
+ const index = parseActivityId(request.activity);
44
+ const artifact = request.artifact;
45
+ if (artifact === undefined || request.claimToken === undefined || index === undefined
46
+ || !sameBody(request.seat.body, bodyFor(target.sessionId))
47
+ || request.route.kind !== routeKind || request.route.address.sessionId !== target.sessionId) {
48
+ return { outcome: 'not-capable', diagnostic: 'OpenCode attention has no matching artifact claim or session.' };
49
+ }
31
50
  const signal = AbortSignal.any([lifetime, AbortSignal.timeout(timeoutMs)]);
32
- const deadline = Date.now() + timeoutMs;
33
- // Start the native call under the ownership fence, but await it OUTSIDE: prompt may
34
- // enter our context hook synchronously and that hook needs the same ownership lock.
35
- const started = await ledger.withClaimLock(async () => {
36
- if (signal.aborted || request.route.sessionId !== target.sessionId || !request.claimToken
37
- || !await (beforeSend?.() ?? Promise.resolve(true))) return undefined;
38
- const owner = (await ledger.listPresence({ location: request.location, participant: request.participant, session: target.sessionId }))
39
- .find((binding) => binding.channel === 'opencode' && binding.epoch === request.route.epoch);
40
- const index = parseActivityId(request.activity);
41
- if (!owner || index === undefined) return undefined;
42
- const square = await openSquare(owner.location, { hostLedger: ledger, env, signal });
43
- try {
44
- const { value: prepared } = await square.artifact.read((facts) => preview(facts, owner, index), signal);
45
- if (!prepared) return undefined;
46
- const row: NativeRow & { routeKind: typeof routeKind } = { location: owner.location, participant: owner.participant, session: target.sessionId,
47
- activity: request.activity, kind: 'wake', outcome: 'dispatching', routeKind, attemptN: request.attemptN, claimToken: request.claimToken,
48
- nativeDelivery: { harness: 'opencode', epoch: owner.epoch ?? 0, inputId: createNativeInputId(), payload: prepared.payload } };
49
- if (!await ledger.prepareNativeWake(row) || !await (beforeSend?.() ?? Promise.resolve(true)) || signal.aborted
50
- || (await square.artifact.read((facts) => preview(facts, owner, index)?.payload, signal)).value !== prepared.payload) return undefined;
51
- return { sending: sendNativeText(target, prepared.payload, { inputId: row.nativeDelivery.inputId, delivery: 'steer',
52
- timeoutMs: Math.max(1, deadline - Date.now()), signal }) };
53
- } finally { await closeOpenSquare(square); }
54
- }, signal);
55
- if (!started) return { outcome: 'not-capable', diagnostic: 'OpenCode attention, binding or local delivery generation changed before send.' };
56
- const result = await started.sending;
51
+ const started = Date.now();
52
+ if (signal.aborted || !await beforeSend())
53
+ return { outcome: 'not-capable', diagnostic: 'OpenCode attention changed before send.' };
54
+ const { value: prepared } = await artifact.read((facts) => preview(facts, request.location, request.seat as Seat, index), signal);
55
+ if (prepared === undefined) return { outcome: 'not-capable', diagnostic: 'OpenCode attention is no longer current.' };
56
+ const inputId = createNativeInputId();
57
+ const correlation = { harness: 'opencode' as const, token: inputId, payloadHash: payloadHash(prepared.payload) };
58
+ if (!await artifact.prepareDelivery({ seat: request.seat, claimToken: request.claimToken, correlation }, signal))
59
+ return { outcome: 'not-capable', diagnostic: 'OpenCode delivery claim is no longer current.' };
60
+ const fresh = await artifact.read((facts) => preview(facts, request.location, request.seat as Seat, index), signal);
61
+ if (fresh.value === undefined || payloadHash(fresh.value.payload) !== correlation.payloadHash)
62
+ return { outcome: 'not-capable', diagnostic: 'OpenCode attention changed before native send.' };
63
+ if (signal.aborted || !await beforeSend())
64
+ return { outcome: 'not-capable', diagnostic: 'OpenCode attention was consumed before send.' };
65
+ const result = await sendNativeText(target, prepared.payload, {
66
+ inputId, delivery: 'steer', timeoutMs: Math.max(1, timeoutMs - (Date.now() - started)), signal,
67
+ });
57
68
  if (result.state === 'accepted') return { outcome: 'accepted' };
58
69
  if (result.state === 'unavailable') return { outcome: 'failed', unavailable: true, retainRoute: true, signature: result.code };
59
70
  return { outcome: 'unknown', signature: result.code };
@@ -61,89 +72,130 @@ function createOpenCodeWakeTransport(target: OpenCodeNativeSession, env: NodeJS.
61
72
  };
62
73
  }
63
74
 
64
- /** Only the matching prepared primary context authorizes presentation, never admission. */
75
+ function textMessage(message: OpenCodeContextMessage): string | undefined {
76
+ if (message.role !== 'user' || message.id === undefined || !message.content.every((part) => part.type === 'text' && typeof part.text === 'string')) return undefined;
77
+ return message.content.map((part) => part.text!).join('');
78
+ }
79
+
80
+ /** The exact prepared native message can authorize artifact-backed presentation once. */
65
81
  export async function observeOpenCodeContext(sessionId: string, messages: readonly OpenCodeContextMessage[], env: NodeJS.ProcessEnv, signal: AbortSignal): Promise<void> {
66
- const ledger = hostLedgerForEnv(env);
67
- await ledger.withClaimLock(async () => {
82
+ const observed = new Map(messages.flatMap((message) => {
83
+ const text = textMessage(message);
84
+ return text === undefined ? [] : [[message.id!, payloadHash(text)] as const];
85
+ }));
86
+ if (observed.size === 0 || signal.aborted) return;
87
+ for (const { location, seat } of await discoverOpenCodeSeats(sessionId, env, signal)) {
68
88
  if (signal.aborted) return;
69
- const rows = (await ledger.listEvidence({ kind: 'wake', session: sessionId })).filter((row): row is NativeRow =>
70
- row.nativeDelivery?.harness === 'opencode' && !!row.claimToken && ['dispatching', 'unknown', 'accepted'].includes(row.outcome));
71
- for (const row of rows) {
72
- if (signal.aborted || !messages.some((message) => message.id === row.nativeDelivery.inputId && message.role === 'user'
73
- && message.content.every((part) => part.type === 'text' && typeof part.text === 'string')
74
- && message.content.map((part) => part.text).join('') === row.nativeDelivery.payload)) continue;
75
- const owner = (await ledger.listPresence({ location: row.location, participant: row.participant, session: sessionId }))
76
- .find((binding) => binding.channel === 'opencode' && binding.epoch === row.nativeDelivery.epoch);
77
- const index = parseActivityId(row.activity);
78
- if (!owner || index === undefined) continue;
79
- const square = await openSquare(row.location, { hostLedger: ledger, env, signal });
80
- try {
81
- const { value: prepared } = await square.artifact.read((facts) => preview(facts, owner, index), signal);
82
- if (!prepared || prepared.payload !== row.nativeDelivery.payload) continue;
83
- const prior = await projectPresentationEvidence({ hostLedger: ledger, location: row.location, participant: row.participant, sessionId, activity: row.activity });
84
- if (presentationSuppressesWake(prior)) continue;
85
- await presentPending({ artifact: square.artifact, hostLedger: ledger, location: row.location, participant: row.participant, session: sessionId,
86
- activity: row.activity, sink: { present() {} }, markSeen: !prepared.clipped, signal,
87
- current: (facts) => !signal.aborted && preview(facts, owner, index)?.payload === row.nativeDelivery.payload });
88
- } finally { await closeOpenSquare(square); }
89
- }
90
- }, signal);
89
+ const artifact = createArtifactPort(location, undefined, signal);
90
+ try {
91
+ const { value: matches } = await artifact.read((facts) => {
92
+ if (!sameSeat(facts.seat(seat.participant), seat)) return [];
93
+ return facts.deliveryEvidence(seat.participant, seat.generation).flatMap((evidence) => {
94
+ const token = evidence.correlation?.harness === 'opencode' ? evidence.correlation.token : undefined;
95
+ const hash = token === undefined ? undefined : observed.get(token);
96
+ const index = parseActivityId(evidence.activity);
97
+ if (token === undefined || hash === undefined || evidence.claimToken === undefined || index === undefined
98
+ || !['dispatching', 'unknown', 'accepted'].includes(evidence.outcome)
99
+ || evidence.correlation?.payloadHash !== hash) return [];
100
+ const prepared = preview(facts, location, seat, index);
101
+ if (prepared === undefined || payloadHash(prepared.payload) !== hash
102
+ || presentationBlocksWake(facts.deliveryEvidence(seat.participant, seat.generation, evidence.activity), Date.now())) return [];
103
+ return [{ evidence, index, hash, clipped: prepared.clipped }];
104
+ });
105
+ }, signal);
106
+ for (const { evidence, index, hash, clipped } of matches) {
107
+ if (signal.aborted || evidence.claimToken === undefined) return;
108
+ await presentPending({ artifact, location, participant: seat.participant,
109
+ activity: formatActivityId(index), seat: asSeatRef(seat), sink: { present() {} }, markSeen: !clipped, signal,
110
+ current: (facts) => {
111
+ const current = facts.seat(seat.participant);
112
+ const prepared = current === undefined || !sameSeat(current, seat) ? undefined : preview(facts, location, seat, index);
113
+ const correlation = facts.deliveryByToken(evidence.claimToken!);
114
+ return prepared !== undefined && payloadHash(prepared.payload) === hash
115
+ && correlation?.correlation?.harness === 'opencode'
116
+ && correlation.correlation.token !== undefined
117
+ && correlation.correlation.payloadHash === hash;
118
+ },
119
+ });
120
+ }
121
+ } finally { await artifact.close(); }
122
+ }
91
123
  }
92
124
 
93
- /** One existing observer and the existing dispatch owner; no second queue or evidence store. */
125
+ /** The native receiver owns no claims outside the artifact; HOME is used only for discovery. */
94
126
  export async function receiveOpenCodePending(target: OpenCodeNativeSession, env: NodeJS.ProcessEnv, signal: AbortSignal): Promise<void> {
95
- const ledger = hostLedgerForEnv(env);
96
- const observer = await observeSessionPending(target.sessionId, env);
97
- const excluded = new Set<string>();
98
- const refreshExclusions = async () => {
99
- excluded.clear();
100
- const owners = await ledger.listPresence({ session: target.sessionId });
101
- for (const row of await ledger.listEvidence({ kind: 'wake', session: target.sessionId })) {
102
- const index = parseActivityId(row.activity);
103
- const current = owners.some((owner) => owner.location === row.location && nameKey(owner.participant) === nameKey(row.participant) && owner.epoch === row.nativeDelivery?.epoch);
104
- if (index !== undefined && (['accepted', 'unknown'].includes(row.outcome) || row.outcome === 'dispatching' && current && row.nativeDelivery?.harness === 'opencode')) excluded.add(key(row.location, row.participant, index));
105
- }
106
- for (const membership of await sessionInbox(target.sessionId, env, signal)) {
107
- const owner = owners.find((binding) => binding.location === membership.squarePath && nameKey(binding.participant) === nameKey(membership.name));
108
- for (const note of membership.notifications) if (note.actIndex <= (owner?.cancelledThrough ?? -1)) excluded.add(key(membership.squarePath, membership.name, note.actIndex));
109
- }
110
- };
111
- const transport = createWakeTransport([], ledger, Date.now, { [routeKind]: createOpenCodeWakeTransport(target, env, signal) });
127
+ const observer = await observeSessionPending(bodyFor(target.sessionId), env);
128
+ const transport = createOpenCodeWakeTransport(target, signal);
129
+ let waiting: ReturnType<typeof observer.wait> | undefined;
112
130
  try {
113
- await refreshExclusions();
114
- let pending = await observer.wait(Infinity, { signal, excludeKeys: excluded });
115
- while (!signal.aborted && pending.length > 0) {
116
- let armed!: (value: boolean) => void;
117
- const ready = new Promise<boolean>((resolve) => { armed = resolve; });
118
- // Capture the next state edge BEFORE dispatch. Evidence failures alone do not retry.
119
- const next = observer.wait(Infinity, { signal, excludeKeys: excluded, skipImmediate: true, onChangeArmed: armed })
120
- .catch(() => { armed(false); return []; });
121
- if (!await ready || signal.aborted) break;
122
- for (const membership of pending) {
123
- const square = await openSquare(membership.squarePath, { hostLedger: ledger, env, signal });
131
+ while (!signal.aborted) {
132
+ const armed = new Promise<boolean>((resolve) => {
133
+ waiting = observer.wait(Infinity, { signal, skipImmediate: true, onChangeArmed: resolve });
134
+ void waiting.catch(() => resolve(false));
135
+ });
136
+ if (!await armed) { await waiting; break; }
137
+ for (const membership of await observer.read({ signal })) {
138
+ const { location, seat } = membership;
139
+ if (signal.aborted) return;
140
+ const artifact = createArtifactPort(location, undefined, signal);
124
141
  try {
125
- for (const note of membership.notifications) {
126
- if (signal.aborted || excluded.has(key(membership.squarePath, membership.name, note.actIndex))) continue;
127
- await deliverPending({ artifact: square.artifact, hostLedger: ledger, transport, location: square.location, activity: formatActivityId(note.actIndex), now: Date.now() });
142
+ const { value: notifications } = await artifact.read((facts) => {
143
+ if (!sameSeat(facts.seat(seat.participant), seat)) return [];
144
+ return membership.notifications.filter((note) => automaticWakeEligible(
145
+ facts.deliveryEvidence(seat.participant, seat.generation, formatActivityId(note.actIndex)), Date.now()));
146
+ }, signal);
147
+ for (const note of notifications) {
148
+ if (signal.aborted) return;
149
+ await deliverPending({ artifact, transport, location, activity: formatActivityId(note.actIndex), now: Date.now() });
128
150
  }
129
- } finally { await closeOpenSquare(square); }
151
+ } catch (error) {
152
+ if (signal.aborted) throw signal.reason ?? error;
153
+ // A candidate that became unreadable cannot authorize sending; the shared observer
154
+ // keeps its subscription and will reconsider the artifact after repair.
155
+ } finally { await artifact.close(); }
130
156
  }
131
- await refreshExclusions();
132
- pending = await next;
157
+ await waiting;
158
+ waiting = undefined;
133
159
  }
134
- } finally { observer.close(); }
160
+ } catch (error) {
161
+ if (!signal.aborted) throw error;
162
+ } finally {
163
+ observer.close();
164
+ await waiting?.catch(() => {});
165
+ }
135
166
  }
136
167
 
137
- export async function publishOpenCodeRoutes(sessionId: string, env: NodeJS.ProcessEnv, signal: AbortSignal): Promise<boolean> {
138
- const ledger = hostLedgerForEnv(env);
139
- return ledger.withClaimLock(async () => {
140
- const owners = (await ledger.listPresence({ session: sessionId })).filter((binding) => binding.channel === 'opencode');
141
- for (const owner of owners) {
142
- const square = await openSquare(owner.location, { hostLedger: ledger, env, signal });
143
- try { await publishWakeRoute(square.artifact, { location: owner.location, participant: owner.participant, sessionId, channel: 'opencode',
144
- kind: routeKind, address: { sessionId }, epoch: owner.epoch }, { requireCurrentSession: true }); }
145
- finally { await closeOpenSquare(square); }
146
- }
147
- return owners.length > 0;
148
- }, signal);
168
+ /** Publish the native address into current artifact seats; the legacy route projection is not read. */
169
+ export async function publishOpenCodeAddress(sessionId: string, env: NodeJS.ProcessEnv, signal: AbortSignal): Promise<boolean> {
170
+ const seats = await discoverOpenCodeSeats(sessionId, env, signal);
171
+ for (const { location, seat } of seats) {
172
+ if (signal.aborted) return false;
173
+ const artifact = createArtifactPort(location, undefined, signal);
174
+ try {
175
+ await artifact.transactAs({ kind: 'participant', seat: asSeatRef(seat) }, (facts) =>
176
+ facts.updateWakeAddress(asSeatRef(seat), { kind: routeKind, address: { sessionId } }));
177
+ } finally { await artifact.close(); }
178
+ }
179
+ return seats.length > 0;
180
+ }
181
+
182
+ export async function cancelOpenCodeDeliveries(sessionId: string, env: NodeJS.ProcessEnv, signal: AbortSignal): Promise<void> {
183
+ for (const { location, seat } of await discoverOpenCodeSeats(sessionId, env, signal)) {
184
+ if (signal.aborted) return;
185
+ const artifact = createArtifactPort(location, undefined, signal);
186
+ try {
187
+ const { value: through } = await artifact.read((facts) => facts.header().lastActIndex, signal);
188
+ if (through >= 0) await artifact.cancelDeliveries(asSeatRef(seat), through, signal);
189
+ } finally { await artifact.close(); }
190
+ }
191
+ }
192
+
193
+ export async function endOpenCodeSession(sessionId: string, env: NodeJS.ProcessEnv, signal: AbortSignal): Promise<void> {
194
+ for (const { location, seat } of await discoverOpenCodeSeats(sessionId, env, signal)) {
195
+ if (signal.aborted) return;
196
+ const { Square } = await import('../../square-api.js');
197
+ const square = await Square.at({ path: location, env, body: seat.body, participant: seat.participant });
198
+ try { await square.endOwnedSession(seat.participant, sessionId, seat.generation, seat.body); }
199
+ finally { await square.close(); }
200
+ }
149
201
  }
@@ -1,11 +1,7 @@
1
- import { operationEnv } from '../../participant-identity.js';
2
- import { hostLedgerForEnv } from '../../registry.js';
1
+ import { observeSquareIndex } from '../../square-index.js';
3
2
  import { canonicalPath } from '../../canonical-path.js';
4
- import { openSquare } from '../../open-square.js';
5
- import { closeOpenSquare } from '../../open-square.js';
6
- import { Square } from '../../square-api.js';
7
3
  import { connectNative, type OpenCodeNativeSession, type OpenCodeSessionCapability } from '../../packages/agent-delivery/src/opencode-native.js';
8
- import { observeOpenCodeContext, publishOpenCodeRoutes, receiveOpenCodePending, type OpenCodeContextMessage } from './delivery.js';
4
+ import { cancelOpenCodeDeliveries, endOpenCodeSession, indexedOpenCodeSessions, observeOpenCodeContext, publishOpenCodeAddress, receiveOpenCodePending, type OpenCodeContextMessage } from './delivery.js';
9
5
 
10
6
  interface OpenCodePluginContext {
11
7
  readonly app: { readonly version: string };
@@ -24,7 +20,7 @@ export default {
24
20
  async setup(ctx: OpenCodePluginContext): Promise<() => Promise<void>> {
25
21
  if (ctx.app.version !== '2.0.20') return async () => {};
26
22
  const env = { ...process.env };
27
- const ledger = hostLedgerForEnv(env);
23
+ const index = await observeSquareIndex(env);
28
24
  const lifetime = new AbortController();
29
25
  const receivers = new Map<string, Receiver>(); // Operational handles only; presence owns membership.
30
26
  const directory = await canonicalPath(ctx.location.directory);
@@ -53,18 +49,17 @@ export default {
53
49
  return target;
54
50
  };
55
51
  const reconcile = async (activeSession?: string) => {
56
- const candidates = new Set((await ledger.listPresence({})).filter((binding) => binding.channel === 'opencode').map((binding) => binding.session));
52
+ const candidates = new Set(await indexedOpenCodeSessions(env));
57
53
  if (activeSession) candidates.add(activeSession);
58
54
  for (const sessionId of candidates) {
59
55
  if (lifetime.signal.aborted) return;
60
56
  const target = await validate(sessionId);
61
57
  if (!target) { stop(sessionId); continue; }
62
- const scopedEnv = operationEnv('opencode', sessionId, env);
63
- if (!await publishOpenCodeRoutes(sessionId, scopedEnv, lifetime.signal)) { stop(sessionId); continue; }
58
+ if (!await publishOpenCodeAddress(sessionId, env, lifetime.signal)) { stop(sessionId); continue; }
64
59
  if (receivers.get(sessionId)?.settled === false) continue;
65
60
  const abort = new AbortController();
66
61
  const signal = AbortSignal.any([lifetime.signal, abort.signal]);
67
- const receiver: Receiver = { abort, settled: false, running: receiveOpenCodePending(target, scopedEnv, signal).catch(() => {}).finally(() => { receiver.settled = true; }) };
62
+ const receiver: Receiver = { abort, settled: false, running: receiveOpenCodePending(target, env, signal).catch(() => {}).finally(() => { receiver.settled = true; }) };
68
63
  receivers.set(sessionId, receiver);
69
64
  }
70
65
  for (const sessionId of receivers.keys()) if (!candidates.has(sessionId)) stop(sessionId);
@@ -73,23 +68,12 @@ export default {
73
68
  if (!receivers.has(sessionId)) return;
74
69
  // Stop synchronously; persist the cutoff before allowing reconciliation to start fresh work.
75
70
  stop(sessionId);
76
- await ledger.withClaimLock(async () => {
77
- for (const owner of (await ledger.listPresence({ session: sessionId })).filter((binding) => binding.channel === 'opencode')) {
78
- const square = await openSquare(owner.location, { hostLedger: ledger, env, signal: lifetime.signal });
79
- try { await ledger.suppressPresence(owner, (await square.artifact.read((facts) => facts.header().nextActIndex - 1, lifetime.signal)).value); }
80
- finally { await closeOpenSquare(square); }
81
- }
82
- }, lifetime.signal);
71
+ await cancelOpenCodeDeliveries(sessionId, env, lifetime.signal);
83
72
  await reconcile();
84
73
  };
85
74
  const end = async (sessionId: string) => {
86
75
  stop(sessionId);
87
- const memberships = (await ledger.listPresence({ session: sessionId })).filter((binding) => binding.channel === 'opencode');
88
- for (const owner of memberships) {
89
- const square = await Square.at({ path: owner.location, hostLedger: ledger, env: operationEnv('opencode', sessionId, env) });
90
- try { await square.endOwnedSession(owner.participant, sessionId, owner.epoch); }
91
- finally { await square.close(); }
92
- }
76
+ await endOpenCodeSession(sessionId, env, lifetime.signal);
93
77
  };
94
78
 
95
79
  await ctx.session.hook('prompt', (event) => coordinate(() => reconcile(event.sessionID)));
@@ -97,16 +81,14 @@ export default {
97
81
  await coordinate(() => reconcile(event.sessionID));
98
82
  const receiver = receivers.get(event.sessionID);
99
83
  if (!receiver || lifetime.signal.aborted) return;
100
- await observeOpenCodeContext(event.sessionID, event.messages, operationEnv('opencode', event.sessionID, env),
84
+ await observeOpenCodeContext(event.sessionID, event.messages, env,
101
85
  AbortSignal.any([lifetime.signal, receiver.abort.signal])).catch(() => {});
102
86
  });
103
- const changes = await ledger.observeChanges();
104
- changes.evidence.close(); // Manual joins activate an idle receiver; evidence already has a session observer.
105
87
  const presenceLoop = (async () => {
106
88
  while (!lifetime.signal.aborted) {
107
- const baseline = await changes.presence.read(lifetime.signal);
89
+ const baseline = await index.read(lifetime.signal);
108
90
  await coordinate(() => reconcile());
109
- await changes.presence.changed(baseline, Infinity, lifetime.signal);
91
+ await index.changed(baseline, Infinity, lifetime.signal);
110
92
  }
111
93
  })().catch(() => {});
112
94
  const eventLoop = (async () => {
@@ -124,7 +106,7 @@ export default {
124
106
  // Do not await any native prompt in setup: plugin activation itself is lazy.
125
107
  return async () => {
126
108
  lifetime.abort();
127
- changes.presence.close();
109
+ index.close();
128
110
  const tasks = [presenceLoop, eventLoop, coordinating, ...[...receivers.keys()].map(stop)];
129
111
  let timer: ReturnType<typeof setTimeout> | undefined;
130
112
  try { await Promise.race([Promise.allSettled(tasks), new Promise<void>((resolve) => { timer = setTimeout(resolve, 3000); })]); }
@@ -8,7 +8,7 @@ import { SQUARE_TOOLS, SQUARE_TOOL_INSTRUCTIONS, squareToolFailure, squareToolFa
8
8
  import { presentPendingAtBoundary, renderPendingAtBoundary } from '../../dist/boundary-presentation.js';
9
9
  import { operationEnv } from '../../dist/participant-identity.js';
10
10
  import { endSessionMemberships } from '../../dist/session-lifecycle.js';
11
- import { sessionInbox, observeSessionPending } from '../../dist/inbox.js';
11
+ import { projectSessionSeats, observeSessionPending, pendingKey } from '../../dist/inbox.js';
12
12
  import { previewAttentionBody, renderAttentionDescription, renderAttentionFull } from '../../dist/attention-presentation.js';
13
13
  import { formatActivityId } from '../../dist/square-core.js';
14
14
  import { createPiReceiver, sendPiMessage } from '../../dist/packages/agent-delivery/src/pi.js';
@@ -26,7 +26,7 @@ export function pendingInbox(inbox) {
26
26
 
27
27
  export function inboxKeys(inbox) {
28
28
  return pendingInbox(inbox).flatMap((item) => item.notifications.map((note) =>
29
- `${item.squarePath}\u0000${item.name.toLocaleLowerCase()}\u0000${note.actIndex}`
29
+ pendingKey(item, note.actIndex)
30
30
  ));
31
31
  }
32
32
 
@@ -41,9 +41,9 @@ function framePiMessage(content) {
41
41
  /** Renderer-only card facts for Pi and Paseo; Pi never sends `details` to the model. */
42
42
  export function pendingCardDetails(pending) {
43
43
  const items = pending.flatMap((membership) => membership.notifications.map((note) => ({
44
- square: path.basename(membership.squarePath, '.square'),
44
+ square: path.basename(membership.location, '.square'),
45
45
  actor: note.actor,
46
- recipient: membership.name,
46
+ recipient: membership.seat.participant,
47
47
  route: note.route,
48
48
  act: formatActivityId(note.actIndex),
49
49
  preview: previewAttentionBody(note.body),
@@ -52,9 +52,9 @@ export function pendingCardDetails(pending) {
52
52
  })));
53
53
  if (items.length === 0) return undefined;
54
54
  const fullText = pending.flatMap((membership) => membership.notifications.map((note) => renderAttentionFull({
55
- squarePath: membership.squarePath,
55
+ squarePath: membership.location,
56
56
  actIndex: note.actIndex,
57
- recipient: membership.name,
57
+ recipient: membership.seat.participant,
58
58
  ...note,
59
59
  }))).join('\n\n');
60
60
  return { items, fullText };
@@ -152,9 +152,11 @@ export default function squarePiExtension(pi) {
152
152
  namespace: { name: 'square', description: 'Participate in a shared public square.', instructions: SQUARE_TOOL_INSTRUCTIONS },
153
153
  async execute(_id, args, signal, _onUpdate, ctx) {
154
154
  try {
155
+ const sessionId = ctx.sessionManager.getSessionId();
155
156
  const square = await Square.at({
156
157
  cwd: ctx.cwd,
157
- env: operationEnv('pi', ctx.sessionManager.getSessionId(), process.env),
158
+ env: operationEnv('pi', sessionId, process.env),
159
+ body: { channel: 'pi', session: sessionId },
158
160
  path: args.location,
159
161
  participant: args.as,
160
162
  }, { signal });
@@ -164,7 +166,7 @@ export default function squarePiExtension(pi) {
164
166
  return { content: [{ type: 'text', text: tool.render(data, args) ?? '' }], details: data, structuredContent: data };
165
167
  } catch (error) {
166
168
  const data = { error: squareToolFailure(error, signal) };
167
- return { isError: true, content: [{ type: 'text', text: squareToolFailureText(data.error, { location: args.location ?? process.env.SQUARE_LOCATION, participant: args.as ?? process.env.SQUARE_PARTICIPANT_NAME }) }], details: data, structuredContent: data };
169
+ return { isError: true, content: [{ type: 'text', text: squareToolFailureText(data.error, { location: args.location ?? process.env.SQUARE_LOCATION, participant: args.as }) }], details: data, structuredContent: data };
168
170
  }
169
171
  },
170
172
  });
@@ -188,10 +190,14 @@ export default function squarePiExtension(pi) {
188
190
  let currentRunSignal;
189
191
  let detachRunAbort;
190
192
  let cancelledRun = false;
193
+ let pendingCancellations = new Map();
194
+ const cancellationFailures = new Set();
191
195
 
192
196
  const cancelRunDelivery = () => {
197
+ if (cancelledRun) return;
193
198
  cancelledRun = true;
194
199
  for (const key of observedPending) handledPending.add(key);
200
+ persistSessionCancellation(sessionId);
195
201
  generation += 1;
196
202
  stopWatcher();
197
203
  failAcks(new Error('Pi notification delivery cancelled'));
@@ -245,6 +251,40 @@ export default function squarePiExtension(pi) {
245
251
  // Pi owns PI_SESSION_ID and several SDK sessions may coexist in one process.
246
252
  const sessionEnv = (id) => ({ ...process.env, ...(id === undefined ? {} : { PI_SESSION_ID: id }) });
247
253
 
254
+ const persistSessionCancellation = (id) => {
255
+ if (id === undefined) return Promise.resolve();
256
+ const previous = pendingCancellations.get(id) ?? Promise.resolve();
257
+ let operation;
258
+ operation = previous.then(async () => {
259
+ const body = { channel: 'pi', session: id };
260
+ const env = sessionEnv(id);
261
+ const seats = await projectSessionSeats({ body, env });
262
+ for (const { location, seat } of seats) {
263
+ const square = await Square.at({ path: location, env, body, participant: seat.participant });
264
+ try { await square.cancelDeliveries(seat.participant, seat.generation); }
265
+ finally { await square.close(); }
266
+ }
267
+ }).then(() => {
268
+ cancellationFailures.delete(id);
269
+ }, () => {
270
+ cancellationFailures.add(id);
271
+ }).finally(() => {
272
+ if (pendingCancellations.get(id) === operation) pendingCancellations.delete(id);
273
+ });
274
+ pendingCancellations.set(id, operation);
275
+ return operation;
276
+ };
277
+
278
+ const waitForSessionCancellation = async (id) => {
279
+ if (id === undefined) return true;
280
+ while (true) {
281
+ const pending = pendingCancellations.get(id);
282
+ if (pending !== undefined) await pending;
283
+ if (pendingCancellations.get(id) !== pending) continue;
284
+ return !cancellationFailures.has(id);
285
+ }
286
+ };
287
+
248
288
  // Aborting releases native landing waits; retain cleanup until the observer
249
289
  // has closed, including when cancellation precedes a lifecycle hook.
250
290
  const stopWatcher = () => {
@@ -267,7 +307,7 @@ export default function squarePiExtension(pi) {
267
307
  const wake = async (token, signal) => {
268
308
  const boundSessionId = sessionId;
269
309
  const env = sessionEnv(boundSessionId);
270
- const observer = await observeSessionPending(boundSessionId, env);
310
+ const observer = await observeSessionPending({ channel: 'pi', session: boundSessionId }, env);
271
311
  try {
272
312
  if (signal.aborted || token !== generation) return;
273
313
  const armDeferredRetry = () => {
@@ -321,7 +361,7 @@ export default function squarePiExtension(pi) {
321
361
  let keepDeferred = false;
322
362
  try {
323
363
  await presentPendingAtBoundary(
324
- boundSessionId,
364
+ { channel: 'pi', session: boundSessionId },
325
365
  async (content) => {
326
366
  if (signal.aborted || currentRunSignal?.aborted) throw new Error('Pi notification delivery cancelled');
327
367
  // One card for the human: a compact heading, expandable to the full body.
@@ -384,7 +424,8 @@ export default function squarePiExtension(pi) {
384
424
  sessionId = ctx.sessionManager.getSessionId();
385
425
  const token = generation;
386
426
  await stopped;
387
- if (token !== generation) return;
427
+ await waitForSessionCancellation(sessionId);
428
+ if (token !== generation || cancellationFailures.has(sessionId)) return;
388
429
  watcherAbort = new AbortController();
389
430
  const signal = watcherAbort.signal;
390
431
  watcher = wake(token, signal).catch(() => undefined);
@@ -438,13 +479,9 @@ export default function squarePiExtension(pi) {
438
479
  currentRunSignal = undefined;
439
480
  if (!cancelledRun || sessionId === undefined) return;
440
481
  const token = generation;
441
- // Cancel the entire pending batch, including entries not yet sent to Pi.
442
- // This is local suppression, never evidence that the model saw the activity.
443
482
  await stopWatcher();
444
- if (token !== generation || sessionId === undefined) return;
445
- const cancelled = await sessionInbox(sessionId, sessionEnv(sessionId));
446
- if (token !== generation || sessionId === undefined) return;
447
- for (const key of inboxKeys(cancelled)) handledPending.add(key);
483
+ await waitForSessionCancellation(sessionId);
484
+ if (token !== generation || sessionId === undefined || cancellationFailures.has(sessionId)) return;
448
485
  cancelledRun = false;
449
486
  watcherAbort = new AbortController();
450
487
  watcher = wake(token, watcherAbort.signal).catch(() => undefined);
@@ -464,6 +501,7 @@ export default function squarePiExtension(pi) {
464
501
  activeTurn = undefined;
465
502
  currentRunSignal = undefined;
466
503
  await stopped;
504
+ await waitForSessionCancellation(endedId);
467
505
  if (endedId) await endSessionMemberships('pi', endedId, endedEnv).catch(() => undefined);
468
506
  });
469
507
  }
@@ -10,7 +10,7 @@ Bring a thought, a question, an objection, or a gesture. Leave private tool chat
10
10
 
11
11
  This skill explains what Square is and how to be present in it. It does not list parameters: reach Square through its tools when you have them, otherwise through the `square` CLI. The tool descriptions and `square <operation> --help` are the exact usage. The operations are the same either way.
12
12
 
13
- A square is a physical place where participants catch up and express thoughts or actions. Every square needs a location and a participant name. Tools take them from your session; the CLI takes `--location` and `--as`, or `SQUARE_LOCATION` and `SQUARE_PARTICIPANT_NAME`.
13
+ A square is a physical place where participants catch up and express thoughts or actions. Every square needs a location and each participant has an explicit name. Tools take the execution body from their live harness context. Use `--location` (or `SQUARE_LOCATION`) and an explicit `--as <name>` when joining. Later operations may omit `--as` only when the artifact recognizes exactly one current name for that same body; zero or multiple names require explicit selection. A name never grants custody.
14
14
 
15
15
  ## The rhythm
16
16
 
@@ -1,12 +0,0 @@
1
- import { type LedgerMaintenanceReport } from '../ledger-maintenance.js';
2
- import { type HostLedgerReport } from '../host-ledger-migration.js';
3
- import { type CommandSpec } from './context.js';
4
- type LedgerIntent = {
5
- readonly action: 'verify' | 'migrate';
6
- } | {
7
- readonly action: 'gc';
8
- readonly dryRun: boolean;
9
- };
10
- type LedgerResult = HostLedgerReport | LedgerMaintenanceReport;
11
- export declare const ledgerCommand: CommandSpec<LedgerIntent, LedgerResult>;
12
- export {};