@volter/supercode-teams 0.3.151 → 0.3.153

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.
package/mail/envelope.mjs CHANGED
@@ -2,8 +2,6 @@
2
2
  // reads of it (`render`). Ported line for line: what a session reads does not depend on which process read it out.
3
3
  import { blake3 } from '@noble/hashes/blake3.js';
4
4
  import { bytesToHex } from '@noble/hashes/utils.js';
5
- import { readFileSync } from 'node:fs';
6
- import { join } from 'node:path';
7
5
 
8
6
  /** Command an agent runs to send or reply. */
9
7
  export const SEND_COMMAND = 'supercode message send';
@@ -101,13 +99,13 @@ export const threadId = (envelope) => envelope.thread ?? envelope.id;
101
99
  * When a native question's reply reached its session (mail_question.rs `delivered_at`): only for a reply to a `q-`
102
100
  * question whose recorded reply is this envelope.
103
101
  */
104
- export function questionDeliveredAt(envelope, mailRoot) {
102
+ export function questionDeliveredAt(envelope, store) {
105
103
  const id = envelope.in_reply_to;
106
104
  if (typeof id !== 'string' || !id.startsWith('q-')) return null;
107
105
  try {
108
- const reply = JSON.parse(readFileSync(join(mailRoot, 'native-questions', `${id}.reply`), 'utf8'));
106
+ const reply = store.record('native_questions', `${id}.reply`);
109
107
  if (reply?.envelope?.id !== envelope.id) return null;
110
- const delivered = JSON.parse(readFileSync(join(mailRoot, 'native-questions', `${id}.delivered`), 'utf8'));
108
+ const delivered = store.record('native_questions', `${id}.delivered`);
111
109
  return Number.isFinite(delivered) ? delivered : null;
112
110
  } catch { return null; }
113
111
  }
@@ -128,9 +126,9 @@ export function describe(delivery) {
128
126
  const printable = (name) => [...String(name)].filter((character) => /^[A-Za-z0-9\-_.@ ]$/.test(character)).slice(0, 80).join('');
129
127
 
130
128
  /** A message as an inbox lists it (message.rs `listed`). */
131
- export function listed(envelope, deliveries, mailRoot) {
129
+ export function listed(envelope, deliveries, store) {
132
130
  const sent = rfc3339(envelope.created_at_ms);
133
- const native = questionDeliveredAt(envelope, mailRoot);
131
+ const native = questionDeliveredAt(envelope, store);
134
132
  const delivered = describe(native !== null ? { state: 'delivered', at_ms: native } : deliveries.get(envelope.id) ?? null);
135
133
  if (envelope.kind === 'answer') return `${render(envelope)}\n(sent ${sent})`;
136
134
  if (envelope.kind !== 'user') return `${render(envelope)}\n(sent ${sent}; delivered to the model: ${delivered})`;
@@ -143,13 +141,13 @@ export function listed(envelope, deliveries, mailRoot) {
143
141
  }
144
142
 
145
143
  /** A message as `show` and `inbox --json` give it (message.rs `message_row`). */
146
- export function messageRow(address, stored, deliveries, mailRoot) {
147
- const native = questionDeliveredAt(stored.envelope, mailRoot);
144
+ export function messageRow(address, stored, deliveries, store) {
145
+ const native = questionDeliveredAt(stored.envelope, store);
148
146
  return {
149
147
  session: address,
150
148
  state: stored.state,
151
149
  delivery: native !== null ? { state: 'delivered', at_ms: native } : deliveries.get(stored.envelope.id) ?? null,
152
150
  envelope: stored.envelope,
153
- rendered: listed(stored.envelope, deliveries, mailRoot),
151
+ rendered: listed(stored.envelope, deliveries, store),
154
152
  };
155
153
  }
package/mail/hooks.mjs CHANGED
@@ -7,8 +7,8 @@
7
7
  // that send went, `relay-inbound` files mail a relay's Claude was handed in the mailbox of the session it speaks for.
8
8
  // - `message native-question-hook` (mail_question.rs `hook`): a native session's creator recorded at its start, and its
9
9
  // AskUserQuestion asked as mail and answered by the reply.
10
- import { existsSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
11
- import { join, resolve, sep } from 'node:path';
10
+ import { readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
11
+ import { dirname, join, resolve, sep } from 'node:path';
12
12
  import { envelope as makeEnvelope, makeAddress, newMessageId, parseAddress } from './compose.mjs';
13
13
  import { blake3Hex } from './envelope.mjs';
14
14
  import { typedLineId } from './transcript.mjs';
@@ -34,13 +34,14 @@ export class MailHooks {
34
34
  /** `handOver(address, id)` a filed message to its session's door; `threadOf(id)` the thread a reply to `id` joins. */
35
35
  constructor({ machine, mailRoot, store, live, handOver, threadOf, stateDir = null, log = () => {} }) {
36
36
  Object.assign(this, { machine, mailRoot, store, live, handOver, threadOf, log });
37
+ this.stopped = false;
38
+ this.waiting = new Set();
37
39
  // Windows: each Codex session's last hook (the process it ran under, the rollout it named, when), which serve's
38
40
  // Codex listing reads through this daemon (codex_peer.rs `hook_recorded_rollouts`); kept beside this daemon's state,
39
41
  // never in the mail folder (mail step 1c)
40
42
  this.codexHooksFile = stateDir ? join(stateDir, 'codex-hooks.json') : null;
41
43
  this.codexHooks = new Map(Object.entries((this.codexHooksFile && readJson(this.codexHooksFile)) || {}));
42
- this.relaysRoot = resolve(mailRoot, 'relays');
43
- this.questions = join(mailRoot, 'native-questions');
44
+ this.relaysRoots = [resolve(mailRoot, 'relays'), resolve(dirname(mailRoot), 'relays')];
44
45
  }
45
46
 
46
47
  // ---- `message hook <harness>` -------------------------------------------------------------------------------------
@@ -50,7 +51,7 @@ export class MailHooks {
50
51
  const session = typeof input?.session_id === 'string' ? input.session_id : null;
51
52
  const address = session ? makeAddress(this.machine, harness, session) : null;
52
53
  if (!address) return null;
53
- const box = this.store.open(address);
54
+ this.store.open(address);
54
55
  // Windows: where the listing cannot see a Codex's open rollout, the session's own hook says it runs (the process
55
56
  // that ran it, and the rollout it names), until the live map knows Codex there (step 1c)
56
57
  if (process.platform === 'win32' && harness === 'codex' && Number.isInteger(claim?.ppid)) {
@@ -64,9 +65,8 @@ export class MailHooks {
64
65
  return null;
65
66
  }
66
67
  if (event !== 'PostToolUse' && !(event === 'Stop' && input.stop_hook_active !== true)) return null;
67
- let pending = [];
68
- try { pending = readdirSync(join(box.dir, 'wake')); } catch { /* none */ }
69
- const unread = this.store.inventory(address).stored.filter((item) => item.state === 'unread' && !pending.includes(item.envelope.id));
68
+ const pending = new Set(this.store.wakes(address));
69
+ const unread = this.store.unreadMessages(address).filter((item) => !pending.has(item.envelope.id));
70
70
  if (!unread.length) return null;
71
71
  const pointer = this.#pointer(unread);
72
72
  return event === 'PostToolUse'
@@ -112,42 +112,45 @@ export class MailHooks {
112
112
 
113
113
  // ---- a relay's hooks ----------------------------------------------------------------------------------------------
114
114
 
115
- /** A relay's own folder, only one under this machine's relays: nothing outside it is read or written for a hook. */
115
+ /** A relay's runtime directory identifies its row. Older hooks can keep their
116
+ * Maildir-era identifier; neither spelling opens that directory for mail. */
116
117
  #relay(directory) {
117
118
  const dir = resolve(String(directory ?? ''));
118
- if (!dir.startsWith(this.relaysRoot + sep) || dir.slice(this.relaysRoot.length + 1).includes(sep)) throw Object.assign(new Error(`${dir} is not a relay's folder`), { code: 400 });
119
- return { dir, queue: join(dir, 'queue.json'), receipt: join(dir, 'receipt.json'), turn: join(dir, 'turn.txt'), sent: join(dir, 'sent.json') };
119
+ const root = this.relaysRoots.find((root) => dir.startsWith(root + sep) && !dir.slice(root.length + 1).includes(sep));
120
+ if (!root) throw Object.assign(new Error(`${dir} is not a relay's folder`), { code: 400 });
121
+ 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` };
120
123
  }
121
124
 
122
125
  /** `message gate`: only a SendMessage equal to the queued one passes (ListAgents always); anything else is denied, and a
123
126
  * queued send refused still answers its sender (claude_relay.rs `gate_decision`). */
124
127
  gate(directory, input) {
125
128
  const paths = this.#relay(directory);
126
- const queued = readJson(paths.queue);
129
+ const queued = this.store.record('relays', paths.queue);
127
130
  const reason = gateDecision(input ?? {}, queued);
128
131
  if (reason == null) return null;
129
- if (queued && !existsSync(paths.receipt)) { try { writeFileSync(paths.receipt, JSON.stringify({ denied: reason })); } catch { /* best kept */ } }
132
+ if (queued) { try { this.store.putRecord('relays', paths.receipt, { denied: reason }, { create: true }); } catch { /* best kept */ } }
130
133
  return { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: reason } };
131
134
  }
132
135
 
133
136
  /** `message relay-receipt`: Claude's own answer to the queued send, its error, or that the turn ended without one. */
134
137
  receipt(directory, input) {
135
138
  const paths = this.#relay(directory);
136
- if (!existsSync(paths.queue) || existsSync(paths.receipt)) return null;
139
+ if (!this.store.record('relays', paths.queue) || this.store.record('relays', paths.receipt)) return null;
137
140
  const event = input?.hook_event_name;
138
141
  let receipt;
139
142
  if (event === 'PostToolUse') receipt = { tool_response: input.tool_response ?? null };
140
143
  else if (event === 'PostToolUseFailure') receipt = { send_failed: typeof input.error === 'string' ? input.error : input.error == null ? '' : JSON.stringify(input.error) };
141
144
  else if (event === 'Stop' || event === 'StopFailure') {
142
145
  // a turn that began before this send was queued (the last send's, still ending) is not its answer
143
- const queued = readJson(paths.queue);
144
- let prompt = null; try { prompt = readFileSync(paths.turn, 'utf8'); } catch { /* none recorded */ }
146
+ const queued = this.store.record('relays', paths.queue);
147
+ const prompt = this.store.record('relays', paths.turn);
145
148
  if (queued && prompt != null && !prompt.includes(sendTurn(queued))) return null;
146
149
  const text = (key) => (typeof input[key] === 'string' ? input[key] : null);
147
150
  const said = event === 'StopFailure' ? `${text('error_details') ?? text('last_assistant_message') ?? ''} (${text('error') ?? 'error'})` : text('last_assistant_message');
148
151
  receipt = { turn_ended: true, said: said == null ? null : Array.from(said).slice(0, 300).join('') };
149
152
  } else return null;
150
- writeFileSync(paths.receipt, JSON.stringify(receipt));
153
+ this.store.putRecord('relays', paths.receipt, receipt, { create: true });
151
154
  return null;
152
155
  }
153
156
 
@@ -158,7 +161,7 @@ export class MailHooks {
158
161
  const prompt = typeof input?.prompt === 'string' ? input.prompt : '';
159
162
  const trimmed = prompt.trimStart();
160
163
  if (!(trimmed.startsWith(NATIVE_PEER_OPENING) || trimmed.startsWith(NATIVE_IDLE_NOTICE) || trimmed.startsWith(NATIVE_DELIVERY_NOTICE))) {
161
- try { writeFileSync(paths.turn, prompt); } catch { /* best kept */ }
164
+ try { this.store.putRecord('relays', paths.turn, prompt); } catch { /* best kept */ }
162
165
  return null;
163
166
  }
164
167
  if (!parseAddress(represented)) throw Object.assign(new Error(`not an address: ${represented}`), { code: 400 });
@@ -169,7 +172,7 @@ export class MailHooks {
169
172
  // the replying Claude session runs beside the relay, on this machine
170
173
  const sender = this.live.sessions().find((row) => row.door === 'native' && row.socket && row.socket === event.from.replace(/^uds:/, ''));
171
174
  const sessionId = sender ? sender.address.split(':').slice(3).join(':') : null;
172
- const lastSent = readJson(paths.sent) ?? {};
175
+ const lastSent = this.store.record('relays', paths.sent) ?? {};
173
176
  const inReplyTo = sessionId ? lastSent[sessionId] ?? null : null;
174
177
  this.#fileHome(represented, makeEnvelope({ id, from: makeAddress(this.machine, 'claude-code', sessionId ?? 'unknown'),
175
178
  from_name: sender ? sender.name : event.fromName ?? 'an unknown Claude session', kind: 'peer', reply_via: { mode: 'command' },
@@ -225,14 +228,15 @@ export class MailHooks {
225
228
  const seedFile = resolve(String(seedPath ?? ''));
226
229
  const launches = resolve(this.mailRoot, 'native-launches');
227
230
  if (!seedFile.startsWith(launches + sep)) throw Object.assign(new Error(`${seedFile} is not a native launch's binding`), { code: 400 });
228
- const seed = readJson(seedFile);
231
+ const seedKey = seedFile.slice(launches.length + 1);
232
+ const seed = this.store.record('native_launches', seedKey);
229
233
  if (!seed || (seed.kind !== 'codex' && seed.kind !== 'claude-code')) throw new Error('unsupported native question harness');
230
234
  const binding = questions.bind(seed, { session_id: input?.session_id, transcript_path: typeof input?.transcript_path === 'string' ? input.transcript_path : null }, seed.kind);
231
235
  if (input?.hook_event_name === 'SessionStart' && binding.transcript_path) {
232
236
  // this launch's baseline precedes its native initial prompt, including on resume
233
- const receipt = `${seedFile.replace(/\.json$/, '')}.session.json`;
237
+ const receipt = `${seedKey.replace(/\.json$/, '')}.session.json`;
234
238
  let offset = 0; try { offset = statSync(binding.transcript_path).size; } catch { /* not there yet */ }
235
- try { questions.create(receipt, { path: binding.transcript_path, offset }); } catch (error) { if (error.code !== 'EEXIST') throw error; }
239
+ this.store.putRecord('native_launches', receipt, { path: binding.transcript_path, offset }, { create: true });
236
240
  }
237
241
  if (input?.tool_name !== 'AskUserQuestion') return {};
238
242
  const tool = input.tool_input ?? {};
@@ -242,23 +246,35 @@ export class MailHooks {
242
246
  const id = questions.idOf(binding.session, native);
243
247
  const answersOf = (reply) => Object.fromEntries(Object.entries(reply?.answers ?? {}).map(([key, value]) => [key, value?.answers?.[0] ?? null]));
244
248
  if (input.hook_event_name === 'PostToolUse') {
245
- const reply = readJson(join(this.questions, `${id}.reply`));
249
+ const reply = this.store.record('native_questions', `${id}.reply`);
246
250
  if (reply && JSON.stringify(input.tool_response?.answers ?? null) === JSON.stringify(answersOf(reply))) questions.confirm(id, reply);
247
251
  return {};
248
252
  }
249
253
  if (input.hook_event_name !== 'PreToolUse') return {};
254
+ if (this.store.record('native_questions', `${id}.reply`)?.body_expired_at_ms != null) {
255
+ return { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny',
256
+ permissionDecisionReason: 'This native question was already answered; its answer text expired under mail retention. Start a new question instead of reusing its tool id.' } };
257
+ }
250
258
  // one hook owns this wait, marked by its process: a hook its harness killed ends the wait, and never answers
251
- const waiting = join(this.questions, `${id}.waiting`);
252
- questions.create(waiting, { pid: claim?.pid ?? null });
259
+ const waiting = `${id}.waiting`;
260
+ if (!this.store.putRecord('native_questions', waiting, { pid: claim?.pid ?? null }, { create: true })) throw Object.assign(new Error(`native question already has a waiting hook: ${id}`), { code: 'EEXIST' });
261
+ this.waiting.add(waiting);
253
262
  try {
254
263
  questions.file(binding, native);
255
264
  for (;;) {
256
- const reply = readJson(join(this.questions, `${id}.reply`));
265
+ if (this.stopped) throw Object.assign(new Error('the daemon stopped while waiting for a native question reply'), { code: 503 });
266
+ const reply = this.store.record('native_questions', `${id}.reply`);
257
267
  if (reply) return { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'allow', updatedInput: { ...tool, answers: answersOf(reply) } } };
258
268
  if (claim?.pid && !alive(Number(claim.pid))) return {};
259
269
  await new Promise((done) => setTimeout(done, 250));
260
270
  }
261
- } finally { rmSync(waiting, { force: true }); }
271
+ } finally { if (!this.stopped) this.store.removeRecord('native_questions', waiting); this.waiting.delete(waiting); }
272
+ }
273
+
274
+ stop() {
275
+ this.stopped = true;
276
+ for (const key of this.waiting) this.store.removeRecord('native_questions', key);
277
+ this.waiting.clear();
262
278
  }
263
279
  }
264
280
 
package/mail/idle.mjs CHANGED
@@ -28,6 +28,7 @@ export class IdleNotices {
28
28
  */
29
29
  constructor({ machine, store, live, serveRequest, locatorOf, log = () => {} }) {
30
30
  Object.assign(this, { machine, store, live, serveRequest, locatorOf, log });
31
+ this.stopped = false;
31
32
  this.targets = new Set(); // addresses here with a subscription on them
32
33
  this.activity = new Map(); // address → { turn, presence } as serve last reported it
33
34
  this.subscription = null; this.followed = '';
@@ -36,14 +37,15 @@ export class IdleNotices {
36
37
  }
37
38
 
38
39
  start() {
39
- this.store.on('subscriptions', (address) => this.#track(address));
40
- for (const address of this.store.addresses()) this.#track(address);
40
+ this.onSubscriptions = (address) => this.#track(address);
41
+ this.store.on('subscriptions', this.onSubscriptions);
42
+ for (const address of this.store.subscribed()) this.#track(address);
41
43
  this.sweeper = setInterval(() => { for (const address of this.targets) void this.#settle(address); }, SWEEP_MS);
42
44
  this.sweeper.unref?.();
43
45
  return this;
44
46
  }
45
47
 
46
- stop() { clearInterval(this.sweeper); clearInterval(this.runtimeTimer); this.#unfollow(); }
48
+ stop() { this.stopped = true; this.store.off('subscriptions', this.onSubscriptions); clearInterval(this.sweeper); clearInterval(this.runtimeTimer); this.#unfollow(); }
47
49
 
48
50
  /** Serve's activity notification for this subscription: each changed session settled. */
49
51
  notified(params) {
@@ -56,6 +58,7 @@ export class IdleNotices {
56
58
  serveExited() { this.subscription = null; this.followed = ''; this.#follow(); }
57
59
 
58
60
  #track(address) {
61
+ if (this.stopped) return;
59
62
  const parsed = parseAddress(address);
60
63
  if (!parsed || parsed.machine !== this.machine) return;
61
64
  const has = this.store.subscriptions(address).length > 0;
@@ -66,6 +69,7 @@ export class IdleNotices {
66
69
  }
67
70
 
68
71
  #seen(activity) {
72
+ if (this.stopped) return;
69
73
  const address = `sc:${this.machine}:${activity.harness}:${activity.session_id}`;
70
74
  if (!this.targets.has(address)) return;
71
75
  this.activity.set(address, { turn: activity.turn, presence: activity.presence });
@@ -76,6 +80,7 @@ export class IdleNotices {
76
80
 
77
81
  /** The subscribed sessions' activity followed (one subscription for all of them, made again when the set changes). */
78
82
  async #follow() {
83
+ if (this.stopped) return;
79
84
  const locators = [];
80
85
  for (const address of this.targets) {
81
86
  if (this.#runtime(address)) continue; // a hosted runtime is asked itself
@@ -90,7 +95,7 @@ export class IdleNotices {
90
95
  if (locators.length) {
91
96
  try {
92
97
  const answer = await this.serveRequest('harness.v1.sessions.activity.subscribe', { locators: locators.slice(0, MAX_FOLLOWED) }, 30_000);
93
- if (this.followed !== fingerprint) { if (answer?.subscription) void this.serveRequest('harness.v1.sessions.activity.unsubscribe', { subscription: answer.subscription }, 30_000).catch(() => {}); return; }
98
+ if (this.stopped || this.followed !== fingerprint) { if (answer?.subscription) void this.serveRequest('harness.v1.sessions.activity.unsubscribe', { subscription: answer.subscription }, 30_000).catch(() => {}); return; }
94
99
  this.subscription = answer?.subscription ?? null;
95
100
  if (locators.length > MAX_FOLLOWED) this.log(`idle notices: ${locators.length - MAX_FOLLOWED} subscribed sessions are not followed (at most ${MAX_FOLLOWED}); they settle on expiry`);
96
101
  for (const activity of answer?.initial ?? []) this.#seen(activity);
@@ -110,6 +115,7 @@ export class IdleNotices {
110
115
 
111
116
  /** The hosted runtimes with a final reply waiting, asked for their turn every RUNTIME_POLL_MS while one waits. */
112
117
  #pollRuntimes() {
118
+ if (this.stopped) return;
113
119
  const waiting = [...this.targets].filter((address) => this.#runtime(address) && this.store.subscriptions(address).some((item) => item.final_reply));
114
120
  this.polling = new Set(waiting);
115
121
  if (!waiting.length) { clearInterval(this.runtimeTimer); this.runtimeTimer = null; return; }
@@ -130,7 +136,7 @@ export class IdleNotices {
130
136
 
131
137
  /** One pass over one receiver's subscriptions (mail_watch.rs `tick`, for one mailbox). */
132
138
  async #settle(address) {
133
- if (this.settling.has(address)) return;
139
+ if (this.stopped || this.settling.has(address)) return;
134
140
  this.settling.add(address);
135
141
  try {
136
142
  const subscriptions = this.store.subscriptions(address);
@@ -151,6 +157,7 @@ export class IdleNotices {
151
157
  const seen = this.activity.get(address);
152
158
  if (seen) { working = seen.turn === 'working'; idle = seen.turn === 'idle'; ended = seen.presence === 'persisted'; }
153
159
  }
160
+ if (this.stopped) return;
154
161
  // one turn settles every message a sender subscribed with; the sender hears it once, about its latest message,
155
162
  // and its earlier subscriptions settle with it silently
156
163
  subscriptions.sort((a, b) => (b.created_at_ms ?? 0) - (a.created_at_ms ?? 0));
@@ -162,6 +169,7 @@ export class IdleNotices {
162
169
  // however fast the turn was
163
170
  if (subscription.final_reply && !ended && !expired) {
164
171
  const answer = receipt && idle ? await this.#answerTo(address, receipt, subscription.message_id) : null;
172
+ if (this.stopped) return;
165
173
  if (answer && this.store.removeSubscription(address, subscription.message_id)) {
166
174
  this.#reply(address, subscription, answer);
167
175
  if (subscription.notice !== false) this.#notice(address, subscription, 'idle');
@@ -237,6 +245,7 @@ export class IdleNotices {
237
245
 
238
246
  /** Filed in the subscriber's mailbox with a wake: the carrier delivers it here, or carries it to its machine. */
239
247
  #file(subscriber, envelope) {
248
+ if (this.stopped) return;
240
249
  try { this.store.deliver(subscriber, envelope); this.store.requestWake(subscriber, envelope.id); }
241
250
  catch (error) { this.log(`idle notices: ${envelope.id} for ${subscriber} was not filed: ${error.message}`); }
242
251
  }