@volter/supercode-teams 0.2.93 → 0.2.95
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/upgrade-watch.mjs +26 -0
- package/bin/workspace.mjs +19 -1
- package/connector/index.mjs +7 -0
- package/machine.mjs +13 -6
- package/package.json +1 -1
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// An upgrade replaces the code a long-running connector runs (its native binary, this package) under it. Its readers
|
|
2
|
+
// go on answering from the code they loaded, so every reading it serves is the old version's until something
|
|
3
|
+
// restarts it. This watches the files that code came from and says, once, when one of them changed.
|
|
4
|
+
import { statSync } from 'node:fs';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
|
|
7
|
+
const CODE_WATCH_MS = 60_000;
|
|
8
|
+
|
|
9
|
+
const stamp = (file) => {
|
|
10
|
+
try { const s = statSync(file); return `${s.ino}:${s.size}:${s.mtimeMs}`; } catch { return null; }
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/** Call `onChange(file)` once, the first time one of `files` is replaced; returns a function that stops watching. */
|
|
14
|
+
export function watchCodeChange(files, onChange) {
|
|
15
|
+
const watched = files.filter(Boolean).map((file) => (file instanceof URL ? fileURLToPath(file) : String(file)));
|
|
16
|
+
const first = new Map(watched.map((file) => [file, stamp(file)]));
|
|
17
|
+
const timer = setInterval(() => {
|
|
18
|
+
// A file that is missing for a moment (an installer between removing and writing it) is not yet a change.
|
|
19
|
+
const changed = watched.find((file) => { const now = stamp(file); return now !== null && now !== first.get(file); });
|
|
20
|
+
if (!changed) return;
|
|
21
|
+
clearInterval(timer);
|
|
22
|
+
onChange(changed);
|
|
23
|
+
}, CODE_WATCH_MS);
|
|
24
|
+
timer.unref?.();
|
|
25
|
+
return () => clearInterval(timer);
|
|
26
|
+
}
|
package/bin/workspace.mjs
CHANGED
|
@@ -18,6 +18,7 @@ import { loadOrCreateKeyPair, publicKeyId } from '@volter/teams/identity';
|
|
|
18
18
|
import { duration, parseArgs, positiveInteger, requireArgs, usage } from './workspace/args.mjs';
|
|
19
19
|
import { reserveArtifactDirectory, reserveProtected, writeProtected } from './workspace/files.mjs';
|
|
20
20
|
import { acquireLifecycle, activeSocket, lifecycleHeld, requestStop } from './workspace/lifecycle.mjs';
|
|
21
|
+
import { watchCodeChange } from './workspace/upgrade-watch.mjs';
|
|
21
22
|
import { volterDeviceSignIn } from './workspace/volter-device.mjs';
|
|
22
23
|
import { loginSummary, signInToTeams } from './workspace/volter-login.mjs';
|
|
23
24
|
import { readSharedSignIn, signOut } from '@volter/identity';
|
|
@@ -730,13 +731,30 @@ async function connectorCommand(verb, { flags, rest, output, client, context, te
|
|
|
730
731
|
const liveCore = new SupercodeHarnessClient({ command: binary, args: ['harness', 'serve'] });
|
|
731
732
|
const connector = new TeamsConnector({ server: context.origin, teamId: context.team_id, machineId: saved.machine.id, sourceId: saved.source.id, credential: saved.credential.token, stateDir: state.directory, core, largeCore, slowCore, liveCore, hostKeyId });
|
|
732
733
|
// A connected machine is a person's: each pane it launches opens a window on its screen (SUPERCODE_POPUPS=0: none).
|
|
733
|
-
const machine = new TeamsMachine({ home: daemonHome, supercode: binary, popups: process.env.SUPERCODE_POPUPS !== '0', ...(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) });
|
|
734
|
+
const machine = new TeamsMachine({ home: daemonHome, supercode: binary, popups: process.env.SUPERCODE_POPUPS !== '0', ...(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) });
|
|
734
735
|
machine.on('link', (event) => { if (event.event === 'replaced') process.stderr.write(`supercode teams: ${event.error}. Two connectors were running for this machine; stop one of them (\`supercode teams disconnect\` where it should not run).\n`); });
|
|
735
736
|
// The connector is the service's daemon and keeps serving; a daemon that took its socket path is the one to stop.
|
|
736
737
|
machine.on('superseded', (path) => process.stderr.write(`supercode teams: another machine daemon replaced this connector's socket at ${path}; stop it (supercode teams status)\n`));
|
|
737
738
|
const stopAll = async () => { await machine.stop().catch(() => {}); await connector.stop(); await core.close(); await largeCore.close(); await slowCore.close(); await liveCore.close(); await lifecycle.close(); state.update(current => current ? ({ ...current, process: undefined }) : null); };
|
|
738
739
|
try { await connector.start({ roots: () => saved.roots }); await machine.start(); } catch (error) { await stopAll(); throw error; }
|
|
739
740
|
output({ version: 1, context: scope(context), data: { machine: saved.machine, source: saved.source, roots: saved.roots.map(({ path: _path, ...item }) => item), socket: machine.socketPath, foreground: true } });
|
|
741
|
+
// Run by a service, the connector moves to an upgrade on its own: its readers would otherwise answer from the code
|
|
742
|
+
// they loaded until something restarted it (after 0.5.51 this Mac's went on reading a Codex turn waiting on an
|
|
743
|
+
// approval as busy). On Windows the service runs a per-version copy, so what an upgrade replaces is the
|
|
744
|
+
// `supercode` on PATH, and the move is the install that re-pins the service (it stops this connector and starts
|
|
745
|
+
// the new copy). Elsewhere it stops and exits non-zero, which every service manager here restarts.
|
|
746
|
+
if (saved.service) {
|
|
747
|
+
const shim = process.platform === 'win32' ? (() => { try { return execFileSync('where', ['supercode'], { encoding: 'utf8', windowsHide: true }).split(/\r?\n/).find(Boolean); } catch { return null; } })() : null;
|
|
748
|
+
watchCodeChange([process.env.SUPERCODE_BIN, new URL('../package.json', import.meta.url), shim], (changed) => {
|
|
749
|
+
process.stderr.write(`supercode teams: ${changed} changed under this connector (an upgrade); moving to the new code\n`);
|
|
750
|
+
if (process.platform === 'win32') {
|
|
751
|
+
spawn('supercode', ['teams', 'connect', '--install', '--context', contextName, '--cwd', path], { shell: true, detached: true, stdio: 'ignore', windowsHide: true }).unref();
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
process.exitCode = 75;
|
|
755
|
+
void stopConnector?.();
|
|
756
|
+
});
|
|
757
|
+
}
|
|
740
758
|
await new Promise(resolveStop => { let stopped = false; stopConnector = async () => { if (stopped) return; stopped = true; await stopAll(); resolveStop(); }; for (const signal of ['SIGINT', 'SIGTERM', ...(process.platform === 'win32' ? ['SIGHUP', 'SIGBREAK'] : [])]) process.once(signal, stopConnector); });
|
|
741
759
|
return;
|
|
742
760
|
}
|
package/connector/index.mjs
CHANGED
|
@@ -475,6 +475,13 @@ export class TeamsConnector {
|
|
|
475
475
|
notes[key]=[...(notes[key]??[]).filter(note=>now-note.at<TURN_NOTE_MS),{text:String(text).trim(),at,source}];
|
|
476
476
|
this.save();
|
|
477
477
|
}
|
|
478
|
+
/** A noted turn its session refused: nobody typed that line through this door, so nothing is named after it. */
|
|
479
|
+
withdrawTurn({harness,session,text,at}){
|
|
480
|
+
const key=`${harness}:${session}`,notes=this.state.turn_notes?.[key];if(!notes)return;
|
|
481
|
+
const kept=notes.filter(note=>!(note.at===at&¬e.text===String(text).trim()));
|
|
482
|
+
if(kept.length===notes.length)return;
|
|
483
|
+
if(kept.length)this.state.turn_notes[key]=kept;else delete this.state.turn_notes[key];this.save();
|
|
484
|
+
}
|
|
478
485
|
labelTurns(key,entries,messages){
|
|
479
486
|
const labels={...this.state.turn_labels?.[key]},notes=[...(this.state.turn_notes?.[key]??[])];let found=false;
|
|
480
487
|
for(const note of [...notes]){
|
package/machine.mjs
CHANGED
|
@@ -98,6 +98,8 @@ function trustClaudeFolder(cwd) {
|
|
|
98
98
|
const LAUNCH_SETTLE_MS = 500;
|
|
99
99
|
/** How many typed lines' keys a machine remembers; a server retries a line for minutes, not thousands of lines. */
|
|
100
100
|
const TURN_KEYS_KEPT = 1000;
|
|
101
|
+
/** How long a typed line's door gets to confirm it: a loaded machine's reader types it in seconds and answers late. */
|
|
102
|
+
const TURN_CONFIRM_MS = 60_000;
|
|
101
103
|
|
|
102
104
|
/**
|
|
103
105
|
* What a pane runs for a launch: the program with its arguments, or, when the caller named a whole command line
|
|
@@ -149,11 +151,12 @@ export class TeamsMachine extends MachineCore {
|
|
|
149
151
|
* server link; `configuration()` resolves the machine's current configuration from the server
|
|
150
152
|
* (custodian and machine grants). Without it the daemon serves its local door only.
|
|
151
153
|
*/
|
|
152
|
-
constructor({ home = teamsHome(), supercode = 'supercode', tmuxSocket, floor = null, limits = null, env = process.env, link = null, popups = false, onTurn = null } = {}) {
|
|
154
|
+
constructor({ home = teamsHome(), supercode = 'supercode', tmuxSocket, floor = null, limits = null, env = process.env, link = null, popups = false, onTurn = null, onTurnWithdrawn = null } = {}) {
|
|
153
155
|
super({ home, floor, limits, env, link });
|
|
154
156
|
this.supercodeBin = supercode;
|
|
155
157
|
// Told of each turn a Room member typed here (the connector labels the captured message with who typed it).
|
|
156
158
|
this.onTurn = onTurn;
|
|
159
|
+
this.onTurnWithdrawn = onTurnWithdrawn;
|
|
157
160
|
this.tmuxSocket = tmuxSocket ?? null;
|
|
158
161
|
// Each pane launched here opens a terminal window on this machine's screen (popupWindow).
|
|
159
162
|
this.popups = Boolean(popups);
|
|
@@ -501,22 +504,26 @@ export class TeamsMachine extends MachineCore {
|
|
|
501
504
|
if (this.#turnKeys.size > TURN_KEYS_KEPT) this.#turnKeys.delete(this.#turnKeys.keys().next().value);
|
|
502
505
|
}
|
|
503
506
|
const reply = (value) => { settle?.(value); answer(value); };
|
|
504
|
-
|
|
507
|
+
// Who typed it is noted before it is typed: a capture can read the session's new line before the door confirms
|
|
508
|
+
// it (a loaded machine confirmed a frozen-then-resumed line after the line was captured, and it went up unnamed).
|
|
509
|
+
// A refusal withdraws the note; an unconfirmed turn keeps it, as the line may well be in the session.
|
|
510
|
+
const note = { harness, session, text: request.text, at: Date.now(), source: { via: 'room', from: { principalId: principal ?? null, name: entry.name ?? null } } };
|
|
511
|
+
try { this.onTurn?.(note); } catch { /* naming is best-effort; the turn goes either way */ }
|
|
505
512
|
this.#serveRequest('harness.v1.sessions.message', {
|
|
506
513
|
locator: { harness, session_id: session, storage: { kind: 'file', path: '' } },
|
|
507
514
|
text: request.text,
|
|
508
515
|
from_name: entry.name ?? principal ?? 'a Room member',
|
|
509
516
|
as_user: true,
|
|
510
|
-
},
|
|
517
|
+
}, TURN_CONFIRM_MS).then((result) => {
|
|
511
518
|
const delivered = result?.message_id && !result.refusal;
|
|
512
519
|
this.log.append({ principal, cap: 'interact', target, outcome: delivered ? 'turn' : 'failed', detail: result?.refusal?.message?.slice(0, 200) });
|
|
513
|
-
if (delivered) try { this.
|
|
520
|
+
if (!delivered) try { this.onTurnWithdrawn?.(note); } catch { /* nothing to withdraw */ }
|
|
514
521
|
reply(delivered
|
|
515
522
|
? { code: 0, turn_id: result.message_id, door: result.delivery?.how === 'waiting' ? 'waiting' : (result.delivery?.door ?? 'pane') }
|
|
516
523
|
: { code: 1, text: result?.refusal?.message ?? 'the session did not take the turn' });
|
|
517
524
|
}, (error) => {
|
|
518
|
-
this.log.append({ principal, cap: 'interact', target, outcome: '
|
|
519
|
-
reply({ code: 1, text: error.message });
|
|
525
|
+
this.log.append({ principal, cap: 'interact', target, outcome: 'unconfirmed', detail: error.message.slice(0, 200) });
|
|
526
|
+
reply({ code: 1, text: `the session did not confirm the line (${error.message}); it may have been typed` });
|
|
520
527
|
});
|
|
521
528
|
}
|
|
522
529
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/supercode-teams",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.95",
|
|
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",
|