@astrosheep/square 0.3.63 → 0.3.65

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (171) hide show
  1. package/claude-plugin/.claude-plugin/plugin.json +3 -2
  2. package/claude-plugin/hooks/hooks.json +1 -22
  3. package/claude-plugin/hooks/register.js +119 -0
  4. package/claude-plugin/types.d.ts +7 -0
  5. package/codex-plugin/.codex-plugin/plugin.json +1 -1
  6. package/dist/activity-feed.d.ts +1 -10
  7. package/dist/activity-feed.js +1 -27
  8. package/dist/activity.js +57 -25
  9. package/dist/artifact.d.ts +1 -1
  10. package/dist/artifact.js +24 -25
  11. package/dist/attention-presentation.js +1 -0
  12. package/dist/automatic-session.d.ts +3 -1
  13. package/dist/automatic-session.js +21 -34
  14. package/dist/boundary-presentation.js +4 -13
  15. package/dist/canonical-path.d.ts +11 -0
  16. package/dist/canonical-path.js +47 -0
  17. package/dist/catch-decisions.js +14 -10
  18. package/dist/catch-selection.d.ts +6 -0
  19. package/dist/catch-selection.js +14 -0
  20. package/dist/claude-delivery.d.ts +11 -0
  21. package/dist/claude-delivery.js +145 -0
  22. package/dist/claude-mod.d.ts +8 -0
  23. package/dist/claude-mod.js +164 -0
  24. package/dist/cli/context.d.ts +0 -1
  25. package/dist/cli/context.js +9 -21
  26. package/dist/cli/harness-command.d.ts +0 -1
  27. package/dist/cli/harness-command.js +0 -6
  28. package/dist/cli/observation-commands.d.ts +1 -1
  29. package/dist/cli/observation-commands.js +31 -58
  30. package/dist/cli/program.js +20 -18
  31. package/dist/cli/registry.js +2 -2
  32. package/dist/cli/square-commands.js +10 -6
  33. package/dist/codex-boundary-state.d.ts +2 -2
  34. package/dist/codex-boundary-state.js +4 -4
  35. package/dist/codex-hook.js +12 -15
  36. package/dist/codex-queue.d.ts +2 -2
  37. package/dist/codex-queue.js +7 -5
  38. package/dist/decisions.d.ts +2 -6
  39. package/dist/decisions.js +16 -17
  40. package/dist/delivery-health.d.ts +1 -2
  41. package/dist/delivery-health.js +4 -5
  42. package/dist/delivery-operations.d.ts +2 -5
  43. package/dist/delivery-operations.js +77 -214
  44. package/dist/delivery.d.ts +25 -47
  45. package/dist/delivery.js +14 -46
  46. package/dist/diagnostic-redaction.js +7 -9
  47. package/dist/file-changes.d.ts +0 -1
  48. package/dist/file-changes.js +2 -19
  49. package/dist/harness-claude.js +8 -6
  50. package/dist/harness-codex.d.ts +0 -2
  51. package/dist/harness-codex.js +0 -2
  52. package/dist/harness-links.js +13 -17
  53. package/dist/harness.js +3 -7
  54. package/dist/help.js +4 -3
  55. package/dist/host-ledger-file-adapter.d.ts +17 -8
  56. package/dist/host-ledger-file-adapter.js +164 -80
  57. package/dist/host-ledger.d.ts +55 -50
  58. package/dist/inbox.d.ts +0 -3
  59. package/dist/inbox.js +88 -50
  60. package/dist/index.d.ts +1 -1
  61. package/dist/index.js +1 -1
  62. package/dist/list.js +1 -4
  63. package/dist/mcp.js +24 -41
  64. package/dist/mention-parse.js +3 -9
  65. package/dist/model.d.ts +11 -2
  66. package/dist/model.js +1 -1
  67. package/dist/notifications.d.ts +3 -16
  68. package/dist/notifications.js +41 -122
  69. package/dist/opencode-delivery.d.ts +14 -0
  70. package/dist/opencode-delivery.js +171 -0
  71. package/dist/opencode.d.ts +39 -18
  72. package/dist/opencode.js +145 -40
  73. package/dist/packages/agent-delivery/src/claude-auth.d.ts +13 -0
  74. package/dist/packages/agent-delivery/src/claude-auth.js +75 -0
  75. package/dist/packages/agent-delivery/src/claude-native.d.ts +28 -0
  76. package/dist/packages/agent-delivery/src/claude-native.js +68 -0
  77. package/dist/packages/agent-delivery/src/claude.d.ts +14 -0
  78. package/dist/packages/agent-delivery/src/claude.js +66 -0
  79. package/dist/packages/agent-delivery/src/deadline.d.ts +11 -0
  80. package/dist/packages/agent-delivery/src/deadline.js +41 -0
  81. package/dist/packages/agent-delivery/src/errors.d.ts +16 -0
  82. package/dist/packages/agent-delivery/src/errors.js +41 -0
  83. package/dist/packages/agent-delivery/src/guards.d.ts +6 -0
  84. package/dist/packages/agent-delivery/src/guards.js +6 -0
  85. package/dist/packages/agent-delivery/src/index.d.ts +83 -0
  86. package/dist/packages/agent-delivery/src/index.js +110 -0
  87. package/dist/packages/agent-delivery/src/local-endpoint.d.ts +26 -0
  88. package/dist/packages/agent-delivery/src/local-endpoint.js +114 -0
  89. package/dist/packages/agent-delivery/src/opencode-native.d.ts +33 -0
  90. package/dist/packages/agent-delivery/src/opencode-native.js +75 -0
  91. package/dist/packages/agent-delivery/src/opencode.d.ts +14 -0
  92. package/dist/packages/agent-delivery/src/opencode.js +127 -0
  93. package/dist/packages/agent-delivery/src/paseo-native.d.ts +58 -0
  94. package/dist/packages/agent-delivery/src/paseo-native.js +122 -0
  95. package/dist/packages/agent-delivery/src/paseo.d.ts +19 -0
  96. package/dist/packages/agent-delivery/src/paseo.js +106 -0
  97. package/dist/packages/agent-delivery/src/pi-client.d.ts +10 -0
  98. package/dist/packages/agent-delivery/src/pi-client.js +106 -0
  99. package/dist/packages/agent-delivery/src/pi-protocol.d.ts +15 -0
  100. package/dist/packages/agent-delivery/src/pi-protocol.js +50 -0
  101. package/dist/packages/agent-delivery/src/pi-send.d.ts +23 -0
  102. package/dist/packages/agent-delivery/src/pi-send.js +4 -0
  103. package/dist/packages/agent-delivery/src/pi.d.ts +25 -0
  104. package/dist/packages/agent-delivery/src/pi.js +209 -0
  105. package/dist/packages/agent-delivery/src/types.d.ts +120 -0
  106. package/dist/participant-host.js +12 -13
  107. package/dist/participant-identity.d.ts +21 -0
  108. package/dist/participant-identity.js +32 -11
  109. package/dist/paseo-delivery.d.ts +2 -2
  110. package/dist/paseo-delivery.js +35 -27
  111. package/dist/paseo-timeline.js +25 -21
  112. package/dist/paseo.d.ts +2 -2
  113. package/dist/paseo.js +1 -2
  114. package/dist/ports.d.ts +7 -20
  115. package/dist/presentation-operations.js +16 -4
  116. package/dist/presentation.d.ts +19 -10
  117. package/dist/presentation.js +99 -177
  118. package/dist/registry.d.ts +15 -66
  119. package/dist/registry.js +45 -123
  120. package/dist/routes.d.ts +1 -28
  121. package/dist/routes.js +22 -104
  122. package/dist/runtime.d.ts +3 -9
  123. package/dist/runtime.js +14 -67
  124. package/dist/search.d.ts +0 -2
  125. package/dist/search.js +3 -10
  126. package/dist/square-actions.js +48 -72
  127. package/dist/square-application.js +32 -30
  128. package/dist/square-core.d.ts +3 -0
  129. package/dist/square-core.js +22 -5
  130. package/dist/square-facade.d.ts +2 -0
  131. package/dist/square-file-adapter.d.ts +2 -1
  132. package/dist/square-file-adapter.js +6 -9
  133. package/dist/square-projections.d.ts +40 -45
  134. package/dist/square-projections.js +60 -74
  135. package/dist/square-state.js +1 -4
  136. package/dist/square-storage.d.ts +5 -8
  137. package/dist/square-storage.js +13 -36
  138. package/dist/square-wiring.d.ts +2 -7
  139. package/dist/square-wiring.js +6 -24
  140. package/dist/stream.js +4 -6
  141. package/dist/time.js +2 -7
  142. package/dist/views.d.ts +6 -18
  143. package/dist/views.js +14 -21
  144. package/dist/wake-attempts.d.ts +2 -56
  145. package/dist/wake-attempts.js +15 -110
  146. package/dist/wake-eligibility.d.ts +43 -0
  147. package/dist/wake-eligibility.js +106 -0
  148. package/dist/wake-evidence.d.ts +32 -9
  149. package/dist/wake-evidence.js +29 -24
  150. package/dist/wake-sink.d.ts +13 -9
  151. package/dist/wake-sink.js +35 -54
  152. package/dist/watch.js +13 -21
  153. package/extensions/square-pi.js +27 -13
  154. package/guides/change-waits.md +1 -1
  155. package/guides/claude-native.md +101 -0
  156. package/guides/host-ledger.md +3 -1
  157. package/guides/participant.md +14 -7
  158. package/guides/pi-native-delivery.md +49 -0
  159. package/package.json +4 -3
  160. package/dist/claude-hook.d.ts +0 -10
  161. package/dist/claude-hook.js +0 -60
  162. package/dist/paseo-connection.d.ts +0 -15
  163. package/dist/paseo-connection.js +0 -141
  164. package/dist/presence.d.ts +0 -2
  165. package/dist/presence.js +0 -24
  166. package/dist/presented.d.ts +0 -2
  167. package/dist/presented.js +0 -10
  168. package/dist/state-cell.d.ts +0 -14
  169. package/dist/wake-port.d.ts +0 -23
  170. package/dist/wake-port.js +0 -27
  171. /package/dist/{state-cell.js → packages/agent-delivery/src/types.js} +0 -0
@@ -1,12 +1,13 @@
1
1
  import { promises as fs } from 'node:fs';
2
- import fsSync from 'node:fs';
3
2
  import path from 'node:path';
4
3
  import { randomUUID } from 'node:crypto';
5
4
  import { setTimeout as sleep } from 'node:timers/promises';
6
5
  import { withFileLock as acquireFileLock } from './file-lock.js';
7
6
  import { nameKey } from './model.js';
7
+ import { formatActivityId } from './square-core.js';
8
8
  import { hostLedgerRoot } from './host-ledger-root.js';
9
9
  import { observeFileMetadata } from './file-changes.js';
10
+ import { canonicalPath, canonicalPathSync } from './canonical-path.js';
10
11
  const LOCK = { retryMs: 10 };
11
12
  const RETENTION = 7 * 86400000;
12
13
  // Prepare before locking: lock databases and their journals are local runtime data too.
@@ -24,32 +25,12 @@ async function withFileLock(file, options, fn) {
24
25
  }
25
26
  return acquireFileLock(file, options, fn);
26
27
  }
27
- async function canon(value) { const absolute = path.resolve(value); try {
28
- return await fs.realpath(absolute);
29
- }
30
- catch {
31
- return absolute;
32
- } }
33
- function canonRoot(value) { let current = path.resolve(value), suffix = []; for (;;) {
34
- try {
35
- return path.join(fsSync.realpathSync.native(current), ...suffix.reverse());
36
- }
37
- catch (error) {
38
- if (error.code !== 'ENOENT')
39
- return path.resolve(value);
40
- const parent = path.dirname(current);
41
- if (parent === current)
42
- return path.resolve(value);
43
- suffix.push(path.basename(current));
44
- current = parent;
45
- }
46
- } }
47
28
  async function read(file, now, includeFuture = false) { try {
48
29
  return (await fs.readFile(file, 'utf8')).split('\n').flatMap(line => { try {
49
- const row = JSON.parse(line);
30
+ const parsed = JSON.parse(line);
31
+ const row = decodeWakeRecord(parsed);
50
32
  const at = row.at ?? row.updatedAt;
51
- const durableTerminal = row.kind === 'wake' && row.outcome === 'accepted';
52
- return row.v === 1 && typeof at === 'number' && (durableTerminal || at >= now - RETENTION) && (includeFuture || at <= now) ? [row] : [];
33
+ return row.v === 1 && typeof at === 'number' && (durableWake(row) || at >= now - RETENTION) && (includeFuture || at <= now) ? [row] : [];
53
34
  }
54
35
  catch {
55
36
  return [];
@@ -89,21 +70,20 @@ async function write(file, rows, signal) {
89
70
  }
90
71
  }
91
72
  function k(v) { return JSON.stringify([v.location, nameKey(v.participant), v.session, v.channel, v.activity, v.kind]); }
92
- function evidenceKey(v) { return JSON.stringify([v.location, nameKey(v.participant), v.session, v.activity, v.kind, v.attemptN ?? null]); }
93
- async function readClaims(file, now) { try {
94
- return (await fs.readFile(file, 'utf8')).split('\n').flatMap(line => { try {
95
- const row = JSON.parse(line);
96
- return row.v === 1 && typeof row.ts === 'number' && typeof row.attention_key === 'string' && typeof row.leaseId === 'string' && typeof row.expiresAt === 'number' && (row.phase === 'claimed' || row.phase === 'dispatching') && row.ts >= now - RETENTION ? [row] : [];
97
- }
98
- catch {
99
- return [];
100
- } });
73
+ function durableWake(record) {
74
+ const row = record;
75
+ return row.kind === 'wake' && (row.outcome === 'accepted' || row.outcome === 'unknown' || row.outcome === 'dispatching');
76
+ }
77
+ function decodeWakeRecord(row) {
78
+ return row.kind === 'wake' && row.outcome === 'unknown' && row.unknownSource === undefined
79
+ ? { ...row, unknownSource: row.signature === 'worker_interrupted_during_dispatch' ? 'interrupted' : 'transport' } : row;
80
+ }
81
+ /** Keep identity and native receipt, never a completed attempt's lease. */
82
+ function finishWake(current, outcome, details = {}) {
83
+ const { expiresAt: _expiresAt, ownerPid: _ownerPid, unknownSource: _unknownSource, ...rest } = current;
84
+ return { ...rest, ...details, kind: 'wake', outcome,
85
+ ...(outcome === 'unknown' ? { unknownSource: details.unknownSource ?? 'transport' } : {}) };
101
86
  }
102
- catch (e) {
103
- if (e.code === 'ENOENT')
104
- return [];
105
- throw e;
106
- } }
107
87
  function processAlive(pid) { if (pid === undefined)
108
88
  return undefined; try {
109
89
  process.kill(pid, 0);
@@ -112,14 +92,11 @@ function processAlive(pid) { if (pid === undefined)
112
92
  catch (error) {
113
93
  return error.code === 'ESRCH' ? false : true;
114
94
  } }
115
- function writeClaims(file, rows) { return write(file, rows); }
116
95
  export class FileHostLedgerPort {
117
96
  root;
118
- claims;
119
97
  clock;
120
98
  constructor(o = {}) {
121
- this.root = canonRoot(o.rootPath ?? hostLedgerRoot());
122
- this.claims = canonRoot(o.claimsPath ?? path.join(this.root, 'wake-claims.ndjsonl'));
99
+ this.root = canonicalPathSync(o.rootPath ?? hostLedgerRoot());
123
100
  this.clock = o.now ?? Date.now;
124
101
  }
125
102
  file(kind) { return path.join(this.root, `${kind}.ndjsonl`); }
@@ -145,7 +122,7 @@ export class FileHostLedgerPort {
145
122
  if (typeof at === 'number') {
146
123
  if (at > now)
147
124
  next = Math.min(next, at);
148
- if (at + RETENTION + 1 > now && !('kind' in row && row.kind === 'wake' && row.outcome === 'accepted'))
125
+ if (at + RETENTION + 1 > now && !durableWake(row))
149
126
  next = Math.min(next, at + RETENTION + 1);
150
127
  }
151
128
  if ('expiresAt' in row && typeof row.expiresAt === 'number' && row.expiresAt > now)
@@ -153,11 +130,15 @@ export class FileHostLedgerPort {
153
130
  }
154
131
  return next;
155
132
  }
133
+ /** The ownership claim critical section: the same lock as claimPresence and finalize. */
134
+ withClaimLock(fn, signal) {
135
+ return withFileLock(path.join(this.root, 'presence-claim.lock'), { ...LOCK, signal }, fn);
136
+ }
156
137
  async claimPresence(i, signal) {
157
- const location = await canon(i.location);
138
+ const location = await canonicalPath(i.location);
158
139
  const record = { ...i, location, updatedAt: i.updatedAt ?? this.clock(), v: 1 };
159
140
  try {
160
- return await withFileLock(path.join(this.root, 'presence-claim.lock'), { ...LOCK, signal }, async () => {
141
+ return await this.withClaimLock(async () => {
161
142
  const file = this.file('presence');
162
143
  // The authoritative read, ownership check, and write share the presence file lock:
163
144
  // a concurrent ensurePresence/removePresence between them must never be overwritten
@@ -170,28 +151,47 @@ export class FileHostLedgerPort {
170
151
  await write(file, [...rows, record], signal);
171
152
  return { status: 'acquired', record };
172
153
  });
173
- });
154
+ }, signal);
174
155
  }
175
156
  catch (error) {
176
157
  return { status: 'degraded', record, error };
177
158
  }
178
159
  }
179
160
  async ensurePresence(i) {
180
- const record = { ...i, location: await canon(i.location), updatedAt: i.updatedAt ?? this.clock(), v: 1 };
161
+ const record = { ...i, location: await canonicalPath(i.location), updatedAt: i.updatedAt ?? this.clock(), v: 1 };
181
162
  try {
182
- await withFileLock(this.file('presence') + '.lock', LOCK, async () => write(this.file('presence'), [...(await read(this.file('presence'), this.clock(), true)).filter((row) => k(row) !== k(record)), record]));
163
+ await withFileLock(this.file('presence') + '.lock', LOCK, async () => {
164
+ const rows = await read(this.file('presence'), this.clock(), true);
165
+ const current = rows.findLast((row) => k(row) === k(record));
166
+ const sameEpoch = current?.epoch === record.epoch;
167
+ const cancelledThrough = sameEpoch ? Math.max(current?.cancelledThrough ?? -1, record.cancelledThrough ?? -1) : record.cancelledThrough ?? -1;
168
+ await write(this.file('presence'), [...rows.filter((row) => k(row) !== k(record)), { ...record, ...(cancelledThrough < 0 ? {} : { cancelledThrough }) }]);
169
+ });
183
170
  return { status: 'ensured', record };
184
171
  }
185
172
  catch (error) {
186
173
  return { status: 'degraded', record, error };
187
174
  }
188
175
  }
176
+ /** Local batch suppression is monotonic only for the captured host generation. */
177
+ async suppressPresence(i, through) {
178
+ const record = { ...i, location: await canonicalPath(i.location) };
179
+ const file = this.file('presence');
180
+ return withFileLock(file + '.lock', LOCK, async () => {
181
+ const rows = await read(file, this.clock(), true);
182
+ const current = rows.findLast((row) => k(row) === k(record) && row.epoch === record.epoch);
183
+ if (!current)
184
+ return false;
185
+ await write(file, rows.map((row) => row === current ? { ...row, cancelledThrough: Math.max(row.cancelledThrough ?? -1, through) } : row));
186
+ return true;
187
+ });
188
+ }
189
189
  async removePresence(i) {
190
- const record = { ...i, location: await canon(i.location) };
190
+ const record = { ...i, location: await canonicalPath(i.location) };
191
191
  await withFileLock(this.file('presence') + '.lock', LOCK, async () => write(this.file('presence'), (await read(this.file('presence'), this.clock(), true)).filter((row) => k(row) !== k(record))));
192
192
  }
193
193
  async removePresenceIfUnchanged(i) {
194
- const record = { ...i, location: await canon(i.location) };
194
+ const record = { ...i, location: await canonicalPath(i.location) };
195
195
  return withFileLock(this.file('presence') + '.lock', LOCK, async () => {
196
196
  const file = this.file('presence');
197
197
  const rows = await read(file, this.clock(), true);
@@ -206,43 +206,127 @@ export class FileHostLedgerPort {
206
206
  });
207
207
  }
208
208
  async listPresence(i = {}) {
209
- const location = i.location === undefined ? undefined : await canon(i.location);
209
+ const location = i.location === undefined ? undefined : await canonicalPath(i.location);
210
210
  return (await read(this.file('presence'), i.now ?? this.clock())).filter((row) => (!location || row.location === location) && (!i.participant || nameKey(row.participant) === nameKey(i.participant)) && (!i.session || row.session === i.session)).map(({ scope: _scope, ...row }) => row);
211
211
  }
212
- async claimWakeDispatch(i) { const location = await canon(i.attention.squarePath), key = JSON.stringify([location, nameKey(i.attention.recipient), i.attention.actIndex, 'wake']), at = i.at ?? this.clock(); return withFileLock(`${this.claims}.lock`, LOCK, async () => { const rows = await readClaims(this.claims, at), existing = rows.find((row) => row.attention_key === key); if (existing?.phase === 'dispatching') {
213
- const alive = processAlive(existing.ownerPid);
214
- if (existing.expiresAt <= at || alive === false)
215
- return { type: 'ambiguous', lease: existing };
216
- return { type: 'busy' };
217
- } if (existing !== undefined && existing.expiresAt > at)
218
- return { type: 'busy' }; const next = { v: 1, ts: at, attention_key: key, leaseId: i.leaseId, expiresAt: at + i.leaseMs, phase: 'claimed', ownerPid: process.pid, ...(i.session === undefined ? {} : { session: i.session }) }; await writeClaims(this.claims, [...rows.filter((row) => row.attention_key !== key), next]); return { type: 'acquired', leaseId: i.leaseId }; }); }
219
- async transitionWakeDispatch(i) { const location = await canon(i.attention.squarePath), key = JSON.stringify([location, nameKey(i.attention.recipient), i.attention.actIndex, 'wake']), at = i.at ?? this.clock(); return withFileLock(`${this.claims}.lock`, LOCK, async () => { const rows = await readClaims(this.claims, at), current = rows.find((row) => { if (row.leaseId !== i.leaseId)
220
- return false; try {
221
- const parts = JSON.parse(row.attention_key);
222
- return parts[0] === location && parts[1] === nameKey(i.attention.recipient) && parts[2] === i.attention.actIndex;
212
+ /** One attempt row owns attention-wide exclusion and session-scoped history for a wake. */
213
+ async claimWakeAttempt(i) {
214
+ const location = await canonicalPath(i.attention.squarePath);
215
+ const activity = formatActivityId(i.attention.actIndex);
216
+ const at = i.now ?? this.clock();
217
+ const file = this.file('evidence');
218
+ try {
219
+ return await withFileLock(file + '.lock', LOCK, async () => {
220
+ const all = await read(file, at, true);
221
+ const attention = all.filter((row) => row.kind === 'wake' && row.location === location && nameKey(row.participant) === nameKey(i.attention.recipient) && row.activity === activity);
222
+ const terminal = attention.findLast((row) => row.outcome === 'accepted') ?? attention.findLast((row) => row.outcome === 'unknown');
223
+ if (terminal)
224
+ return { status: 'terminal', record: terminal };
225
+ const interrupted = attention.filter((row) => row.outcome === 'dispatching');
226
+ const busy = interrupted.find((row) => (row.expiresAt ?? 0) > at && processAlive(row.ownerPid) !== false);
227
+ if (busy)
228
+ return { status: 'busy', record: busy };
229
+ const nextNumber = (session) => attention.filter((row) => row.session === session).reduce((highest, row) => Math.max(highest, row.attemptN ?? 0), 0) + 1;
230
+ // An abandoned attempt may already have been sent, so it blocks every route.
231
+ if (interrupted.length) {
232
+ const recovered = interrupted.map((row) => ({ ...finishWake(row, 'unknown', {
233
+ unknownSource: 'interrupted', signature: 'worker_interrupted_during_dispatch',
234
+ message: 'The notification worker ended after dispatch began; transport acceptance is unknown.',
235
+ }), routeKind: row.routeKind ?? i.routeKind, attemptN: row.attemptN ?? nextNumber(row.session), v: 1 }));
236
+ const replacements = new Map(interrupted.map((row, index) => [row, recovered[index]]));
237
+ await write(file, all.map((row) => replacements.get(row) ?? row));
238
+ return { status: 'terminal', record: recovered[0] };
239
+ }
240
+ const attemptN = nextNumber(i.session), claimToken = `${process.pid}-${at}-${randomUUID()}`;
241
+ await write(file, [...all, { v: 1, at, location, participant: i.attention.recipient, session: i.session, activity,
242
+ kind: 'wake', outcome: 'dispatching', routeKind: i.routeKind, attemptN, claimToken, ownerPid: process.pid, expiresAt: at + i.leaseMs }]);
243
+ return { status: 'acquired', claimToken, attemptN };
244
+ });
245
+ }
246
+ catch (error) {
247
+ return { status: 'degraded', error };
248
+ }
223
249
  }
224
- catch {
225
- return false;
226
- } }); if (current === undefined)
227
- return false; const next = { v: 1, ts: at, attention_key: key, leaseId: i.leaseId, expiresAt: at + i.leaseMs, phase: i.phase, ownerPid: current.ownerPid, ...(i.routeKind === undefined ? {} : { routeKind: i.routeKind }), ...(i.attemptN === undefined ? {} : { attemptN: i.attemptN }), ...(i.session === undefined ? {} : { session: i.session }) }; await writeClaims(this.claims, [...rows.filter((row) => row !== current), next]); return true; }); }
228
- async releaseWakeDispatch(i) { const location = await canon(i.attention.squarePath), key = JSON.stringify([location, nameKey(i.attention.recipient), i.attention.actIndex, 'wake']), at = i.at ?? this.clock(); await withFileLock(`${this.claims}.lock`, LOCK, async () => { const rows = await readClaims(this.claims, at); await writeClaims(this.claims, rows.filter((row) => row.attention_key !== key || row.leaseId !== i.leaseId)); }); }
229
- async listWakeAttempts(i = {}) { const attention = i.attention; return this.listEvidence({ kind: 'wake', ...(attention === undefined ? {} : { location: await canon(attention.squarePath), participant: attention.recipient, activity: `act/${attention.actIndex}` }), ...(i.session === undefined ? {} : { session: i.session }), now: i.now }); }
230
- async appendWakeAttempt(i) { if (i.kind !== 'wake')
231
- throw new Error('Wake attempt rows require wake evidence kind'); await this.appendEvidence(i); }
232
- async claimEvidence(i) { const f = this.file('evidence'), at = i.now ?? this.clock(), { leaseMs, claimToken: requestedToken, ...claim } = i, r = { ...claim, location: await canon(i.location), outcome: 'dispatching', at, expiresAt: at + leaseMs, v: 1 }; try {
250
+ /** One locked, token-fenced replacement; each operation owns its state condition. */
251
+ async updateWake(input, update, at = this.clock()) {
252
+ if (!input.claimToken)
253
+ return false;
254
+ const location = await canonicalPath(input.location), file = this.file('evidence');
255
+ return withFileLock(file + '.lock', LOCK, async () => {
256
+ const all = await read(file, at, true);
257
+ const current = all.findLast((row) => row.kind === 'wake' && row.location === location && nameKey(row.participant) === nameKey(input.participant) && row.activity === input.activity && row.session === input.session && row.claimToken === input.claimToken);
258
+ const next = current && update(current);
259
+ if (!next)
260
+ return false;
261
+ if (next !== current)
262
+ await write(file, all.map((row) => row === current ? { ...next, v: 1 } : row));
263
+ return true;
264
+ });
265
+ }
266
+ async listWakeAttempts(i = {}) {
267
+ const attention = i.attention;
268
+ return this.listEvidence({ kind: 'wake', ...(attention === undefined ? {} : { location: await canonicalPath(attention.squarePath), participant: attention.recipient, activity: formatActivityId(attention.actIndex) }), session: i.session, now: i.now });
269
+ }
270
+ async prepareNativeWake(i) {
271
+ return this.updateWake(i, (current) => current.outcome === 'dispatching'
272
+ ? { ...current, nativeDelivery: i.nativeDelivery, routeKind: i.routeKind, attemptN: i.attemptN } : undefined);
273
+ }
274
+ async confirmWakeAdmission(i) {
275
+ return this.updateWake(i, (current) => {
276
+ if (!current.nativeDelivery || !['dispatching', 'unknown', 'accepted'].includes(current.outcome))
277
+ return undefined;
278
+ return current.outcome === 'accepted' ? current : finishWake(current, 'accepted', { signature: 'native_queue_confirmed', message: undefined, diagnostic: undefined });
279
+ });
280
+ }
281
+ async claimEvidence(i) { if (i.kind === 'wake')
282
+ return { status: 'degraded', error: new Error('wake attempts are claimed with claimWakeAttempt') }; const f = this.file('evidence'), at = i.now ?? this.clock(), { leaseMs, ...claim } = i, r = { ...claim, location: await canonicalPath(i.location), outcome: 'dispatching', at, expiresAt: at + leaseMs, v: 1 }; try {
233
283
  return await withFileLock(f + '.lock', LOCK, async () => { const all = await read(f, at, true), matching = all.filter(x => x.kind === r.kind && x.location === r.location && nameKey(x.participant) === nameKey(r.participant) && x.activity === r.activity && x.session === r.session), old = matching.findLast(x => x.outcome === 'dispatching'); if (old !== undefined && typeof old.expiresAt === 'number' && old.expiresAt > at)
234
- return { status: 'busy', record: old }; const delivered = (r.kind === 'wake' && matching.some(x => x.outcome === 'accepted')) || (r.kind === 'presentation' && matching.some(x => x.outcome === 'presented')); if (delivered)
235
- return { status: 'delivered', record: matching.findLast(x => x.outcome === 'accepted' || x.outcome === 'presented') }; const index = Number(r.activity.slice(4)), attentionKey = JSON.stringify([r.location, nameKey(r.participant), index, r.session ?? null, 'wake']), dispatchToken = r.kind === 'wake' && typeof requestedToken === 'string' && Number.isInteger(index) && (await readClaims(this.claims, at)).some((row) => row.leaseId === requestedToken && row.attention_key === attentionKey) ? requestedToken : undefined, claimToken = dispatchToken ?? `${process.pid}-${at}-${Math.random().toString(36).slice(2)}`, row = { ...r, claimToken }; await write(f, [...all.filter(x => !(x.kind === r.kind && x.location === r.location && nameKey(x.participant) === nameKey(r.participant) && x.activity === r.activity && x.session === r.session && (x.outcome === 'dispatching' || x.outcome === 'released'))), row]); return { status: 'acquired', claimToken }; });
284
+ return { status: 'busy', record: old }; if (r.kind === 'presentation' && matching.some(x => x.outcome === 'presented'))
285
+ return { status: 'delivered', record: matching.findLast(x => x.outcome === 'presented') }; const claimToken = `${process.pid}-${at}-${Math.random().toString(36).slice(2)}`, row = { ...r, claimToken }; await write(f, [...all.filter(x => !(x.kind === r.kind && x.location === r.location && nameKey(x.participant) === nameKey(r.participant) && x.activity === r.activity && x.session === r.session && (x.outcome === 'dispatching' || x.outcome === 'released'))), row]); return { status: 'acquired', claimToken }; });
236
286
  }
237
287
  catch (error) {
238
288
  return { status: 'degraded', error };
239
289
  } }
240
- async releaseEvidence(i) { const f = this.file('evidence'), location = await canon(i.location), at = i.now ?? this.clock(); await withFileLock(f + '.lock', LOCK, async () => { const all = await read(f, at, true), same = (x) => x.kind === i.kind && x.location === location && nameKey(x.participant) === nameKey(i.participant) && x.activity === i.activity && x.session === i.session, current = all.findLast(x => same(x) && x.outcome === 'dispatching'); if (current === undefined || current.claimToken !== i.claimToken)
241
- return; const tombstone = { ...current, outcome: 'released', at, ...(i.routeKind === undefined ? {} : { routeKind: i.routeKind }), ...(i.attemptN === undefined ? {} : { attemptN: i.attemptN }), ...(i.signature === undefined ? {} : { signature: i.signature }), ...(i.message === undefined ? {} : { message: i.message }), ...(i.diagnostic === undefined ? {} : { diagnostic: i.diagnostic }), expiresAt: undefined }; delete tombstone.expiresAt; await write(f, [...all.filter(x => x !== current), tombstone]); }); }
242
- async appendEvidence(i) { const f = this.file('evidence'), r = { ...i, location: await canon(i.location), at: i.at ?? this.clock(), v: 1 }; const token = i.claimToken; if (typeof token !== 'string' || token.length === 0)
243
- return; await withFileLock(f + '.lock', LOCK, async () => { const all = await read(f, r.at, true); const same = (x) => x.kind === r.kind && x.location === r.location && nameKey(x.participant) === nameKey(r.participant) && x.activity === r.activity && x.session === r.session; const current = all.findLast(x => same(x) && x.outcome === 'dispatching'); if (current === undefined || current.claimToken !== token)
244
- return; const terminalKey = (x) => same(x) && x.attemptN === r.attemptN && x.outcome !== 'dispatching' && x.outcome !== 'released'; await write(f, [...all.filter(x => x !== current && !terminalKey(x)), r]); }); }
245
- async listEvidence(i = {}) { const loc = i.location === undefined ? undefined : await canon(i.location); return (await read(this.file('evidence'), i.now ?? this.clock())).filter(r => (i.includeReleased === true || r.outcome !== 'released') && (!loc || r.location === loc) && (!i.participant || nameKey(r.participant) === nameKey(i.participant)) && (!i.session || r.session === i.session) && (!i.activity || r.activity === i.activity) && (!i.kind || r.kind === i.kind)); }
246
- async gcEvidence(i) { const f = this.file('evidence'), pending = new Set(i.pendingWakeActivities ?? []); await withFileLock(f + '.lock', LOCK, async () => write(f, (await read(f, this.clock(), true)).filter(r => r.at >= i.before || r.kind === 'wake' && r.outcome === 'accepted' && pending.has(r.activity)))); }
290
+ async releaseEvidence(i) {
291
+ if (i.kind === 'wake') {
292
+ const { signature, message, diagnostic, routeKind, attemptN } = i;
293
+ const details = Object.fromEntries(Object.entries({ signature, message, diagnostic, routeKind, attemptN }).filter(([, value]) => value !== undefined));
294
+ await this.updateWake(i, (current) => current.outcome === 'dispatching'
295
+ ? finishWake(current, 'released', { ...details, at: i.now ?? this.clock() }) : undefined, i.now);
296
+ return;
297
+ }
298
+ const f = this.file('evidence'), location = await canonicalPath(i.location), at = i.now ?? this.clock();
299
+ await withFileLock(f + '.lock', LOCK, async () => { const all = await read(f, at, true), same = (x) => x.kind === i.kind && x.location === location && nameKey(x.participant) === nameKey(i.participant) && x.activity === i.activity && x.session === i.session, current = all.findLast(x => same(x) && x.outcome === 'dispatching'); if (current === undefined || current.claimToken !== i.claimToken)
300
+ return; const tombstone = { ...current, outcome: 'released', at, ...(i.routeKind === undefined ? {} : { routeKind: i.routeKind }), ...(i.attemptN === undefined ? {} : { attemptN: i.attemptN }), ...(i.signature === undefined ? {} : { signature: i.signature }), ...(i.message === undefined ? {} : { message: i.message }), ...(i.diagnostic === undefined ? {} : { diagnostic: i.diagnostic }), expiresAt: undefined }; delete tombstone.expiresAt; delete tombstone.ownerPid; await write(f, [...all.filter(x => x !== current), tombstone]); });
301
+ }
302
+ /** Token-fenced terminal write. A token may only finish the attempt it claimed. */
303
+ async appendEvidence(i) {
304
+ const f = this.file('evidence'), r = { ...i, location: await canonicalPath(i.location), at: i.at ?? this.clock(), v: 1 };
305
+ const token = i.claimToken;
306
+ if (typeof token !== 'string' || token.length === 0)
307
+ return;
308
+ if (i.kind === 'wake') {
309
+ if (r.outcome !== 'accepted' && r.outcome !== 'failed' && r.outcome !== 'unknown')
310
+ return;
311
+ const outcome = r.outcome;
312
+ await this.updateWake(r, (current) => {
313
+ if (current.outcome !== 'dispatching' && !(current.outcome === 'unknown' && current.unknownSource === 'interrupted' && outcome === 'accepted'))
314
+ return undefined;
315
+ return finishWake(current, outcome, { at: r.at, routeKind: r.routeKind, attemptN: r.attemptN, signature: r.signature, message: r.message, diagnostic: r.diagnostic });
316
+ }, r.at);
317
+ return;
318
+ }
319
+ await withFileLock(f + '.lock', LOCK, async () => {
320
+ const all = await read(f, r.at, true);
321
+ const same = (x) => x.kind === r.kind && x.location === r.location && nameKey(x.participant) === nameKey(r.participant) && x.activity === r.activity && x.session === r.session;
322
+ const dispatching = all.findLast((x) => same(x) && x.outcome === 'dispatching');
323
+ const source = dispatching?.claimToken === token ? dispatching : undefined;
324
+ if (source === undefined)
325
+ return;
326
+ await write(f, [...all.filter((x) => x !== source && !(same(x) && x.attemptN === r.attemptN && x.outcome !== 'dispatching' && x.outcome !== 'released')), { ...r, ...(source.nativeDelivery === undefined ? {} : { nativeDelivery: source.nativeDelivery }) }]);
327
+ });
328
+ }
329
+ async listEvidence(i = {}) { const loc = i.location === undefined ? undefined : await canonicalPath(i.location); return (await read(this.file('evidence'), i.now ?? this.clock())).filter(r => (i.includeReleased === true || r.outcome !== 'released') && (!loc || r.location === loc) && (!i.participant || nameKey(r.participant) === nameKey(i.participant)) && (!i.session || r.session === i.session) && (!i.activity || r.activity === i.activity) && (!i.kind || r.kind === i.kind)); }
330
+ async gcEvidence(i) { const f = this.file('evidence'), pending = new Set(i.pendingWakeActivities ?? []); await withFileLock(f + '.lock', LOCK, async () => write(f, (await read(f, this.clock(), true)).filter(r => r.at >= i.before || durableWake(r) && pending.has(r.activity)))); }
247
331
  }
248
332
  export function createHostLedgerPort(o = {}) { return new FileHostLedgerPort(o); }
@@ -1,16 +1,14 @@
1
1
  import type { WakeRouteKind } from './model.js';
2
- export type PresenceChannel = 'claude-code' | 'codex' | 'opencode' | 'pi' | 'paseo' | 'unknown';
2
+ import type { HarnessSessionChannel } from './participant-identity.js';
3
+ export type PresenceChannel = HarnessSessionChannel | 'unknown';
3
4
  export interface PresenceRecord {
4
5
  readonly location: string;
5
6
  readonly participant: string;
6
7
  readonly session: string;
7
8
  readonly channel: PresenceChannel;
8
- readonly route?: {
9
- readonly kind: WakeRouteKind;
10
- readonly address: Readonly<Record<string, string>>;
11
- };
12
9
  readonly updatedAt?: number;
13
10
  readonly epoch?: number;
11
+ readonly cancelledThrough?: number;
14
12
  }
15
13
  export type PresenceKey = Pick<PresenceRecord, 'location' | 'participant' | 'session' | 'channel'>;
16
14
  export interface PresenceLookup {
@@ -38,12 +36,24 @@ export type PresenceClaimResult = {
38
36
  readonly record: PresenceRecord;
39
37
  readonly error: unknown;
40
38
  };
39
+ export type NativeDeliveryEvidence = {
40
+ readonly payload: string;
41
+ readonly epoch: number;
42
+ } & ({
43
+ readonly harness: 'claude';
44
+ readonly endpoint: string;
45
+ } | {
46
+ readonly harness: 'opencode';
47
+ readonly inputId: string;
48
+ });
41
49
  export interface EvidenceRecord {
50
+ readonly bindingEpoch?: number;
51
+ readonly nativeDelivery?: NativeDeliveryEvidence;
42
52
  readonly location: string;
43
53
  readonly participant: string;
44
54
  readonly session: string;
45
55
  readonly activity: string;
46
- readonly kind: 'wake' | 'presentation';
56
+ readonly kind: 'wake' | 'presentation' | 'lifecycle';
47
57
  readonly outcome: string;
48
58
  readonly at?: number;
49
59
  readonly expiresAt?: number;
@@ -53,25 +63,33 @@ export interface EvidenceRecord {
53
63
  readonly message?: string;
54
64
  readonly diagnostic?: unknown;
55
65
  readonly claimToken?: string;
66
+ readonly ownerPid?: number;
67
+ readonly unknownSource?: 'interrupted' | 'transport';
56
68
  }
69
+ /** Presentation and lifecycle claims only: wake attempts are acquired by claimWakeAttempt. */
57
70
  export interface EvidenceClaim {
58
71
  readonly location: string;
59
72
  readonly participant: string;
60
73
  readonly session: string;
61
74
  readonly activity: string;
62
- readonly kind: EvidenceRecord['kind'];
75
+ readonly kind: 'presentation' | 'lifecycle';
63
76
  readonly leaseMs: number;
64
- readonly claimToken?: string;
65
77
  readonly now?: number;
66
78
  }
67
79
  /** Release metadata and includeReleased are diagnostic-only; default evidence reads stay behavior-safe. */
68
- export interface EvidenceRelease extends Omit<EvidenceClaim, 'leaseMs'> {
80
+ export interface EvidenceRelease {
81
+ readonly location: string;
82
+ readonly participant: string;
83
+ readonly session: string;
84
+ readonly activity: string;
85
+ readonly kind: EvidenceRecord['kind'];
69
86
  readonly claimToken: string;
70
87
  readonly routeKind?: WakeRouteKind;
71
88
  readonly attemptN?: number;
72
89
  readonly signature?: string;
73
90
  readonly message?: string;
74
91
  readonly diagnostic?: unknown;
92
+ readonly now?: number;
75
93
  }
76
94
  export interface EvidenceLookup {
77
95
  readonly location?: string;
@@ -91,46 +109,28 @@ export interface WakeAttention {
91
109
  readonly actIndex: number;
92
110
  readonly recipient: string;
93
111
  }
94
- export interface WakeDispatchLease {
95
- readonly leaseId: string;
96
- readonly expiresAt: number;
97
- readonly phase: 'claimed' | 'dispatching';
98
- readonly routeKind?: WakeRouteKind;
99
- readonly attemptN?: number;
100
- readonly session?: string;
112
+ /** One wake attempt row carries attention-wide exclusion and session-specific history. */
113
+ export interface WakeAttemptClaimInput {
114
+ readonly attention: WakeAttention;
115
+ readonly session: string;
116
+ readonly routeKind: WakeRouteKind;
117
+ readonly leaseMs: number;
118
+ readonly now?: number;
101
119
  }
102
- export type WakeDispatchClaim = {
103
- readonly type: 'acquired';
104
- readonly leaseId: string;
120
+ export type WakeAttemptClaim = {
121
+ readonly status: 'acquired';
122
+ readonly claimToken: string;
123
+ readonly attemptN: number;
124
+ } | {
125
+ readonly status: 'busy';
126
+ readonly record: EvidenceRecord;
105
127
  } | {
106
- readonly type: 'busy';
128
+ readonly status: 'terminal';
129
+ readonly record: EvidenceRecord;
107
130
  } | {
108
- readonly type: 'ambiguous';
109
- readonly lease: WakeDispatchLease;
131
+ readonly status: 'degraded';
132
+ readonly error: unknown;
110
133
  };
111
- export interface WakeDispatchClaimInput {
112
- readonly attention: WakeAttention;
113
- readonly leaseId: string;
114
- readonly leaseMs: number;
115
- readonly session?: string;
116
- readonly at?: number;
117
- }
118
- export interface WakeDispatchTransitionInput {
119
- readonly attention: WakeAttention;
120
- readonly leaseId: string;
121
- readonly phase: WakeDispatchLease['phase'];
122
- readonly leaseMs: number;
123
- readonly routeKind?: WakeRouteKind;
124
- readonly attemptN?: number;
125
- readonly session?: string;
126
- readonly at?: number;
127
- }
128
- export interface WakeDispatchReleaseInput {
129
- readonly attention: WakeAttention;
130
- readonly leaseId: string;
131
- readonly session?: string;
132
- readonly at?: number;
133
- }
134
134
  export interface WakeAttemptLookup {
135
135
  readonly attention?: WakeAttention;
136
136
  readonly session?: string;
@@ -147,8 +147,10 @@ export type ClaimResult = {
147
147
  readonly error: unknown;
148
148
  };
149
149
  export interface HostLedgerPort {
150
+ withClaimLock<T>(fn: () => Promise<T>, signal?: AbortSignal): Promise<T>;
150
151
  claimPresence(input: PresenceRecord, signal?: AbortSignal): Promise<PresenceClaimResult>;
151
152
  ensurePresence(input: PresenceRecord): Promise<PresenceResult>;
153
+ suppressPresence(input: PresenceRecord, through: number): Promise<boolean>;
152
154
  removePresence(input: PresenceKey): Promise<void>;
153
155
  removePresenceIfUnchanged(input: PresenceRecord): Promise<boolean>;
154
156
  listPresence(input: PresenceLookup): Promise<readonly PresenceRecord[]>;
@@ -159,11 +161,14 @@ export interface HostLedgerPort {
159
161
  }): Promise<void>;
160
162
  listEvidence(input: EvidenceLookup): Promise<readonly EvidenceRecord[]>;
161
163
  listWakeAttempts(input?: WakeAttemptLookup): Promise<readonly EvidenceRecord[]>;
162
- appendWakeAttempt(input: EvidenceRecord & {
164
+ prepareNativeWake(input: EvidenceRecord & {
163
165
  readonly claimToken: string;
164
- }): Promise<void>;
165
- claimWakeDispatch(input: WakeDispatchClaimInput): Promise<WakeDispatchClaim>;
166
- transitionWakeDispatch(input: WakeDispatchTransitionInput): Promise<boolean>;
167
- releaseWakeDispatch(input: WakeDispatchReleaseInput): Promise<void>;
166
+ readonly routeKind: WakeRouteKind;
167
+ readonly nativeDelivery: NativeDeliveryEvidence;
168
+ }): Promise<boolean>;
169
+ confirmWakeAdmission(input: EvidenceRecord & {
170
+ readonly claimToken: string;
171
+ }): Promise<boolean>;
172
+ claimWakeAttempt(input: WakeAttemptClaimInput): Promise<WakeAttemptClaim>;
168
173
  gcEvidence(input: EvidenceGc): Promise<void>;
169
174
  }
package/dist/inbox.d.ts CHANGED
@@ -1,5 +1,4 @@
1
1
  import { type InboxMembership } from './model.js';
2
- export declare function hostLedgerForEnv(env: NodeJS.ProcessEnv): import("./host-ledger-file-adapter.js").FileHostLedgerPort;
3
2
  export interface PendingWaitOptions {
4
3
  signal?: AbortSignal;
5
4
  /** Local delivery suppression, never persisted as evidence of receipt. */
@@ -15,5 +14,3 @@ export interface SessionPendingObserver {
15
14
  }
16
15
  /** Session-scoped projection over process-shared storage change detectors. */
17
16
  export declare function observeSessionPending(sessionId: string, suppliedEnv?: NodeJS.ProcessEnv): Promise<SessionPendingObserver>;
18
- /** Finite one-shot consumers retain their API; Pi owns a long-lived observer instead. */
19
- export declare function waitForSessionPending(sessionId: string, timeoutMs: number, options?: PendingWaitOptions, env?: NodeJS.ProcessEnv): Promise<InboxMembership[]>;