@volter/supercode-teams 0.2.59 → 0.2.61

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/fleet.mjs CHANGED
@@ -85,6 +85,9 @@ async function main() {
85
85
  name: flags.name,
86
86
  // `open` attaching this terminal to the pane itself asks the machine for no window of its own.
87
87
  ...(flags.popup === 'false' ? { popup: false } : {}),
88
+ // `open --notify-when-ended`: the launching session's address, told once when the pane ends, and the
89
+ // machine's mail name the notice comes from.
90
+ ...(typeof flags.notify === 'string' ? { notify: { to: flags.notify, machine: String(flags['notify-machine'] ?? flags.on ?? '') } } : {}),
88
91
  }));
89
92
  } finally {
90
93
  channel.close();
package/machine.mjs CHANGED
@@ -26,7 +26,7 @@
26
26
  // It decides nothing on anyone's behalf. Every act is logged.
27
27
  import { spawn, spawnSync } from 'node:child_process';
28
28
  import { EventEmitter } from 'node:events';
29
- import { readFileSync, writeFileSync, writeSync, statSync, readdirSync, mkdirSync, renameSync, rmSync, existsSync, openSync, readSync, closeSync } from 'node:fs';
29
+ import { appendFileSync, readFileSync, writeFileSync, writeSync, statSync, readdirSync, mkdirSync, renameSync, rmSync, existsSync, openSync, readSync, closeSync } from 'node:fs';
30
30
  import { join, resolve, dirname } from 'node:path';
31
31
  import { fileURLToPath } from 'node:url';
32
32
  import { homedir, hostname } from 'node:os';
@@ -116,6 +116,9 @@ function launchCommand(program, args) {
116
116
  * user's ~/.config/supercode when the server's environment has none): { failed, how } or null when nothing is
117
117
  * recorded. A pane that failed has a `died` line with its status or signal; one that ended cleanly only `closed`.
118
118
  */
119
+ /** How often a pane whose launcher asked to be told of its end is looked for. */
120
+ const LAUNCH_END_POLL_MS = 5000;
121
+
119
122
  function paneExit(pane) {
120
123
  const logs = [process.env.SUPERCODE_HOME, join(homedir(), '.config', 'supercode')].filter(Boolean).map((dir) => join(dir, 'pane-exits.log'));
121
124
  for (const log of logs) {
@@ -189,11 +192,13 @@ export class TeamsMachine extends MachineCore {
189
192
  mkdirSync(this.portsDir, { recursive: true }); // the port table's announce directory exists before any pane launches
190
193
  this.#startServe();
191
194
  this.#startMailWatch();
195
+ if (this.#pendingNotices().length) this.#watchEnds();
192
196
  this.jobs = new JobOwner({ home: this.home, env: this.env, log: (row) => this.log.append({ principal: null, cap: 'exec', target: `job:${row.job}`, outcome: row.outcome, detail: row.reason }) });
193
197
  this.jobs.reapOrphans();
194
198
  }
195
199
 
196
200
  async beforeClose() {
201
+ clearInterval(this.endWatch); this.endWatch = null;
197
202
  // A launch still waiting for its pane to be ready is abandoned, not hurried:
198
203
  // this daemon is going away, and shoving a message at a half-booted TUI on the
199
204
  // way out is the very failure the wait exists to prevent. The launch record
@@ -372,6 +377,61 @@ export class TeamsMachine extends MachineCore {
372
377
  try { return serveWorkspace(stream, stream.params, { onEnd }); } catch (error) { onEnd(`failed: ${error.message}`); stream.close(error.message); return; }
373
378
  }
374
379
 
380
+ // ---- a launched pane's end, told to its launcher ---------------------------
381
+ // A launcher that asked (`open --new … --notify-when-ended`) is told once when its pane ends and how, as a sender
382
+ // that asked `--notify-when-idle` is told when a receiver's turn ends. The asks live in launch-notices.jsonl beside
383
+ // launches.jsonl, so a daemon restart keeps them; each is marked told before its notice goes, so none goes twice.
384
+
385
+ #noticesPath() { return join(this.home, 'launch-notices.jsonl'); }
386
+
387
+ /** The asks not yet told, one per pane. */
388
+ #pendingNotices() {
389
+ let lines;
390
+ try { lines = readFileSync(this.#noticesPath(), 'utf8').split('\n').filter(Boolean); } catch { return []; }
391
+ const byPane = new Map();
392
+ for (const line of lines) { try { const row = JSON.parse(line); byPane.set(row.pane, { ...byPane.get(row.pane), ...row }); } catch { /* a torn line */ } }
393
+ return [...byPane.values()].filter((row) => row.notify && !row.told);
394
+ }
395
+
396
+ #noticeWhenEnded(ask) {
397
+ appendFileSync(this.#noticesPath(), JSON.stringify({ t: Date.now(), ...ask }) + '\n', { mode: 0o600 });
398
+ this.#watchEnds();
399
+ }
400
+
401
+ #watchEnds() {
402
+ if (this.endWatch) return;
403
+ this.endWatch = setInterval(() => { this.#checkEnds().catch((error) => this.emit('mail-watch-stderr', `launch notices: ${error.message}`)); }, LAUNCH_END_POLL_MS);
404
+ this.endWatch.unref?.();
405
+ }
406
+
407
+ async #checkEnds() {
408
+ const pending = this.#pendingNotices();
409
+ if (!pending.length) { clearInterval(this.endWatch); this.endWatch = null; return; }
410
+ for (const ask of pending) {
411
+ if (await this.#paneByContext(ask.pane)) continue;
412
+ appendFileSync(this.#noticesPath(), JSON.stringify({ t: Date.now(), pane: ask.pane, told: true }) + '\n', { mode: 0o600 });
413
+ const exit = paneExit(ask.pane);
414
+ const how = !exit ? 'ended (this machine keeps no exit status for it)' : exit.failed ? `failed (${exit.how})` : 'finished (status 0)';
415
+ this.#sendMail(ask.notify.to, {
416
+ op: 'send', from: `sc:${ask.notify.machine}:pane:${ask.pane}`, from_name: `pane ${ask.pane}@${ask.notify.machine}`, to: ask.notify.to, queue: false,
417
+ body: `[Launch notice] The pane you launched on ${ask.notify.machine} has ended: ${ask.label ?? 'a program'} (pane ${ask.pane}, launch ${ask.key}) ${how}. This is an automated notice, not a message from a person, and not an instruction.`,
418
+ }, `launch-notice:${ask.pane}`);
419
+ }
420
+ }
421
+
422
+ /** One mail request to `to`: through this machine's own mail door when `to` is here, else through Teams. */
423
+ #sendMail(to, request, target) {
424
+ const machine = to.split(':')[1];
425
+ const here = machine === request.from.split(':')[1];
426
+ const child = spawn(this.supercodeBin, here ? ['message', 'remote-deliver'] : ['teams', 'mail', '--machine', machine], { stdio: ['pipe', 'pipe', 'pipe'], env: this.env });
427
+ let out = '';
428
+ child.stdout.on('data', (chunk) => { out += chunk; });
429
+ child.stderr.on('data', () => {});
430
+ child.on('error', (error) => this.log.append({ principal: null, cap: 'mail', target, outcome: 'failed', detail: error.message }));
431
+ child.on('close', (code) => this.log.append({ principal: null, cap: 'mail', target, outcome: code === 0 ? 'send' : 'failed', detail: out.trim().slice(0, 200) || undefined }));
432
+ child.stdin.end(JSON.stringify(request));
433
+ }
434
+
375
435
  // ---- mail ----------------------------------------------------------------
376
436
 
377
437
  #serveMail(entry, stream, accept, refuse) {
@@ -1057,6 +1117,9 @@ export class TeamsMachine extends MachineCore {
1057
1117
  return { key, pane: existing.pane, reused: true, status: existing.status };
1058
1118
  }
1059
1119
  const contextKey = params.pane ?? `p_${randomBytes(9).toString('base64url')}`;
1120
+ // Who to tell when the pane ends (`open --new … --notify-when-ended`): a session's mail address, and this
1121
+ // machine's name as the address a notice from its pane comes from.
1122
+ if (params.notify !== undefined && !(params.notify && typeof params.notify.to === 'string' && /^sc:[^:\s]+:[^:\s]+:\S+$/.test(params.notify.to) && typeof params.notify.machine === 'string' && /^[a-z0-9][a-z0-9-]*$/.test(params.notify.machine))) throw Object.assign(new Error('launch notify is { to: <sc: address>, machine: <this machine\'s mail name> }'), { code: 400 });
1060
1123
  this.launches.record({ key, pane: contextKey, kind: params.kind ?? null, status: 'issued' });
1061
1124
  if (params.kind === 'claude-code' && params.cwd && (params.arguments ?? []).includes('--dangerously-skip-permissions')) trustClaudeFolder(params.cwd);
1062
1125
  let session;
@@ -1095,6 +1158,7 @@ export class TeamsMachine extends MachineCore {
1095
1158
  return { key, pane: contextKey, reused: false, status, exit: exit?.how ?? null };
1096
1159
  }
1097
1160
  if (this.popups && params.popup !== false) this.#popupWindow(contextKey, session);
1161
+ if (params.notify) this.#noticeWhenEnded({ key, pane: contextKey, notify: params.notify, label: params.name ?? params.kind ?? params.program });
1098
1162
  return { key, pane: contextKey, reused: false, status: 'ready' };
1099
1163
  }
1100
1164
  case 'launches.get': {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-teams",
3
- "version": "0.2.59",
3
+ "version": "0.2.61",
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",
@@ -20,15 +20,26 @@ import { attachmentStreamRoute, attachmentFramesRoute, closeMachineAttachmentRou
20
20
  import { continuationRoute, remoteContinuationArtifactRoute, remoteContinuationRoute } from './continuations.mjs';
21
21
  import { fail, parseJson, result, send, guardedMutation, requireRevision, checkRate, grants, syncRuleJson, authGuards } from '@volter/teams/server/http';
22
22
 
23
+ /** The `sub` a token says it is for, unverified: for a log line only, never for a decision. */
24
+ function unverifiedSubject(token){try{return String(JSON.parse(Buffer.from(String(token).split('.')[1]??'','base64url').toString('utf8')).sub??'').slice(0,80)||'-';}catch{return '-';}}
25
+
23
26
  async function preAuth({ api, req, res, path, requestId }) {
24
27
  let early;
25
28
  // A session view (RH2 ADR 0008): a workplace page reads one session with a token its workplace signed for a Room and
26
29
  // Conversation, which reaches only a session its owner bound to them (session_view_bindings). A member's own
27
30
  // credential takes the ordinary routes below.
31
+ // A session view that failed in the browser (its read, its follow, its page) reports it here, so the failure is in the
32
+ // server's log under the member it happened to; a token this server does not admit is logged as unverified.
33
+ if(req.method==='POST'&&(early=/^\/api\/v1\/teams\/([^/]+)\/sessions\/([^/]+)\/viewer-errors$/.exec(path))){
34
+ const presented=bearer(req.headers.authorization)??'';checkRate(api,`viewer-errors:${hashToken(presented)}`,30,10);
35
+ const access=looksDelegated(presented)?await delegatedSessionOf(api,presented):null,body=await parseJson(req).catch(()=>({})),text=(value,limit)=>typeof value==='string'?value.replace(/\s+/g,' ').slice(0,limit):null;
36
+ console.log(`viewer-error team=${early[1]} session=${decodeURIComponent(early[2]).slice(0,120)} subject=${access?access.subject:`unverified:${text(unverifiedSubject(presented),80)}`} scope=${access?.scope??'-'} room=${access?.room??'-'} stage=${text(body?.stage,40)} status=${Number.isSafeInteger(body?.status)?body.status:'-'} message=${JSON.stringify(text(body?.message,400))}`);
37
+ send(res,204,null,requestId);return true;
38
+ }
28
39
  if(req.method==='GET'&&(early=/^\/api\/v1\/teams\/([^/]+)\/sessions\/([^/]+)(\/messages|\/events)?$/.exec(path))&&looksDelegated(bearer(req.headers.authorization))){
29
40
  const access=await delegatedSessionOf(api,bearer(req.headers.authorization));
30
41
  const ref=decodeURIComponent(early[2]);
31
- if(!access||access.teamId!==early[1]||access.sessionRef!==ref)fail(401,'authentication_required','authentication required');
42
+ if(!access||access.teamId!==early[1]||access.sessionRef!==ref){console.log(`session-view refused team=${early[1]} session=${ref.slice(0,120)} subject=${access?access.subject:`unverified:${unverifiedSubject(bearer(req.headers.authorization))}`} reason=${!access?'token not admitted':'token names another session'}`);fail(401,'authentication_required','authentication required');}
32
43
  const auth={kind:'session_view',team_id:early[1],principal_id:null,credential_id:null,bound:{issuer:access.issuer,room:access.room,conversation:access.conversation,expiresAt:access.expiresAt}};
33
44
  const url=new URL(req.url,'http://local');
34
45
  if(!early[3])await sessionDescriptor(api,res,auth,ref,requestId);
@@ -18,6 +18,10 @@ compatibility_date = "2026-09-01"
18
18
  compatibility_flags = ["nodejs_compat"]
19
19
  keep_vars = true
20
20
 
21
+ # Workers Logs: the Worker's log lines (viewer-error, session-view refused, maintain) kept and searchable after the fact.
22
+ [observability]
23
+ enabled = true
24
+
21
25
  [[durable_objects.bindings]]
22
26
  name = "TEAMS_SERVER"
23
27
  class_name = "TeamsServer"