@volter/supercode-teams 0.3.9 → 0.3.10

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; });
@@ -959,7 +961,9 @@ export class TeamsMachine extends MachineCore {
959
961
  const evidence = {
960
962
  liveness: own?.liveness ?? livenessOf(session.activeCommand),
961
963
  signal: classifyAttentionSignal(capture) ?? own?.signal ?? null,
962
- idleSeconds: this.#idleSeconds(session.id, capture, now),
964
+ // tmux keeps when each pane last printed, so idle time advances between queries; a host that does not say
965
+ // (the Windows pane server) falls back to the screen's own plateau since this daemon last looked.
966
+ idleSeconds: Number.isFinite(session.lastOutputAt) ? Math.max(0, Math.round((now - session.lastOutputAt) / 1000)) : this.#idleSeconds(session.id, capture, now),
963
967
  requested,
964
968
  ...(own ? { livenessSource: 'cooperative' } : {}),
965
969
  };
@@ -1044,9 +1048,15 @@ export class TeamsMachine extends MachineCore {
1044
1048
  }
1045
1049
 
1046
1050
  async #drainTerminalMail() {
1047
- for (const saved of retainedTerminalMail(this.terminalMailRoot)) {
1051
+ // One pane listing per tick, shared by every mailbox: a listing per mailbox each second was most of this
1052
+ // daemon's tmux spawns (8 retained mailboxes, about 70 tmux processes a second, t_23e47b73).
1053
+ const retained = retainedTerminalMail(this.terminalMailRoot);
1054
+ if (!retained.length && !this.terminalMailboxes.size) return;
1055
+ const panes = await this.host.listSessions();
1056
+ const paneByContext = (contextKey) => panes.find((s) => s.contextKey === contextKey) ?? null;
1057
+ for (const saved of retained) {
1048
1058
  if (this.terminalMailboxes.has(saved.recipient) || !saved.binding) continue;
1049
- const session = await this.#paneByContext(saved.binding.context);
1059
+ const session = paneByContext(saved.binding.context);
1050
1060
  if (!session || session.id !== saved.binding.terminal || session.rootPid !== saved.binding.pid) continue;
1051
1061
  const queue = new TerminalMailbox(this.terminalMailRoot, saved.recipient, receipt => this.log.append({ principal: null, cap: 'control',
1052
1062
  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 +1067,7 @@ export class TeamsMachine extends MachineCore {
1057
1067
  // exists. Its mail stays retained: the session's next pane takes it up.
1058
1068
  for (const [recipient, queue] of this.terminalMailboxes) {
1059
1069
  const binding = queue.state.binding;
1060
- const live = binding ? await this.#paneByContext(binding.context) : null;
1070
+ const live = binding ? paneByContext(binding.context) : null;
1061
1071
  if (!live || live.id !== binding.terminal) this.terminalMailboxes.delete(recipient);
1062
1072
  }
1063
1073
  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 +1507,10 @@ export class TeamsMachine extends MachineCore {
1497
1507
  const result = await withTimeout(this.#dispatch(entry, request.method, request.params ?? {}), bound, request.method);
1498
1508
  reply({ id: request.id, result });
1499
1509
  this.emit('door', { method: request.method, ms: Date.now() - startedAt, outcome: 'ok' });
1510
+ recordSlow({ kind: 'door', name: request.method, ms: Date.now() - startedAt, slowest: null, extra: { principal: entry.principal ?? null } });
1500
1511
  } catch (error) {
1501
1512
  this.emit('door', { method: request.method, ms: Date.now() - startedAt, outcome: error.code ?? 'error', detail: error.message });
1513
+ recordSlow({ kind: 'door', name: request.method, ms: Date.now() - startedAt, outcome: String(error.code ?? 'error'), slowest: null, extra: { principal: entry.principal ?? null } });
1502
1514
  reply({ id: request.id, error: { code: error.code ?? -1, message: error.message } });
1503
1515
  }
1504
1516
  }
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.10",
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",