@volter/supercode-teams 0.3.112 → 0.3.114

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/bin/workspace.mjs CHANGED
@@ -1010,13 +1010,19 @@ async function connectorCommand(verb, { flags, rest, output, client, context, te
1010
1010
  const sync = saved ? { ...(readSyncStatus(state.directory) ?? { not_syncing: [], last_error: null, queued_captures: 0 }), agent_identity: agentIdentityStatus(teamsHome()) } : null;
1011
1011
  // The mail this machine's mailbox still has to deliver: after a failed attempt, or for a machine that is offline.
1012
1012
  let mailWaiting = [];
1013
- try { mailWaiting = JSON.parse(execFileSync(process.env.SUPERCODE_TEAMS_TEST_BIN ?? process.env.SUPERCODE_BIN ?? 'supercode', ['message', 'waiting', '--json'], { encoding: 'utf8', timeout: 15000, stdio: ['ignore', 'pipe', 'ignore'] })); } catch { /* an older supercode has no such door */ }
1014
- const value = { version: 1, context: scope(context), data: { mail_waiting: mailWaiting, reachable, account, connector: saved ? { enrolled: true, running: running || busy, ...(busy ? { busy: true } : {}), machine: saved.machine, source: saved.source, sync } : { enrolled: false, running: false }, sources: reachable && !context.machine_only ? (await team.sources.list()).items : [] } };
1013
+ // asked of the daemon, which keeps these records (mail step 1); a daemon that does not answer is said, never read as none
1014
+ let mailWaitingError = null;
1015
+ try {
1016
+ const channel = await openLocalMachine(paths(teamsHome()).socket);
1017
+ try { const rpc = await nodeRpc(channel); mailWaiting = (await rpc.call('harness.v1.mail.waiting', {}, { timeoutMs: 15000 }))?.rows ?? []; }
1018
+ finally { channel.close?.(); }
1019
+ } catch (error) { mailWaitingError = error.message; }
1020
+ const value = { version: 1, context: scope(context), data: { mail_waiting: mailWaiting, ...(mailWaitingError ? { mail_waiting_error: mailWaitingError } : {}), reachable, account, connector: saved ? { enrolled: true, running: running || busy, ...(busy ? { busy: true } : {}), machine: saved.machine, source: saved.source, sync } : { enrolled: false, running: false }, sources: reachable && !context.machine_only ? (await team.sources.list()).items : [] } };
1015
1021
  if (flags.json) return output(value);
1016
1022
  const lines = [`Team ${context.team_name ?? context.team_id} · ${new URL(context.origin).host}${reachable ? '' : ' · unreachable'}`];
1017
1023
  lines.push(saved?.machine ? `Connector ${saved.machine.name ?? saved.machine.id} · ${running ? 'running' : busy ? 'running (busy: its control socket did not answer within 1 s)' : 'not running'}${saved.service?.kind ? ` (${saved.service.kind})` : ''}` : 'Connector not connected (supercode teams connect)');
1018
1024
  lines.push(...syncStatusLines(sync));
1019
- lines.push(...mailWaitingLines(mailWaiting));
1025
+ lines.push(...(mailWaitingError ? [`Mail waiting mail unknown: this machine's daemon did not answer (${mailWaitingError})`] : mailWaitingLines(mailWaiting)));
1020
1026
  process.stdout.write(`${lines.join('\n')}\n`);
1021
1027
  return;
1022
1028
  }
package/machine.mjs CHANGED
@@ -42,10 +42,12 @@ import { Agents } from './mail/agents.mjs';
42
42
  import { Relays } from './mail/relay.mjs';
43
43
  import { serveRelayEndpoint } from './mail/relay-endpoint.mjs';
44
44
  import { MailCarrier } from './mail/carrier.mjs';
45
+
46
+ const NO_CALLER = "Can't tell which session is running this command, so replies would have nowhere to go. Nothing was sent. Run it from your agent session's own shell tool.";
45
47
  import { MailSend } from './mail/send.mjs';
46
48
  import { AgentVerbs } from './mail/verbs.mjs';
47
49
  import { deliverToRuntime } from './mail/runtime.mjs';
48
- import { envelope as envelopeOf, linkOf, parseAddress, senderIdentity } from './mail/compose.mjs';
50
+ import { envelope as envelopeOf, linkOf, makeAddress, parseAddress, senderIdentity } from './mail/compose.mjs';
49
51
  import { codexQuestions } from './native-questions.mjs';
50
52
  import { Duplex, PassThrough } from 'node:stream';
51
53
 
@@ -1927,6 +1929,30 @@ export class TeamsMachine extends MachineCore {
1927
1929
  * a claim is the same user's word, held to what the map holds. A request the Teams connector forwards from another
1928
1930
  * machine keeps the caller it carries. A local claim the map does not hold is refused.
1929
1931
  */
1932
+ /** The caller's unread mail claimed under its reader's process (a reader that ends gives it back), with the token its
1933
+ * reader acknowledges once it has shown it: `message inbox`, `read_messages` and `message wait`. */
1934
+ async #claimInbox(caller, { all, last, pid, via }) {
1935
+ const read = await this.mail.inbox(caller, { all, last, pid, via });
1936
+ let token = null;
1937
+ if (read.claimed.length) {
1938
+ token = randomBytes(12).toString('hex');
1939
+ this.inboxClaims ??= new Map();
1940
+ // a reader that never acknowledges leaves its claims under its own pid (given back when it ends): its token
1941
+ // is let go after a while, never held for good
1942
+ for (const [old] of [...this.inboxClaims].slice(0, Math.max(0, this.inboxClaims.size - 255))) this.inboxClaims.delete(old);
1943
+ this.inboxClaims.set(token, { address: caller, claimed: read.claimed });
1944
+ }
1945
+ // `plain`: each message as its reader is shown it (the envelope rendered), with no listing's line about when
1946
+ const plain = read.stored.map((item) => render(item.envelope));
1947
+ const claimedIds = new Set(read.claimed.map((item) => item.envelope.id));
1948
+ const kept = inboxKept(read.stored.length,
1949
+ (index) => Buffer.byteLength(read.rendered[index] ?? '') + Buffer.byteLength(plain[index] ?? '') + Buffer.byteLength(JSON.stringify(read.rows[index] ?? null)),
1950
+ (index) => claimedIds.has(read.stored[index].envelope.id));
1951
+ const left = read.stored.length - kept.length;
1952
+ const note = left ? [inboxNote(left)] : [];
1953
+ return { code: 0, address: caller, rendered: [...note, ...kept.map((index) => read.rendered[index])], plain: [...note, ...kept.map((index) => plain[index])], rows: kept.map((index) => read.rows[index]), omitted: left, token };
1954
+ }
1955
+
1930
1956
  #callerOf(entry, params) {
1931
1957
  const named = typeof params.caller === 'string' && params.caller ? params.caller : null;
1932
1958
  if (entry.principal !== 'local') return Promise.resolve(named);
@@ -3673,28 +3699,65 @@ export class TeamsMachine extends MachineCore {
3673
3699
  // the caller's own inbox: its unread mail claimed under its own process (a reader that ends gives it back), then
3674
3700
  // acknowledged once it has printed it (harness.v1.mail.acknowledge)
3675
3701
  const caller = await this.#callerOf(entry, params);
3676
- if (typeof caller !== 'string' || /^sc:[^:]+:board:/.test(caller)) return { code: 2, text: "Can't tell which session is running this command, so replies would have nowhere to go. Nothing was sent. Run it from your agent session's own shell tool." };
3702
+ if (typeof caller !== 'string' || /^sc:[^:]+:board:/.test(caller)) return { code: 2, text: NO_CALLER };
3677
3703
  // `via`: the door the claims record (`inbox`, or `read_messages` for `message mcp`'s tool)
3678
- const via = params.via === 'read_messages' ? 'read_messages' : 'inbox';
3679
- const read = await this.mail.inbox(caller, { all: params.all === true, last, pid: Number(params.claim?.pid) || process.pid, via });
3680
- let token = null;
3681
- if (read.claimed.length) {
3682
- token = randomBytes(12).toString('hex');
3683
- this.inboxClaims ??= new Map();
3684
- // a reader that never acknowledges leaves its claims under its own pid (given back when it ends): its token
3685
- // is let go after a while, never held for good
3686
- for (const [old] of [...this.inboxClaims].slice(0, Math.max(0, this.inboxClaims.size - 255))) this.inboxClaims.delete(old);
3687
- this.inboxClaims.set(token, { address: caller, claimed: read.claimed });
3704
+ return this.#claimInbox(caller, { all: params.all === true, last, pid: Number(params.claim?.pid) || process.pid, via: params.via === 'read_messages' ? 'read_messages' : 'inbox' });
3705
+ }
3706
+ // `message wait` (step 1): the caller's next unread mail (from one sender, with `from`), waited for on the store's
3707
+ // own change events, at most 100 s, then claimed as its inbox claims it; nothing polls
3708
+ case 'harness.v1.mail.wait': {
3709
+ if (entry.principal !== 'local' || entry.appDoor) throw Object.assign(new Error('mail is read here by this machine\'s own processes'), { code: 403 });
3710
+ const caller = await this.#callerOf(entry, params);
3711
+ if (typeof caller !== 'string' || /^sc:[^:]+:board:/.test(caller)) return { code: 2, text: NO_CALLER };
3712
+ let wanted = null;
3713
+ if (typeof params.from === 'string' && params.from) { const found = this.mail.resolve(params.from); wanted = found.error ? params.from : found.address; }
3714
+ const seconds = Math.min(100, Math.max(0, Number.isFinite(Number(params.timeout)) ? Math.floor(Number(params.timeout)) : 100));
3715
+ // exactly what its claim would take (store.claimUnread): never a live reader's claimed copy or a user's own turn
3716
+ // waiting for its pane, so a wait never wakes for mail it then cannot claim; an abandoned claim is given back first
3717
+ const waiting = () => this.mailStore.hasClaimable(caller, wanted ? (envelope) => envelope.from === wanted || String(envelope.from_name ?? '').startsWith(wanted) : null);
3718
+ if (!waiting()) {
3719
+ const arrived = await new Promise((resolve) => {
3720
+ const changed = (address) => { if (address === caller && waiting()) done(true); };
3721
+ const done = (value) => { clearTimeout(timer); this.mailStore.off('changed', changed); resolve(value); };
3722
+ const timer = setTimeout(() => done(false), seconds * 1000);
3723
+ this.mailStore.on('changed', changed);
3724
+ });
3725
+ if (!arrived) return { code: 6, timeout: true, text: `No mail within ${seconds} seconds; nothing is lost.` };
3726
+ }
3727
+ // everything unread is handed over, not only the awaited sender's: a message not shown now would stay silent
3728
+ return this.#claimInbox(caller, { all: false, last: null, pid: Number(params.claim?.pid) || process.pid, via: 'wait' });
3729
+ }
3730
+ // harness serve's `sessions.inbox` (step 1), asked here by serve: an operator's own mailbox (`from_name`) has its
3731
+ // unread mail claimed and acknowledged, so a second read does not return it; any other address (`address`) is only
3732
+ // looked at, never consumed
3733
+ case 'harness.v1.mail.operator_inbox': {
3734
+ if (entry.principal !== 'local' || entry.appDoor) throw Object.assign(new Error('sessions.inbox is answered here for this machine\'s own harness serve'), { code: 403 });
3735
+ if (params.address != null && params.from_name != null) throw Object.assign(new Error('sessions.inbox takes from_name or address, not both'), { code: 400 });
3736
+ let address;
3737
+ if (typeof params.address === 'string') {
3738
+ if (!parseAddress(params.address)) throw Object.assign(new Error(`not an address: ${params.address}`), { code: 400 });
3739
+ address = params.address;
3740
+ } else {
3741
+ const name = Array.from(String(params.from_name ?? 'supercode').trim(), (c) => /\s|@/.test(c) ? '-' : c).join('');
3742
+ if (!name) throw Object.assign(new Error('from_name must not be empty'), { code: 400 });
3743
+ address = makeAddress(machineName(this.env), 'operator', name);
3744
+ if (!address) throw Object.assign(new Error(`from_name is not a mailbox name: ${name}`), { code: 400 });
3688
3745
  }
3689
- // `plain`: each message as its reader is shown it (the envelope rendered), with no listing's line about when
3690
- const plain = read.stored.map((item) => render(item.envelope));
3691
- const claimedIds = new Set(read.claimed.map((item) => item.envelope.id));
3692
- const kept = inboxKept(read.stored.length,
3693
- (index) => Buffer.byteLength(read.rendered[index] ?? '') + Buffer.byteLength(plain[index] ?? '') + Buffer.byteLength(JSON.stringify(read.rows[index] ?? null)),
3694
- (index) => claimedIds.has(read.stored[index].envelope.id));
3695
- const left = read.stored.length - kept.length;
3696
- const note = left ? [inboxNote(left)] : [];
3697
- return { code: 0, address: caller, rendered: [...note, ...kept.map((index) => read.rendered[index])], plain: [...note, ...kept.map((index) => plain[index])], rows: kept.map((index) => read.rows[index]), omitted: left, token };
3746
+ const all = params.all === true;
3747
+ const shown = (item, state) => { const envelope = this.mailStore.body(item) ?? item.envelope; return { state, envelope, rendered: render(envelope) }; };
3748
+ if (typeof params.address === 'string') {
3749
+ const { stored } = this.mailStore.inventory(address);
3750
+ return { address, messages: stored.filter((item) => all || item.state === 'unread').map((item) => shown(item, item.state)), looked: true };
3751
+ }
3752
+ const read = await this.mail.inbox(address, { all, last: null, pid: process.pid, via: 'sessions.inbox' });
3753
+ const messages = [...(all ? read.stored.filter((item) => item.state === 'read').map((item) => shown(item, 'read')) : []), ...read.claimed.map((item) => shown(item, 'unread'))];
3754
+ this.mail.acknowledge(address, read.claimed);
3755
+ return { address, messages };
3756
+ }
3757
+ // `message waiting` and `message expired` (step 1): the carrier's own records
3758
+ case 'harness.v1.mail.waiting': case 'harness.v1.mail.expired': {
3759
+ if (entry.principal !== 'local' || entry.appDoor) throw Object.assign(new Error('mail is read here by this machine\'s own processes'), { code: 403 });
3760
+ return { code: 0, rows: method === 'harness.v1.mail.waiting' ? this.carrier.waiting() : this.carrier.expired() };
3698
3761
  }
3699
3762
  // Who a local client is, from its claim (live-sessions.mjs callerOf): what a command acting on another machine sends
3700
3763
  // there as its caller, resolved here, where the map that holds it is. Local only; never the app door's.
package/mail/carrier.mjs CHANGED
@@ -262,6 +262,49 @@ export class MailCarrier {
262
262
  catch (error) { this.log(`carrier: ${machine}'s wait was not kept: ${error.message}`); }
263
263
  }
264
264
 
265
+ // ---- what waits, and what expired (`message waiting`, `message expired`) -----------------------------------------
266
+
267
+ /** Every message here still waiting to be delivered after a failed attempt, or for a machine that is offline, oldest
268
+ * filed first (mailbox.rs `waiting_mail`, field for field). */
269
+ waiting() {
270
+ const all = [];
271
+ let folders = [];
272
+ try { folders = readdirSync(this.mailRoot); } catch { return all; }
273
+ for (const folder of folders) {
274
+ let ids = [];
275
+ try { ids = readdirSync(join(this.mailRoot, folder, 'wake')); } catch { continue; }
276
+ if (!ids.length) continue;
277
+ const address = this.#address(folder);
278
+ if (!address) continue;
279
+ const machine = address.split(':')[1];
280
+ const wait = machine !== this.machine ? readJson(this.#machineWaitPath(machine)) : null;
281
+ for (const id of ids) {
282
+ const state = this.store.wakeState(address, id);
283
+ if (!wait && !(state.attempts > 0) && state.last_error == null) continue;
284
+ const found = this.store.findIn(address, id);
285
+ const envelope = found ? readJson(found.path) : null;
286
+ if (!envelope) continue;
287
+ all.push({ id, to: address, from: envelope.from ?? '', subject: envelope.subject ?? null, filed_at_ms: Number(envelope.created_at_ms) || 0,
288
+ waiting_for: wait ? wait.machine : null, since_ms: wait?.offline_since_ms ?? state.failing_since_ms ?? (Number(envelope.created_at_ms) || 0),
289
+ attempts: state.attempts ?? 0, last_error: wait?.last_error ?? state.last_error ?? null });
290
+ }
291
+ }
292
+ return all.sort((a, b) => a.filed_at_ms - b.filed_at_ms);
293
+ }
294
+
295
+ /** Every wake that expired here, oldest first (mailbox.rs `expired_wakes`). */
296
+ expired() {
297
+ const all = [];
298
+ let folders = [];
299
+ try { folders = readdirSync(this.mailRoot); } catch { return all; }
300
+ for (const folder of folders) {
301
+ let names = [];
302
+ try { names = readdirSync(join(this.mailRoot, folder, 'expired')); } catch { continue; }
303
+ for (const name of names) { const expiry = readJson(join(this.mailRoot, folder, 'expired', name)); if (expiry?.id) all.push(expiry); }
304
+ }
305
+ return all.sort((a, b) => (a.expired_at_ms ?? 0) - (b.expired_at_ms ?? 0));
306
+ }
307
+
265
308
  // ---- how an attempt went ---------------------------------------------------------------------------------------
266
309
 
267
310
  /** Record how a wake went: done, waiting for its next attempt, or expired at its bound (mail_watch.rs `settle`). */
package/mail/relay.mjs CHANGED
@@ -283,11 +283,11 @@ export class Relays {
283
283
  }
284
284
  // why it is not reused, said in the timing line: a relay that should have been warm and was not shows here
285
285
  step(`runtime not reused (${stale ? 'it held a stale turn' : !runtime ? 'no live receipt' : !alive(this.#pid(spec, runtime)) ? 'its process ended' : !record.connection ? 'no connection' : 'endpoint moved'})`);
286
- // closed through the owner's door even after its receipt went. One whose receipt is gone (it ended) is only
287
- // serve's record of it: let go without holding this send (yuerans, 2026-10-06: 2.3-18 s of every send under
288
- // load); one still held is closed before its successor starts, as before
286
+ // closed through the owner's door even after its receipt went. One whose receipt is gone, or whose process
287
+ // ended, is only serve's record of it: let go without holding this send (yuerans, 2026-10-06: 2.3-18 s of every
288
+ // send under load); one still running is closed before its successor starts, as before
289
289
  const close = this.serve('harness.v1.runtimes.close', { connection: record.connection, runtime_id: record.runtime_id }, CLOSE_MS);
290
- if (runtime) {
290
+ if (runtime && alive(this.#pid(spec, runtime))) {
291
291
  try { await close; }
292
292
  catch (error) { if (this.runtimeOf('claude-code', record.runtime_id)) throw new Error(`could not retire the old relay: ${error.message}`); }
293
293
  step('old runtime closed (runtimes.close)');
package/mail/store.mjs CHANGED
@@ -191,6 +191,22 @@ export class MailStore extends EventEmitter {
191
191
  /** The unread mail of `address` (not the user's own turns) moved into `claimed/` under its reader's pid (`pid`, the
192
192
  * reading process: a claim whose reader ends goes back to `new/`), each claim recorded; another reader that took one
193
193
  * first keeps it (mailbox.rs `claim_unread`). Oldest first. */
194
+ /** Whether `claimUnread` would take anything now, taking nothing: abandoned claims given back first, as it does, then
195
+ * any envelope in new/ that is not a user's own turn (`message wait` wakes only for mail it can claim). From the
196
+ * mailbox's own folders, as claimUnread reads them. */
197
+ hasClaimable(address, wanted = null) {
198
+ const box = this.boxOf(address) ?? this.#named(address);
199
+ if (!box) return false;
200
+ this.#recoverAbandoned(box);
201
+ let names = [];
202
+ try { names = readdirSync(join(box.dir, 'new')); } catch { return false; }
203
+ return names.some((file) => {
204
+ if (!file.endsWith('.json')) return false;
205
+ const envelope = readEnvelope(join(box.dir, 'new', file));
206
+ return envelope && typeof envelope.id === 'string' && envelope.kind !== 'user' && (!wanted || wanted(envelope));
207
+ });
208
+ }
209
+
194
210
  claimUnread(address, via, caller, pid = process.pid) {
195
211
  // the mailbox's own folders, read here (one named read): what decides a delivery never comes from the watch-fed
196
212
  // index alone, whose events a platform can drop (an FSEvents drop, an inotify overflow)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-teams",
3
- "version": "0.3.112",
3
+ "version": "0.3.114",
4
4
  "type": "module",
5
5
  "description": "A machine daemon serving this machine's doors, and a local or remote Teams server for shared session discovery, OpenTelemetry ingestion and access to enrolled machines.",
6
6
  "license": "MIT",