@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 +3 -0
- package/machine.mjs +65 -1
- package/package.json +1 -1
- package/server/harness-routes.mjs +12 -1
- package/worker/wrangler.toml +4 -0
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.
|
|
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);
|
package/worker/wrangler.toml
CHANGED
|
@@ -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"
|