@volter/supercode-teams 0.3.159 → 0.3.161

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.
@@ -0,0 +1,42 @@
1
+ // Filing and a terminal queue are not native acceptance. These facts survive
2
+ // a daemon restart, so callers never infer delivery from a retained paste.
3
+ import { envelope, makeAddress, messageIdFor, parseAddress, senderIdentity, threadOf } from './compose.mjs';
4
+
5
+ export function terminalReceipt(store, address, id) {
6
+ const parsed = parseAddress(address);
7
+ if (!parsed) return null;
8
+ const state = store.terminalState(`${parsed.harness}:${parsed.session}`);
9
+ if (!state) return null;
10
+ const settled = state.receipts?.find(item => item.message_ids?.includes(id))
11
+ ?? state.refusals?.find(item => item.message_ids?.includes(id));
12
+ return settled ?? (state.accepted?.[id]
13
+ ? { delivered: false, queued: true, message_ids: [id], reason: state.active?.error ?? 'awaiting native transcript confirmation' } : null);
14
+ }
15
+
16
+ export function paneWaitReason(answer) {
17
+ return answer?.waits?.reason ?? answer?.refused?.reason ?? answer?.reason ?? 'pane input was not accepted';
18
+ }
19
+
20
+ /** Stop the attempt and file one notice in the same commit. Only a locally
21
+ * observed original sender earns an automatic return; link claims do not. */
22
+ export function expireDelivery(store, machine, address, id, attempts, reason) {
23
+ const found = store.findIn(address, id);
24
+ const sent = found ? store.body(found) : null;
25
+ const now = Date.now();
26
+ const expiry = { id, to: address, from: sent?.from ?? '', subject: sent?.subject ?? null,
27
+ filed_at_ms: Number(sent?.created_at_ms) || 0, expired_at_ms: now, attempts, reason };
28
+ store.transaction(() => {
29
+ store.expire(address, id, expiry);
30
+ if (sent?.kind !== 'peer' || parseAddress(sent.from)?.machine !== machine
31
+ || sent.sender_identity?.classification !== 'local' || sent.sender_identity?.observation !== `local:${machine}`) return;
32
+ const from = makeAddress(machine, 'operator', 'mail-delivery');
33
+ const notice = { ...envelope({ id: messageIdFor(from, `delivery-stopped\0${address}\0${id}`), created_at_ms: now,
34
+ from, from_name: 'supercode mail delivery', sender_identity: senderIdentity(from, null, machine),
35
+ kind: 'notice', reply_via: { mode: 'none' }, in_reply_to: id, thread: threadOf(sent), subject: 'Mail delivery stopped',
36
+ body: `Delivery of ${id} to ${address} stopped: ${reason}. Native acceptance was not confirmed. The original message remains unread in its mailbox; it was not discarded. Run supercode message show ${id} or supercode message expired for its recorded status. Do not resend it as a new message.` }),
37
+ delivery_failure_notice: { message_id: id, recipient: address } };
38
+ store.deliver(sent.from, notice);
39
+ store.requestWake(sent.from, notice.id, { keep: true });
40
+ });
41
+ return expiry;
42
+ }
package/mail/hooks.mjs CHANGED
@@ -10,7 +10,9 @@
10
10
  import { readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
11
11
  import { dirname, join, resolve, sep } from 'node:path';
12
12
  import { envelope as makeEnvelope, makeAddress, newMessageId, parseAddress } from './compose.mjs';
13
- import { blake3Hex } from './envelope.mjs';
13
+ import { blake3Hex, shortId } from './envelope.mjs';
14
+ import { readReceipt } from './relay.mjs';
15
+ import { settleRelayAttempt, uniqueRelayDestination } from './relay-routing.mjs';
14
16
  import { typedLineId } from './transcript.mjs';
15
17
 
16
18
  const readJson = (path) => { try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return null; } };
@@ -36,11 +38,19 @@ export class MailHooks {
36
38
  Object.assign(this, { machine, mailRoot, store, live, handOver, threadOf, log });
37
39
  this.stopped = false;
38
40
  this.waiting = new Set();
41
+ this.hookViews = new Map(); // address → last change cursor and unread summary
39
42
  // Windows: each Codex session's last hook (the process it ran under, the rollout it named, when), which serve's
40
43
  // Codex listing reads through this daemon (codex_peer.rs `hook_recorded_rollouts`); kept beside this daemon's state,
41
44
  // never in the mail folder (mail step 1c)
42
45
  this.codexHooksFile = stateDir ? join(stateDir, 'codex-hooks.json') : null;
43
46
  this.codexHooks = new Map(Object.entries((this.codexHooksFile && readJson(this.codexHooksFile)) || {}));
47
+ this.poolDrain = false;
48
+ this.onPoolRecord = ({ collection }) => {
49
+ if (this.stopped || this.poolDrain || !['relay_routes', 'relay_pending', 'relays'].includes(collection)) return;
50
+ this.poolDrain = true;
51
+ setImmediate(() => { this.poolDrain = false; if (!this.stopped) { try { this.drainPoolReplies(); } catch (error) { this.log(`pool replies retained: ${error.message}`); } } });
52
+ };
53
+ this.store.database.on('record', this.onPoolRecord);
44
54
  this.relaysRoots = [resolve(mailRoot, 'relays'), resolve(dirname(mailRoot), 'relays')];
45
55
  }
46
56
 
@@ -65,10 +75,17 @@ export class MailHooks {
65
75
  return null;
66
76
  }
67
77
  if (event !== 'PostToolUse' && !(event === 'Stop' && input.stop_hook_active !== true)) return null;
68
- const pending = new Set(this.store.wakes(address));
69
- const unread = this.store.unreadMessages(address).filter((item) => !pending.has(item.envelope.id));
70
- if (!unread.length) return null;
71
- const pointer = this.#pointer(unread);
78
+ let view = this.hookViews.get(address);
79
+ const page = view ? this.store.changes.read(view.cursor, { topics: ['message', 'delivery', 'claim'], addresses: [address] }) : null;
80
+ if (!view || page.reset || page.changes.length || page.more) {
81
+ // A pointer is a count and sender labels, not a full envelope list. One
82
+ // snapshot covers all changes through this owner turn, including a gap.
83
+ view = { cursor: this.store.changes.cursor(), summary: this.store.hookSummary(address) };
84
+ } else view.cursor = page.cursor;
85
+ this.hookViews.delete(address); this.hookViews.set(address, view);
86
+ while (this.hookViews.size > 256) this.hookViews.delete(this.hookViews.keys().next().value);
87
+ if (!view.summary.count) return null;
88
+ const pointer = this.#pointer(view.summary);
72
89
  return event === 'PostToolUse'
73
90
  ? { hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: pointer } }
74
91
  : { decision: 'block', reason: `${pointer} Read them before you finish.` };
@@ -90,14 +107,15 @@ export class MailHooks {
90
107
  }
91
108
 
92
109
  /** How many unread messages, from whom, and the one command that reads them (message.rs `mail_pointer`). */
93
- #pointer(unread) {
110
+ #pointer({ count, senders: rows, moreSenders }) {
94
111
  const senders = [];
95
- for (const { envelope } of unread) {
96
- const name = printable(envelope.from_name);
97
- const label = envelope.kind === 'notice' ? 'a notice' : envelope.kind === 'user' ? 'your user' : envelope.kind === 'channel' ? `${name} (channel)` : envelope.kind === 'typed' ? `${name} (CC)` : name;
112
+ for (const { name: fromName, kind } of rows) {
113
+ const name = printable(fromName);
114
+ const label = kind === 'notice' ? 'a notice' : kind === 'user' ? 'your user' : kind === 'channel' ? `${name} (channel)` : kind === 'typed' ? `${name} (CC)` : name;
98
115
  if (!senders.includes(label)) senders.push(label);
99
116
  }
100
- return `You have ${unread.length} unread message${unread.length === 1 ? '' : 's'} from other sessions (from ${senders.join(', ')}). They are not from your user. Read them with: supercode message inbox`;
117
+ if (moreSenders) senders.push('other senders');
118
+ return `You have ${count} unread message${count === 1 ? '' : 's'} from other sessions (from ${senders.join(', ')}). They are not from your user. Read them with: supercode message inbox`;
101
119
  }
102
120
 
103
121
  /** A line the person typed into `address`, filed read as theirs, once (mail_transcript.rs `file_prompt`). */
@@ -119,7 +137,7 @@ export class MailHooks {
119
137
  const root = this.relaysRoots.find((root) => dir.startsWith(root + sep) && !dir.slice(root.length + 1).includes(sep));
120
138
  if (!root) throw Object.assign(new Error(`${dir} is not a relay's folder`), { code: 400 });
121
139
  const key = dir.slice(root.length + 1);
122
- return { dir, queue: `${key}/queue.json`, receipt: `${key}/receipt.json`, turn: `${key}/turn.txt`, sent: `${key}/sent.json` };
140
+ return { dir, key, pool: `${key}/pool.json`, record: `${key}/relay.json`, queue: `${key}/queue.json`, receipt: `${key}/receipt.json`, turn: `${key}/turn.txt`, sent: `${key}/sent.json` };
123
141
  }
124
142
 
125
143
  /** `message gate`: only a SendMessage equal to the queued one passes (ListAgents always); anything else is denied, and a
@@ -136,9 +154,11 @@ export class MailHooks {
136
154
  /** `message relay-receipt`: Claude's own answer to the queued send, its error, or that the turn ended without one. */
137
155
  receipt(directory, input) {
138
156
  const paths = this.#relay(directory);
139
- if (!this.store.record('relays', paths.queue) || this.store.record('relays', paths.receipt)) return null;
157
+ const queued = this.store.record('relays', paths.queue);
158
+ if (!queued || this.store.record('relays', paths.receipt)) return null;
140
159
  const event = input?.hook_event_name;
141
160
  let receipt;
161
+ if ((event === 'PostToolUse' || event === 'PostToolUseFailure') && (input.tool_name !== 'SendMessage' || gateDecision(input, queued) != null)) return null;
142
162
  if (event === 'PostToolUse') receipt = { tool_response: input.tool_response ?? null };
143
163
  else if (event === 'PostToolUseFailure') receipt = { send_failed: typeof input.error === 'string' ? input.error : input.error == null ? '' : JSON.stringify(input.error) };
144
164
  else if (event === 'Stop' || event === 'StopFailure') {
@@ -150,7 +170,10 @@ export class MailHooks {
150
170
  const said = event === 'StopFailure' ? `${text('error_details') ?? text('last_assistant_message') ?? ''} (${text('error') ?? 'error'})` : text('last_assistant_message');
151
171
  receipt = { turn_ended: true, said: said == null ? null : Array.from(said).slice(0, 300).join('') };
152
172
  } else return null;
153
- this.store.putRecord('relays', paths.receipt, receipt, { create: true });
173
+ this.store.transaction(() => {
174
+ this.store.putRecord('relays', paths.receipt, receipt, { create: true });
175
+ settleRelayAttempt(this.store, queued, readReceipt(receipt));
176
+ });
154
177
  return null;
155
178
  }
156
179
 
@@ -172,6 +195,14 @@ export class MailHooks {
172
195
  // the replying Claude session runs beside the relay, on this machine
173
196
  const sender = this.live.sessions().find((row) => row.door === 'native' && row.socket && row.socket === event.from.replace(/^uds:/, ''));
174
197
  const sessionId = sender ? sender.address.split(':').slice(3).join(':') : null;
198
+ // A spooled input can arrive between handle retirement and the next
199
+ // successful start. Its durable pool identity still selects the route
200
+ // ledger and the writer notice, never the legacy operator inbox path.
201
+ if (this.store.record('relays', paths.pool)?.pool === true || this.store.record('relays', paths.record)?.pool === true) {
202
+ const incoming = { represented, id, event, sender: sender ? { address: sender.address, name: sender.name, sessionId } : null };
203
+ if (!this.#routePoolReply(incoming)) this.store.putRecord('relay_pending', id, incoming, { create: true });
204
+ return { decision: 'block', reason: 'native reply processed by the shared pool; ambiguous replies are undelivered and the writer is notified through its own door' };
205
+ }
175
206
  const lastSent = this.store.record('relays', paths.sent) ?? {};
176
207
  const inReplyTo = sessionId ? lastSent[sessionId] ?? null : null;
177
208
  this.#fileHome(represented, makeEnvelope({ id, from: makeAddress(this.machine, 'claude-code', sessionId ?? 'unknown'),
@@ -185,6 +216,49 @@ export class MailHooks {
185
216
  return INBOUND_BLOCK;
186
217
  }
187
218
 
219
+ /** A reply during a native send waits for its receipt, not for a guess about
220
+ * the intended origin. Deferred inputs survive a daemon restart. */
221
+ drainPoolReplies() {
222
+ if (this.stopped) return;
223
+ const rows = this.store.statement("SELECT key, record FROM mail_records WHERE collection = 'relay_pending' ORDER BY key LIMIT 256").all();
224
+ let routed = 0;
225
+ for (const row of rows) {
226
+ if (this.#routePoolReply(JSON.parse(row.record))) { this.store.removeRecord('relay_pending', row.key); routed++; }
227
+ }
228
+ if (routed && rows.length === 256) this.onPoolRecord({ collection: 'relay_pending' });
229
+ }
230
+
231
+ #routePoolReply({ represented, id, event, sender }) {
232
+ const route = sender ? uniqueRelayDestination(this.store, sender.sessionId) : { origin: null, candidates: [] };
233
+ if (route.pending) return false;
234
+ const from = sender?.address ?? makeAddress(this.machine, 'claude-code', 'unknown');
235
+ if (route.origin) {
236
+ this.#fileHome(route.origin.sender, makeEnvelope({ id, from, from_name: sender.name, kind: 'peer', reply_via: { mode: 'command' },
237
+ in_reply_to: route.origin.id, in_reply_to_inferred: true, thread: this.threadOf(route.origin.id), native_from: event.from, body: event.body }));
238
+ return true;
239
+ }
240
+ const candidates = route.candidates.map(({ sender: address, id: message }) => ({ address, id: message }));
241
+ const held = { ...makeEnvelope({ id, from, from_name: sender?.name ?? event.fromName ?? 'unverified native writer', kind: 'peer',
242
+ reply_via: { mode: 'none' }, native_from: event.from, body: event.body }),
243
+ pool_reply_undelivered: { reason: sender ? 'there is no single proven sender destination' : 'the native writer is unverified', candidates } };
244
+ this.store.transaction(() => {
245
+ // Retention of the exact reply is diagnostics, never a delivery to any
246
+ // possible sender. The writer's notice has its own distinct message id.
247
+ this.store.deliver(represented, held);
248
+ if (sender) {
249
+ const noticeId = `m-${blake3Hex(`pool-undelivered\0${id}`).slice(0, 24)}`;
250
+ const examples = candidates.map((candidate) => `supercode message reply ${candidate.id}`).join(' or ');
251
+ const notice = { ...makeEnvelope({ id: noticeId, from: represented, from_name: 'supercode mail relay', kind: 'notice', reply_via: { mode: 'none' },
252
+ body: `Your native reply ${shortId(id)} was NOT DELIVERED to a sender: this shared relay has no single proven reply destination. None of the possible senders was marked delivered. Use supercode message reply <original-message-id>, or the send_message MCP tool with in_reply_to naming that id. ${examples ? `Known message ids: ${examples}. ` : ''}The original incoming envelope names its reply door. Do not send a plain native reply to the relay.` }),
253
+ pool_reply_failure_notice: { reply_id: id } };
254
+ this.store.deliver(sender.address, notice);
255
+ this.store.requestWake(sender.address, noticeId, { keep: true });
256
+ }
257
+ });
258
+ this.log(`pool reply ${id} undelivered: ${sender ? `writer ${sender.address}'s notice waits for its own delivery door` : 'the native writer could not be verified; exact reply retained with the refusal'}`);
259
+ return true;
260
+ }
261
+
188
262
  /** A relay's inbound mail its hook kept while this daemon did not answer it (the front door's spool, outside the mail
189
263
  * folder): each filed as the hook would have filed it, then let go; oldest first. */
190
264
  drainSpool(dir) {
@@ -215,8 +289,7 @@ export class MailHooks {
215
289
  #fileHome(represented, envelope) {
216
290
  // not filed is a failed filing, never a quiet one: the hook keeps the mail (its spool), and a drain keeps its file
217
291
  this.store.deliver(represented, envelope);
218
- if (parseAddress(represented).machine === this.machine) void Promise.resolve(this.handOver(represented, envelope.id)).catch((error) => this.log(`relay inbound: ${envelope.id} stays in ${represented}'s mailbox: ${error.message}`));
219
- else this.store.requestWake(represented, envelope.id);
292
+ this.store.requestWake(represented, envelope.id, { keep: true });
220
293
  }
221
294
 
222
295
  // ---- `message native-question-hook <binding>` ---------------------------------------------------------------------
@@ -273,6 +346,8 @@ export class MailHooks {
273
346
 
274
347
  stop() {
275
348
  this.stopped = true;
349
+ this.store.database.off('record', this.onPoolRecord);
350
+ this.hookViews.clear();
276
351
  for (const key of this.waiting) this.store.removeRecord('native_questions', key);
277
352
  this.waiting.clear();
278
353
  }
package/mail/reads.mjs CHANGED
@@ -140,7 +140,8 @@ export class MailReads {
140
140
  paneOf(session) { return this.#paneOf(session); }
141
141
 
142
142
  #paneOf(session) {
143
- const pane = typeof session.pane === 'string' ? session.pane.split(':')[0] : null;
143
+ const selected = session.door === 'native' ? session.delivery_pane : session.pane;
144
+ const pane = typeof selected === 'string' ? selected.split(':')[0] : null;
144
145
  return pane && isDaemonPane(pane) ? pane : null;
145
146
  }
146
147
 
@@ -0,0 +1,41 @@
1
+ // A shared native identity carries no per-sender reply address. Keep at most two
2
+ // origins per recipient: a second origin makes ambiguity permanent. Never evict
3
+ // ambiguity and then infer a fresh unique origin for that same session id.
4
+ const COLLECTION = 'relay_routes';
5
+ const MAX_ROUTES = 1_000_000;
6
+
7
+ export function relayRoute(store, receiver) { return store.record(COLLECTION, receiver); }
8
+
9
+ export function beginRelayAttempt(store, queued) {
10
+ if (!queued.expects_reply) return;
11
+ const route = relayRoute(store, queued.receiver) ?? { origins: [], ambiguous: false };
12
+ if (!relayRoute(store, queued.receiver) && store.statement('SELECT count(*) AS count FROM mail_records WHERE collection = ?').get(COLLECTION).count >= MAX_ROUTES) {
13
+ throw new Error('the pool reply-address ledger reached its bound; no send was submitted');
14
+ }
15
+ if (route.pending && route.pending.id !== queued.message_id) throw new Error('the receiver has an unresolved pooled send; no send was submitted');
16
+ store.putRecord(COLLECTION, queued.receiver, { ...route, pending: { sender: queued.sender, id: queued.message_id } });
17
+ }
18
+
19
+ export function settleRelayAttempt(store, queued, outcome) {
20
+ if (!queued?.expects_reply) return;
21
+ const route = relayRoute(store, queued.receiver);
22
+ if (!route || route.pending?.id !== queued.message_id) return;
23
+ const origins = [...route.origins];
24
+ if (outcome.delivered || outcome.unknown) {
25
+ const index = origins.findIndex((origin) => origin.sender === queued.sender);
26
+ const origin = { sender: queued.sender, id: queued.message_id, confirmed: outcome.delivered === true };
27
+ if (index >= 0) {
28
+ // An unanswered attempt never replaces the last confirmed message id.
29
+ if (outcome.delivered || !origins[index].confirmed) origins[index] = origin;
30
+ } else if (origins.length < 2) origins.push(origin);
31
+ route.ambiguous ||= origins.some((item) => item.sender !== queued.sender);
32
+ }
33
+ store.putRecord(COLLECTION, queued.receiver, { origins, ambiguous: route.ambiguous, pending: null });
34
+ }
35
+
36
+ export function uniqueRelayDestination(store, receiver) {
37
+ const route = relayRoute(store, receiver);
38
+ if (route?.pending) return { pending: true, candidates: route.origins };
39
+ const origin = !route?.ambiguous && route?.origins.length === 1 && route.origins[0].confirmed ? route.origins[0] : null;
40
+ return { origin, candidates: route?.origins ?? [], ambiguous: Boolean(route?.ambiguous) };
41
+ }