@volter/supercode-teams 0.3.85 → 0.3.87

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -12,6 +12,18 @@
12
12
  import { execFileSync } from 'node:child_process';
13
13
  import { randomUUID } from 'node:crypto';
14
14
  import { usage, requireArgs } from './args.mjs';
15
+ import { openMachine } from '../../machine-door.mjs';
16
+ import { nodeRpc } from '../../client.mjs';
17
+ import { ownClaim } from '../../live-sessions.mjs';
18
+
19
+ /** One mail sent by this machine's daemon, as this process's caller (its claim): throws when it was not sent. */
20
+ async function mail(to, body, subject) {
21
+ const channel = await openMachine({ machine: null });
22
+ try {
23
+ const answer = await (await nodeRpc(channel)).call('harness.v1.mail.send', { to, body, subject, claim: ownClaim() }, { timeoutMs: 120_000 });
24
+ if (answer?.code !== 0) throw new Error(answer?.text ?? 'not sent');
25
+ } finally { channel.close(); }
26
+ }
15
27
 
16
28
  async function pack() {
17
29
  try { return await import('@volter/supercode-calendar'); } catch { /* not installed */ }
@@ -116,7 +128,7 @@ export async function calendarCommand(sub, { flags, rest, output, client, contex
116
128
  const uid = `notice-${risk.id}-${risk.due}`;
117
129
  if (notices.has(uid)) continue;
118
130
  const body = `${risk.title} (${risk.id}) is at risk for ${new Date(risk.due).toISOString().slice(0, 16)}Z: ${calendar.sentence(risk.chance, risk.p85, risk.due)} (${risk.reason}). The calendar's base rate; your call is yours.`;
119
- execFileSync(supercode(), ['message', 'send', String(flags.notify), body, '--subject', `At risk: ${risk.id}`], { stdio: ['ignore', 'pipe', 'pipe'] });
131
+ await mail(String(flags.notify), body, `At risk: ${risk.id}`);
120
132
  await put(client, path, 'notice', { '@type': 'Event', uid, title: `Mailed ${flags.notify} about ${risk.id}`, start: new Date().toISOString(), duration: 'PT0S', 'volter:kind': 'notice', 'volter:card': risk.id });
121
133
  sent.push(risk.id);
122
134
  }
package/bin/workspace.mjs CHANGED
@@ -1132,6 +1132,11 @@ async function connectorCommand(verb, { flags, rest, output, client, context, te
1132
1132
  if (!context.machine_only) machineEvents.published = new PublishedEvents({ supercodeHome: home, context: contextName, machineId: saved.machine.id });
1133
1133
  const machine = new TeamsMachine({ home: daemonHome, supercode: binary, ...(flags.tmux_socket ? { tmuxSocket: String(flags.tmux_socket) } : {}), link: { server: context.origin, teamId: context.team_id, machineId: saved.machine.id, credential: saved.credential.token, incarnationId: connector.state.incarnation_id, configuration: () => connector.configuration() }, onTurn: (turn) => connector.noteTurn(turn), onTurnWithdrawn: (turn) => connector.withdrawTurn(turn) });
1134
1134
  machine.machineEvents = machineEvents;
1135
+ // what ends the daemon's harness serve is said in the connector's log, as `machine start` says it
1136
+ machine.on('serve-exit', (exit) => process.stderr.write(`supercode teams: harness serve exited: ${JSON.stringify(exit)}\n`));
1137
+ machine.on('serve-stderr', (text) => process.stderr.write(`supercode teams: harness serve: ${String(text).trimEnd().slice(-2000)}\n`));
1138
+ // its mail goes through the daemon's own send, in this process
1139
+ machineEvents.send = (request) => machine.sendOwn(request);
1135
1140
  // the Claude worker homes the roots declare: their live sessions are this machine's too (the live-session map)
1136
1141
  connector.onClaudeHomes = (homes) => machine.live?.claudeHomes(homes);
1137
1142
  // The boards registered with this daemon are published by this connector, under the machine credential (D138 step 4).
@@ -176,6 +176,9 @@ function atomic(path, text) {
176
176
  /** How long a held session's never-published observation is kept. */
177
177
  const PROFILE_FORGOTTEN_MS=7*86400000;
178
178
 
179
+ /** How long the connector waits for the machine daemon's harness to come back before it gives up on its feeds. */
180
+ const REOPEN_PATIENCE_MS=3*60_000;
181
+
179
182
  export class TeamsConnector {
180
183
  constructor({server,teamId,machineId,sourceId,credential,stateDir,daemon=null,core,largeCore=core,slowCore=largeCore,harnesses=[],hostKeyId=null,effectExecutor=null,clock={nowMs:()=>Date.now()},fetch:fetchImpl=globalThis.fetch,heartbeatMs=15000,nativeIdleMs=NATIVE_IDLE_MS}){Object.assign(this,{server:serverOrigin(server),teamId,machineId,sourceId,credential,stateDir,daemon,core,largeCore,slowCore,harnesses,hostKeyId,effectExecutor,clock,fetch:fetchImpl,heartbeatMs,nativeIdleMs});mkdirSync(stateDir,{recursive:true,mode:0o700});chmodSync(stateDir,0o700);this.statePath=join(stateDir,'connector.json');this.state=existsSync(this.statePath)?JSON.parse(readFileSync(this.statePath,'utf8')):{version:1,sequence:0};this.loadEntries();this.state.effects??={};if(Array.isArray(this.state.sync_status?.problems))this.state.sync_status.problems=this.state.sync_status.problems.filter(item=>item?.code!==CAPACITY_CODE);this.forgetNativeCache();this.state.native_failed??={};this.spillHolders();this.removeUnheldSpills();this.state.incarnation_id=`inc_${randomBytes(16).toString('hex')}`;this.abort=new AbortController();this.work=Promise.resolve();this.nativeEffects=new NativeEffects(this);this.effects=new EffectConsumer(this,effectExecutor?{prepare:async effect=>({execute:()=>effectExecutor(effect,{core:this.core,signal:this.abort.signal})})}:this.nativeEffects);this.save();}
181
184
  // A capture burst saves after every step, and each save is a synchronous stringify, write and fsync of the whole
@@ -515,8 +518,23 @@ export class TeamsConnector {
515
518
  if(this.reopening||!this.feeds?.size)return;this.reopening=true;
516
519
  process.stderr.write(`supercode teams: ${reason}; the session index feeds are opened again\n`);
517
520
  for(const feed of this.feeds.values())feed.dead=true;
518
- try{for(const [key,feed] of [...this.feeds])await this.openFeed(key,feed,{});this.activityFingerprint=null;this.queueActivityFollow();}
519
- catch(error){this.feedsFailed(error);}
521
+ // The daemon starts its harness serve again after it ends (1 s doubling to 1 min): the feeds are opened again on a
522
+ // growing wait that outlasts it (1 s doubling to 30 s, for up to 3 minutes), and only a daemon that never brings
523
+ // one back ends this connector.
524
+ const started=Date.now();let wait=1000;
525
+ try{
526
+ for(;;){
527
+ try{for(const [key,feed] of [...this.feeds])if(feed.dead)await this.openFeed(key,feed,{});break;}
528
+ catch(error){
529
+ if(this.abort.signal.aborted)return;
530
+ if(Date.now()-started>REOPEN_PATIENCE_MS){this.feedsFailed(error);return;}
531
+ process.stderr.write(`supercode teams: the session index feeds are not open yet (${error?.message??error}); tried again in ${Math.round(wait/1000)} s\n`);
532
+ await new Promise(resolve=>{const timer=setTimeout(resolve,wait);timer.unref?.();});
533
+ wait=Math.min(30_000,wait*2);
534
+ }
535
+ }
536
+ this.activityFingerprint=null;this.queueActivityFollow();
537
+ }
520
538
  finally{this.reopening=false;}
521
539
  }
522
540
  /**
package/execution.mjs CHANGED
@@ -109,7 +109,9 @@ export class JobOwner {
109
109
  writeFileSync(join(this.dir, `${id}.json`), JSON.stringify(record), { mode: 0o600 });
110
110
  this.live.set(id, record);
111
111
  child.stdout.on('data', (bytes) => { if (!stream.closed) stream.write(bytes.toString('base64')); });
112
- stream.on('data', (chunk) => { if (!child.stdin.destroyed) child.stdin.write(Buffer.from(chunk, 'base64')); });
112
+ // a job that stopped reading its input (EPIPE) ends that job's stream, never the daemon
113
+ child.stdin.on('error', (error) => finish(`its input closed (${error.code ?? error.message})`));
114
+ stream.on('data', (chunk) => { if (!child.stdin.destroyed && child.stdin.writable) child.stdin.write(Buffer.from(chunk, 'base64')); });
113
115
  const finish = (reason) => {
114
116
  if (!this.live.has(id)) return;
115
117
  this.live.delete(id);
package/live-sessions.mjs CHANGED
@@ -219,6 +219,8 @@ export class LiveSessions extends EventEmitter {
219
219
  return;
220
220
  }
221
221
  if (typeof record.sessionId !== 'string' || !record.sessionId) return;
222
+ // a parked session (its process kept for a job) is no live session (claude_peer.rs `read_registry`)
223
+ if (record.parkedJobId != null) { if (known?.dir === dir) { this.#dropClaude(pid); this.#changed(); } return; }
222
224
  const history = known ? known.history.filter((id) => id !== record.sessionId) : [];
223
225
  history.push(record.sessionId);
224
226
  while (history.length > HISTORY_KEPT) history.shift();
@@ -226,6 +228,7 @@ export class LiveSessions extends EventEmitter {
226
228
  this.claude.set(pid, {
227
229
  pid, dir, sessionId: record.sessionId, name: typeof record.name === 'string' ? record.name : '', status: typeof record.status === 'string' ? record.status : 'unknown',
228
230
  cwd: typeof record.cwd === 'string' ? record.cwd : null, tmux: typeof record.tmux === 'string' ? record.tmux : null, procStart, history,
231
+ socket: typeof record.messagingSocketPath === 'string' && record.messagingSocketPath ? record.messagingSocketPath : null,
229
232
  // a rewrite of the same process's file (its status, its name) keeps its start's check: no `ps` for each rewrite
230
233
  ...(known && known.procStart === procStart && known.startChecked ? { startChecked: known.startChecked } : {}),
231
234
  });
@@ -371,20 +374,20 @@ export class LiveSessions extends EventEmitter {
371
374
  sessions() {
372
375
  const out = []; const seen = new Set();
373
376
  const named = new Map([...this.claude.values()].map((entry) => [entry.sessionId, entry]));
374
- for (const runtime of this.runtimes.values()) {
377
+ for (const [id, runtime] of this.runtimes) {
375
378
  if (!alive(runtime.pid)) continue;
376
379
  const registered = named.get(runtime.sessionId) ?? (runtime.runtimeSessionId ? named.get(runtime.runtimeSessionId) : null);
377
380
  if (registered?.name.startsWith(RELAY_PREFIX)) continue;
378
381
  const address = this.#address(runtime.harness, runtime.sessionId);
379
382
  if (seen.has(address)) continue; seen.add(address);
380
383
  const name = registered?.name || `${runtime.harness}-${runtime.sessionId.slice(0, 8)}`;
381
- out.push({ address, name: `${name}@${this.machine}`, status: 'hosted', door: 'runtime', pid: runtime.pid, cwd: runtime.cwd, pane: null, tools: false });
384
+ out.push({ address, name: `${name}@${this.machine}`, status: 'hosted', door: 'runtime', pid: runtime.pid, cwd: runtime.cwd, pane: null, tools: false, receipt: id });
382
385
  }
383
386
  for (const entry of this.claude.values()) {
384
387
  if (entry.name.startsWith(RELAY_PREFIX) || !alive(entry.pid)) continue;
385
388
  const address = this.#address('claude-code', entry.sessionId);
386
389
  if (seen.has(address)) continue; seen.add(address);
387
- out.push({ address, name: `${entry.name}@${this.machine}`, status: entry.status, door: 'native', pid: entry.pid, cwd: entry.cwd, pane: entry.tmux, tools: this.hasTools(entry.pid) });
390
+ out.push({ address, name: `${entry.name}@${this.machine}`, status: entry.status, door: 'native', pid: entry.pid, cwd: entry.cwd, pane: entry.tmux, tools: this.hasTools(entry.pid), socket: entry.socket ?? null });
388
391
  }
389
392
  const codex = new Set([...this.codexRunning.keys(), ...this.codexPaneOf.keys()]);
390
393
  for (const sessionId of codex) {
@@ -399,6 +402,36 @@ export class LiveSessions extends EventEmitter {
399
402
  return out;
400
403
  }
401
404
 
405
+ /**
406
+ * The live-runtime receipt of `harness`'s runtime `id` (its source session or its runtime session), read whole from its
407
+ * file and checked as live_runtime.rs `resolve_live_runtime` checks it (its schema, a loopback address, its process
408
+ * alive, the source it names): the bearer token a delivery uses is read here, at the delivery, never kept in the map.
409
+ */
410
+ runtimeReceipt(harness, id) {
411
+ for (const [receiptId, runtime] of this.runtimes) {
412
+ if (runtime.harness !== harness || (runtime.sessionId !== id && runtime.runtimeSessionId !== id) || !alive(runtime.pid)) continue;
413
+ const receipt = readReceiptFile(join(this.receiptsDir, `${receiptId}.json`));
414
+ if (receipt?.receipt_id === receiptId && receipt.source?.harness === harness && receipt.source?.session_id === runtime.sessionId) return receipt;
415
+ }
416
+ return null;
417
+ }
418
+
419
+ /**
420
+ * The live-runtime receipt named `receiptId`, read from its own file (`<live-runtimes>/<receiptId>.json`) and taken
421
+ * into the map: a runtime whose receipt the watch has not delivered yet (its event late or lost) is read by name, never
422
+ * waited on or listed for.
423
+ */
424
+ receiptNamed(receiptId) {
425
+ if (typeof receiptId !== 'string' || !/^[0-9a-f]+$/i.test(receiptId)) return null;
426
+ const receipt = readReceiptFile(join(this.receiptsDir, `${receiptId}.json`));
427
+ if (!receipt || receipt.receipt_id !== receiptId || typeof receipt.source?.harness !== 'string' || typeof receipt.source?.session_id !== 'string') return null;
428
+ if (!this.runtimes.has(receiptId)) {
429
+ this.runtimes.set(receiptId, { pid: receipt.pid, harness: receipt.source.harness, sessionId: receipt.source.session_id, runtimeSessionId: typeof receipt.runtime_session_id === 'string' ? receipt.runtime_session_id : null, cwd: typeof receipt.source.workspace === 'string' ? receipt.source.workspace : null });
430
+ this.#changed();
431
+ }
432
+ return receipt;
433
+ }
434
+
402
435
  /** A session the index holds that is not live: mail to it is stored until it is next active, never refused. */
403
436
  isIndexedNotLive(address) {
404
437
  const [, machine, harness, sessionId] = String(address).split(':');
@@ -420,7 +453,7 @@ export class LiveSessions extends EventEmitter {
420
453
  }
421
454
 
422
455
  /** The session a client runs in, from its claim: its parent's current session, else its environment's id through the map. */
423
- #sessionOfClaim({ ppid, session }) {
456
+ #sessionOfClaim({ pid, ppid, session }) {
424
457
  const parent = this.claude.get(Number(ppid));
425
458
  if (parent && alive(parent.pid)) return this.#address('claude-code', parent.sessionId);
426
459
  if (typeof session === 'string' && session) {
@@ -428,6 +461,15 @@ export class LiveSessions extends EventEmitter {
428
461
  if (!found) throw new CallerRefused(`the session this command runs in (${session}) is not one this machine's live sessions have held, so it cannot act for it; nothing was done`);
429
462
  return found;
430
463
  }
464
+ // a runtime supercode hosts, calling from its own process or a child of it (the native agent's mail tool): taken
465
+ // only when exactly one live runtime's receipt names that process. A process several receipts name (harness serve
466
+ // records its own pid on each runtime it hosts with no child of its own) is no one runtime, and is refused.
467
+ for (const candidate of [Number(pid), Number(ppid)]) {
468
+ if (!Number.isInteger(candidate) || candidate <= 0) continue;
469
+ const held = [...this.runtimes.values()].filter((runtime) => runtime.pid === candidate && alive(runtime.pid));
470
+ if (held.length === 1) return this.#address(held[0].harness, held[0].sessionId);
471
+ if (held.length > 1) throw new CallerRefused(`process ${candidate} hosts ${held.length} runtimes, so which one is calling is not known; nothing was done`);
472
+ }
431
473
  return null;
432
474
  }
433
475
 
@@ -458,6 +500,16 @@ export class LiveSessions extends EventEmitter {
458
500
  }
459
501
  }
460
502
 
503
+ /** A live-runtime receipt file, when it is one a delivery may use: its schema, and a loopback HTTP address. */
504
+ function readReceiptFile(path) {
505
+ let receipt = null;
506
+ try { receipt = JSON.parse(readFileSync(path, 'utf8')); } catch { return null; }
507
+ if (receipt?.schema !== 'supercode.live-runtime.v1' || typeof receipt.token !== 'string' || !receipt.token) return null;
508
+ let url = null; try { url = new URL(receipt.base_url); } catch { return null; }
509
+ if (url.protocol !== 'http:' || !['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname) || !alive(receipt.pid)) return null;
510
+ return receipt;
511
+ }
512
+
461
513
  /** The environment variables a client's claim is read from: what names the session or board a process acts for. */
462
514
  export const ACTING_VARIABLES = ['CLAUDE_CODE_SESSION_ID', 'CODEX_THREAD_ID', 'SUPERCODE_CALLER', 'SUPERCODE_BOARD_OWNER_PID'];
463
515
 
@@ -4,7 +4,6 @@
4
4
  // processes through the daemon's `events` door (PublishedEvents), never one stream per process.
5
5
  import { mkdirSync, readFileSync, writeFileSync, renameSync } from 'node:fs';
6
6
  import { join } from 'node:path';
7
- import { spawn } from 'node:child_process';
8
7
  import { createHash, randomBytes } from 'node:crypto';
9
8
  import { fromContext } from '@volter/teams/context';
10
9
  import { retryAfterMs, spentClause } from '@volter/teams/http-client';
@@ -80,14 +79,13 @@ export class MachineEvents {
80
79
  }
81
80
  })().finally(()=>{this.flushing=null;});return this.flushing;
82
81
  }
83
- deliver(item){return new Promise((resolve,reject)=>{
84
- const child=spawn(this.binary,['message','remote-deliver'],{env:this.env,stdio:['pipe','pipe','pipe']});let detail='';
85
- const timer=setTimeout(()=>{child.kill('SIGTERM');reject(new Error('mail delivery timed out; retained for retry'));},30000);
86
- child.stdout.on('data',chunk=>{detail=(detail+chunk).slice(-1000);});child.stderr.on('data',chunk=>{detail=(detail+chunk).slice(-1000);});
87
- child.once('error',error=>{clearTimeout(timer);reject(error);});child.once('close',code=>{clearTimeout(timer);let receipt;try{receipt=JSON.parse(detail.trim().split('\n').at(-1));}catch{}code===0&&receipt?.code===0?resolve():reject(new Error(receipt?.text||detail||`exit ${code}`));});
88
- child.stdin.on('error',()=>{});
89
- child.stdin.end(JSON.stringify({op:'send',from:`sc:${this.machine}:operator:teams-events`,from_name:'Teams machine events',to:item.to,id:item.id,queue:item.to.split(':')[2]==='board',notice:true,body:item.body}));
90
- });}
82
+ /** One event's mail, through this machine's daemon's own send (`send`, set by the connector that runs both): retained
83
+ * for retry when it is not delivered. */
84
+ async deliver(item){
85
+ if(typeof this.send!=='function')throw new Error('this machine\'s daemon has not started; retained for retry');
86
+ const receipt=await this.send({from:`sc:${this.machine}:operator:teams-events`,from_name:'Teams machine events',to:item.to,id:item.id,queue:item.to.split(':')[2]==='board',notice:true,body:item.body});
87
+ if(receipt?.code!==0)throw new Error(receipt?.text||'not delivered');
88
+ }
91
89
  }
92
90
 
93
91
  // What a local reader's cursor names: this connector's stream continuity (its epoch) and a server event sequence. A