@volter/supercode-teams 0.3.9 → 0.3.11

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/teams.mjs CHANGED
@@ -33,6 +33,7 @@ import { appendFileSync, createWriteStream, readFileSync, writeFileSync } from '
33
33
  import { spawnSync } from 'node:child_process';
34
34
  import { fileURLToPath, pathToFileURL } from 'node:url';
35
35
  import { SupercodeHarnessClient } from '@volter/supercode-harness-sdk';
36
+ import { noteStep, slowLogPath, timeCommand } from '@volter/supercode-harness-sdk/slow-log';
36
37
 
37
38
  // A Windows scheduled task keeps no output (launchd and systemd name log files), so its unit names one here.
38
39
  if (process.env.SUPERCODE_TEAMS_LOG) {
@@ -49,9 +50,9 @@ import { moveWorkspace } from '../workspace-move.mjs';
49
50
  import { teamsHome, paths, readJson, AccountLog, parseHostPort } from '@volter/teams/home';
50
51
  import { NODE_PROTOCOL } from '@volter/teams/channel';
51
52
 
52
- const MACHINE_VERBS = new Set(['machine', 'describe', 'panes', 'input', 'suspend', 'resume', 'files', 'ports', 'port', 'browser', 'stream', 'jobs', 'workspace', 'title', 'attention', 'watch', 'rpc', 'launches', 'log', 'mail', 'health']);
53
+ const MACHINE_VERBS = new Set(['machine', 'slow', 'describe', 'panes', 'input', 'suspend', 'resume', 'files', 'ports', 'port', 'browser', 'stream', 'jobs', 'workspace', 'title', 'attention', 'watch', 'rpc', 'launches', 'log', 'mail', 'health']);
53
54
  // Every flag a machine verb reads; any other is refused rather than silently ignored.
54
- const KNOWN_FLAGS = new Set(['home', 'machine', 'context', 'floor', 'supercode', 'tmux-socket', 'verbose', 'lines', 'input', 'name', 'for', 'urgency', 'reason', 'interrupt', 'query', 'out', 'in', 'interval', 'no-enter', 'when-composer-empty', 'key', 'listen', 'list', 'from', 'to', 'path', 'to-path', 'repo', 'branch', 'solo', 'card', 'create', 'presence-only', 'clear-draft', 'message-ids', 'json', 'all']);
55
+ const KNOWN_FLAGS = new Set(['home', 'since', 'machine', 'context', 'floor', 'supercode', 'tmux-socket', 'verbose', 'lines', 'input', 'name', 'for', 'urgency', 'reason', 'interrupt', 'query', 'out', 'in', 'interval', 'no-enter', 'when-composer-empty', 'key', 'listen', 'list', 'from', 'to', 'path', 'to-path', 'repo', 'branch', 'solo', 'card', 'create', 'presence-only', 'clear-draft', 'message-ids', 'json', 'all']);
55
56
 
56
57
  const argv = process.argv.slice(2);
57
58
  const flags = {};
@@ -68,8 +69,12 @@ for (let i = 0; i < argv.length; i += 1) {
68
69
  } else positional.push(a);
69
70
  }
70
71
  const [verb, sub, ...rest] = positional;
72
+ // Each command is timed (the slow log, @volter/supercode-harness-sdk/slow-log); a service, a watch or a forward runs
73
+ // until stopped and is timed one door at a time instead.
74
+ const LONG_RUNNING = new Set(['connect', 'watch', 'port', 'stream', 'attach', 'serve']);
75
+ if (!LONG_RUNNING.has(verb) && !(verb === 'machine' && sub === 'start') && !(verb === 'message' && sub === 'watch')) timeCommand(`teams ${[verb, sub].filter(Boolean).join(' ')}`, argv);
71
76
 
72
- const MACHINE_HELP = 'machine verbs: machine start|status, describe, panes, input, suspend, resume, files, ports, port, stream, jobs, workspace, title, attention, watch, rpc, launches, log, health, browser (add --machine NAME for an enrolled machine)';
77
+ const MACHINE_HELP = 'machine verbs: machine start|status|restart, slow [--since 1h], describe, panes, input, suspend, resume, files, ports, port, stream, jobs, workspace, title, attention, watch, rpc, launches, log, health, browser (add --machine NAME for an enrolled machine)';
73
78
 
74
79
  if (verb === undefined || verb === 'help') {
75
80
  process.stdout.write(`supercode teams: ${MACHINE_HELP}\n`);
@@ -109,7 +114,9 @@ function keyBytes(names) {
109
114
  }
110
115
 
111
116
  async function machineChannel() {
112
- return openMachine({ machine: flags.machine ? String(flags.machine) : null, context: flags.context ? String(flags.context) : null, home });
117
+ const started = performance.now();
118
+ try { return await openMachine({ machine: flags.machine ? String(flags.machine) : null, context: flags.context ? String(flags.context) : null, home }); }
119
+ finally { noteStep(flags.machine ? `open machine ${flags.machine}` : 'open the local machine', performance.now() - started); }
113
120
  }
114
121
 
115
122
  // The session (or board) this command acts for: the pane doors refuse a session acting on a pane it did not start.
@@ -176,6 +183,38 @@ async function main() {
176
183
  if (sub === 'restart') return withNode(async (rpc) => out(await rpc.call('machine.restart', { reason: flags.reason ? String(flags.reason) : null })));
177
184
  die('machine start|status|restart');
178
185
  }
186
+ case 'slow': {
187
+ // `slow [--since 1h|30m|<iso>]`: the slow log on this machine, grouped by what was slow (kind and name), the
188
+ // slowest first, each with its count, median and longest time and its usual slowest step. The audit reads this.
189
+ const since = (() => {
190
+ const value = String(flags.since ?? '24h');
191
+ const relative = /^(\d+)([smhd])$/.exec(value);
192
+ if (relative) return Date.now() - Number(relative[1]) * { s: 1e3, m: 6e4, h: 36e5, d: 864e5 }[relative[2]];
193
+ const at = Date.parse(value);
194
+ if (Number.isNaN(at)) die('slow --since takes 30m, 2h, 1d or a date');
195
+ return at;
196
+ })();
197
+ let text = '';
198
+ for (const file of [`${slowLogPath()}.1`, slowLogPath()]) {
199
+ try { text += readFileSync(file, 'utf8'); } catch (error) { if (error.code !== 'ENOENT') throw error; }
200
+ }
201
+ const groups = new Map();
202
+ for (const line of text.split('\n')) {
203
+ let row; try { row = JSON.parse(line); } catch { continue; }
204
+ if (!(Date.parse(row.at) >= since)) continue;
205
+ const key = `${row.kind} ${row.name}`;
206
+ const group = groups.get(key) ?? { kind: row.kind, name: row.name, count: 0, ms: [], steps: new Map(), last: null };
207
+ group.count += 1; group.ms.push(row.ms); group.last = row.at;
208
+ if (row.slowest?.step) group.steps.set(row.slowest.step, (group.steps.get(row.slowest.step) ?? 0) + 1);
209
+ groups.set(key, group);
210
+ }
211
+ out([...groups.values()].map((group) => {
212
+ const sorted = group.ms.sort((a, b) => a - b);
213
+ const step = [...group.steps].sort((a, b) => b[1] - a[1])[0];
214
+ return { kind: group.kind, name: group.name, count: group.count, median_ms: sorted[Math.floor(sorted.length / 2)], max_ms: sorted.at(-1), usual_slowest_step: step ? step[0] : null, last: group.last };
215
+ }).sort((a, b) => b.max_ms * b.count - a.max_ms * a.count));
216
+ return;
217
+ }
179
218
  case 'describe':
180
219
  return withNode(async (rpc) => out(await rpc.call('describe')));
181
220
  case 'panes': {
package/client.mjs CHANGED
@@ -8,6 +8,7 @@ import { EventEmitter } from 'node:events';
8
8
  import { Channel } from '@volter/teams/channel';
9
9
  import { openLocalMachine, openRemoteMachine, StreamRpc, fromWebPage, harnessTransport } from '@volter/teams/client';
10
10
  import { lineSplitter } from '@volter/teams/lines';
11
+ import { noteStep } from '@volter/supercode-harness-sdk/slow-log';
11
12
 
12
13
  export { openLocalMachine, openRemoteMachine, StreamRpc, fromWebPage, harnessTransport };
13
14
 
@@ -50,8 +51,17 @@ export async function execServerBridge(channel, harness, { host = '127.0.0.1' }
50
51
 
51
52
  /** The node's own doors (describe, grants, launch, files, ports, attention, titles) over the `node` stream. */
52
53
  export async function nodeRpc(channel) {
54
+ const opened = performance.now();
53
55
  const stream = await channel.open('node');
54
- return new StreamRpc(stream);
56
+ noteStep('open the node stream', performance.now() - opened);
57
+ const rpc = new StreamRpc(stream);
58
+ // Each door call is a step of the running command (the slow log names the slowest).
59
+ const call = rpc.call.bind(rpc);
60
+ rpc.call = async (method, ...rest) => {
61
+ const started = performance.now();
62
+ try { return await call(method, ...rest); } finally { noteStep(`machine door ${method}`, performance.now() - started); }
63
+ };
64
+ return rpc;
55
65
  }
56
66
 
57
67
  /**
package/machine.mjs CHANGED
@@ -44,6 +44,7 @@ import { TmuxTerminalHost, classifyAttentionSignal, composerHoldsDraft, composer
44
44
  // than failing the load: a POSIX daemon never needs it. Windows needs a supercode-terminal with PtyTerminalHost.
45
45
  import * as terminal from '@volter/supercode-terminal';
46
46
  import { findLiveReceipt, mintFrontendCredential } from '@volter/supercode-harness-sdk/live-runtime';
47
+ import { recordSlow } from '@volter/supercode-harness-sdk/slow-log';
47
48
  import { lineSplitter } from '@volter/teams/lines';
48
49
  import { NODE_PROTOCOL } from '@volter/teams/channel';
49
50
  import { holds, observesPanes } from '@volter/teams/admission';
@@ -235,7 +236,8 @@ export class TeamsMachine extends MachineCore {
235
236
  }, 1000);
236
237
  this.terminalMailTimer.unref?.();
237
238
  // What actually runs under each hosted pane, for the trace pack (process-tree.mjs): one read every five seconds.
238
- this.processTree = new ProcessTree({ root: join(this.home, 'processes'), panes: async () => (await this.attentionEvidence()).map(({ session }) => ({ pane: session.contextKey, rootPid: session.rootPid })) });
239
+ // It needs each pane's root pid only: the pane listing, not attention's screen capture of every pane.
240
+ this.processTree = new ProcessTree({ root: join(this.home, 'processes'), panes: async () => (await this.host.listSessions()).map((session) => ({ pane: session.contextKey, rootPid: session.rootPid })) });
239
241
  this.processTimer = setInterval(() => {
240
242
  if (this.processWork) return;
241
243
  this.processWork = this.processTree.sample().catch(error => this.emit('mail-watch-stderr', `process tree: ${error.message}`)).finally(() => { this.processWork = null; });
@@ -519,10 +521,12 @@ export class TeamsMachine extends MachineCore {
519
521
  async #checkEnds() {
520
522
  const pending = this.#pendingNotices();
521
523
  if (!pending.length) { clearInterval(this.endWatch); this.endWatch = null; return; }
524
+ // One pane listing for every pending notice, not one per notice.
525
+ const panes = await this.host.listSessions();
522
526
  for (const ask of pending) {
523
527
  // A pane has ended when it is gone, or when its program has exited and the host keeps it for its last screen
524
528
  // (a ConPTY pane whose program failed).
525
- const session = await this.#paneByContext(ask.pane);
529
+ const session = panes.find((s) => s.contextKey === ask.pane) ?? null;
526
530
  if (session && !session.dead) continue;
527
531
  appendFileSync(this.#noticesPath(), JSON.stringify({ t: Date.now(), pane: ask.pane, told: true }) + '\n', { mode: 0o600 });
528
532
  const exit = session?.dead ? deadPaneExit(session) : paneExit(ask.pane);
@@ -959,7 +963,9 @@ export class TeamsMachine extends MachineCore {
959
963
  const evidence = {
960
964
  liveness: own?.liveness ?? livenessOf(session.activeCommand),
961
965
  signal: classifyAttentionSignal(capture) ?? own?.signal ?? null,
962
- idleSeconds: this.#idleSeconds(session.id, capture, now),
966
+ // tmux keeps when each pane last printed, so idle time advances between queries; a host that does not say
967
+ // (the Windows pane server) falls back to the screen's own plateau since this daemon last looked.
968
+ idleSeconds: Number.isFinite(session.lastOutputAt) ? Math.max(0, Math.round((now - session.lastOutputAt) / 1000)) : this.#idleSeconds(session.id, capture, now),
963
969
  requested,
964
970
  ...(own ? { livenessSource: 'cooperative' } : {}),
965
971
  };
@@ -1044,9 +1050,15 @@ export class TeamsMachine extends MachineCore {
1044
1050
  }
1045
1051
 
1046
1052
  async #drainTerminalMail() {
1047
- for (const saved of retainedTerminalMail(this.terminalMailRoot)) {
1053
+ // One pane listing per tick, shared by every mailbox: a listing per mailbox each second was most of this
1054
+ // daemon's tmux spawns (8 retained mailboxes, about 70 tmux processes a second, t_23e47b73).
1055
+ const retained = retainedTerminalMail(this.terminalMailRoot);
1056
+ if (!retained.length && !this.terminalMailboxes.size) return;
1057
+ const panes = await this.host.listSessions();
1058
+ const paneByContext = (contextKey) => panes.find((s) => s.contextKey === contextKey) ?? null;
1059
+ for (const saved of retained) {
1048
1060
  if (this.terminalMailboxes.has(saved.recipient) || !saved.binding) continue;
1049
- const session = await this.#paneByContext(saved.binding.context);
1061
+ const session = paneByContext(saved.binding.context);
1050
1062
  if (!session || session.id !== saved.binding.terminal || session.rootPid !== saved.binding.pid) continue;
1051
1063
  const queue = new TerminalMailbox(this.terminalMailRoot, saved.recipient, receipt => this.log.append({ principal: null, cap: 'control',
1052
1064
  target: `pane:${saved.binding.context}`, outcome: receipt.delivered ? 'input-delivered' : 'input-pending', detail: JSON.stringify(receipt) }), (locator, options) => this.#serveRequest('harness.v1.sessions.load', { locator, options: { ...options, include_subagents: false } }, 10000));
@@ -1057,7 +1069,7 @@ export class TeamsMachine extends MachineCore {
1057
1069
  // exists. Its mail stays retained: the session's next pane takes it up.
1058
1070
  for (const [recipient, queue] of this.terminalMailboxes) {
1059
1071
  const binding = queue.state.binding;
1060
- const live = binding ? await this.#paneByContext(binding.context) : null;
1072
+ const live = binding ? paneByContext(binding.context) : null;
1061
1073
  if (!live || live.id !== binding.terminal) this.terminalMailboxes.delete(recipient);
1062
1074
  }
1063
1075
  await Promise.all([...this.terminalMailboxes.values()].filter(queue => !this.inputRepairs[queue.state.binding?.context] && !this.inputInFlight.has(queue.session?.id)).map(queue => queue.kick()));
@@ -1497,8 +1509,10 @@ export class TeamsMachine extends MachineCore {
1497
1509
  const result = await withTimeout(this.#dispatch(entry, request.method, request.params ?? {}), bound, request.method);
1498
1510
  reply({ id: request.id, result });
1499
1511
  this.emit('door', { method: request.method, ms: Date.now() - startedAt, outcome: 'ok' });
1512
+ recordSlow({ kind: 'door', name: request.method, ms: Date.now() - startedAt, slowest: null, extra: { principal: entry.principal ?? null } });
1500
1513
  } catch (error) {
1501
1514
  this.emit('door', { method: request.method, ms: Date.now() - startedAt, outcome: error.code ?? 'error', detail: error.message });
1515
+ recordSlow({ kind: 'door', name: request.method, ms: Date.now() - startedAt, outcome: String(error.code ?? 'error'), slowest: null, extra: { principal: entry.principal ?? null } });
1502
1516
  reply({ id: request.id, error: { code: error.code ?? -1, message: error.message } });
1503
1517
  }
1504
1518
  }
@@ -1864,9 +1878,15 @@ export class TeamsMachine extends MachineCore {
1864
1878
  // Everything this daemon launched, by key, with whether the pane still lives: how an
1865
1879
  // operator tells its own launches from a person's panes without reading labels.
1866
1880
  if (!operator) throw Object.assign(new Error('operators only'), { code: 403 });
1881
+ // One pane listing for the whole journal: one per launch (thousands of them) ran past the door's 30 s bound,
1882
+ // the slowest door the slow log named on yuerans-macbook-pro (t_23e47b73).
1883
+ // The journal first, then the panes: a launch that becomes ready while the panes are listed is in a listing
1884
+ // taken after it, so it never reads as not live.
1867
1885
  const rows = [];
1868
- for (const found of this.launches.all()) {
1869
- const session = found.pane ? await this.#paneByContext(found.pane) : null;
1886
+ const launched = this.launches.all();
1887
+ const panes = await this.host.listSessions();
1888
+ for (const found of launched) {
1889
+ const session = found.pane ? panes.find((s) => s.contextKey === found.pane) ?? null : null;
1870
1890
  rows.push({ key: found.key, pane: found.pane ?? null, kind: found.kind ?? null, by: found.by ?? null, status: found.status ?? null, at: found.t, live: Boolean(session && !session.dead), ...initialInputFacts(found) });
1871
1891
  }
1872
1892
  return rows;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-teams",
3
- "version": "0.3.9",
3
+ "version": "0.3.11",
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",
@@ -66,7 +66,7 @@
66
66
  "@volter/supercode-health": "^0.1.0",
67
67
  "@volter/supercode-terminal": "^0.2.54",
68
68
  "@volter/supercode-trace": "^0.1.0",
69
- "@volter/teams": "0.2.6",
69
+ "@volter/teams": "0.2.7",
70
70
  "@xterm/addon-serialize": "^0.14.0",
71
71
  "@xterm/headless": "^6.0.0",
72
72
  "protobufjs": "8.8.0",