@volter/supercode-teams 0.3.124 → 0.3.126

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
@@ -31,6 +31,7 @@
31
31
  // CLI in ./workspace.mjs.
32
32
  import { appendFileSync, createWriteStream, readFileSync, writeFileSync } from 'node:fs';
33
33
  import { spawnSync } from 'node:child_process';
34
+ import { resolve } from 'node:path';
34
35
  import { fileURLToPath, pathToFileURL } from 'node:url';
35
36
  import { SupercodeHarnessClient } from '@volter/supercode-harness-sdk';
36
37
  import { noteStep, rotatedLogs, slowLogPath, timeCommand } from '@volter/supercode-harness-sdk/slow-log';
@@ -54,7 +55,7 @@ import { APP_DOOR_PATH, appDoorFile, appDoorLink, normalOrigin, readAppDoor, rea
54
55
 
55
56
  const MACHINE_VERBS = new Set(['machine', 'slow', 'describe', 'records', 'panes', 'input', 'suspend', 'resume', 'files', 'ports', 'port', 'browser', 'stream', 'jobs', 'workspace', 'title', 'attention', 'watch', 'rpc', 'launches', 'log', 'mail', 'board', 'health', 'app-door']);
56
57
  // Every flag a machine verb reads; any other is refused rather than silently ignored.
57
- 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', 'processes', 'panes', 'clear-draft', 'message-ids', 'harness', 'observe', 'attach-context', 'json', 'all', 'origin', 'port', 'remove-origin', 'open']);
58
+ 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', 'processes', 'panes', 'clear-draft', 'message-ids', 'harness', 'observe', 'attach-context', 'json', 'all', 'origin', 'port', 'remove-origin', 'open', 'foreign']);
58
59
 
59
60
  const argv = process.argv.slice(2);
60
61
  const flags = {};
@@ -195,7 +196,15 @@ async function main() {
195
196
  }
196
197
  // `machine restart [--machine M] [--reason text]`: the daemon (this one, or M's through the server) restarts under its service.
197
198
  if (sub === 'restart') return withNode(async (rpc) => out(await rpc.call('machine.restart', { reason: flags.reason ? String(flags.reason) : null })));
198
- die('machine start|status|restart');
199
+ // `machine boards [list]` and `machine boards unregister <root>`: the board homes this machine's connector follows
200
+ // and publishes, and one no longer followed (a proof's scratch home: t_f7b219b8)
201
+ if (sub === 'boards') {
202
+ const [action = 'list', root] = rest;
203
+ if (action === 'list') return withNode(async (rpc) => out(await rpc.call('harness.v1.boards.list', {})));
204
+ if (action === 'unregister' && root) return withNode(async (rpc) => out(await rpc.call('harness.v1.boards.unregister', { root: resolve(root) })));
205
+ die('machine boards [list] | machine boards unregister <root>');
206
+ }
207
+ die('machine start|status|restart|boards');
199
208
  }
200
209
  case 'slow': {
201
210
  // `slow [--since 1h|30m|<iso>]`: the slow log on this machine, grouped by what was slow (kind and name), the
@@ -238,7 +247,13 @@ async function main() {
238
247
  flags.processes ? 'panes.processes' : flags['presence-only'] ? 'panes.present' : 'panes.list',
239
248
  flags['presence-only'] && typeof flags.panes === 'string' ? { panes: flags.panes.split(',').filter(Boolean) } : {})));
240
249
  if (sub === 'capture') return withNode(async (rpc) => out((await rpc.call('panes.capture', { pane: rest[0], lines: Number(flags.lines ?? 200) })).text));
241
- if (sub === 'close') return withNode(async (rpc) => out(await rpc.call('panes.close', { pane: rest[0] })));
250
+ // One pane, named: `--foreign` closes a pane another session started, and is said for that pane alone.
251
+ if (sub === 'close') {
252
+ // the flag parser takes `--foreign p_…` as the flag's value: that pane is the one named
253
+ const named = typeof flags.foreign === 'string' ? [flags.foreign, ...rest] : rest;
254
+ if (named.length !== 1) die('panes close <pane> [--foreign]: one pane per close');
255
+ return withNode(async (rpc) => out(await rpc.call('panes.close', { pane: named[0], foreign: flags.foreign !== undefined, ...(await acting()) })));
256
+ }
242
257
  // `window <pane> [machine]`: a terminal window on this machine's screen showing the pane, this machine's or, named,
243
258
  // another machine's (attached through Teams, in that machine's Teams context `--attach-context`); `--observe` shows it
244
259
  // read-only.
package/bin/workspace.mjs CHANGED
@@ -1477,7 +1477,9 @@ function stampStderr() {
1477
1477
  const decoder = new StringDecoder('utf8');
1478
1478
  let atStart = true;
1479
1479
  process.stderr.write = (chunk, ...rest) => {
1480
- const text = typeof chunk === 'string' ? chunk : decoder.write(Buffer.from(chunk));
1480
+ // a string written while a Buffer's split character is pending ends that character first (as U+FFFD, the bytes
1481
+ // it had), so the bytes are never held back to land after the string
1482
+ const text = typeof chunk === 'string' ? decoder.end() + chunk : decoder.write(Buffer.from(chunk));
1481
1483
  // an empty write (or a Buffer holding only part of a character) changes no line
1482
1484
  if (!text) return write(text, ...rest);
1483
1485
  const stamp = new Date().toISOString();
@@ -1497,7 +1499,10 @@ async function releaseSkills() {
1497
1499
  const release = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', '..');
1498
1500
  try {
1499
1501
  if (JSON.parse(readFileSync(join(release, 'package.json'), 'utf8')).name !== '@volter/supercode') return [];
1500
- const { installSkills } = await import(pathToFileURL(join(release, 'lib', 'skills.mjs')).href);
1502
+ const { installSkills, notThisHomesOwner } = await import(pathToFileURL(join(release, 'lib', 'skills.mjs')).href);
1503
+ // only as the home's owner (a `sudo -E` connect would leave root-owned skill folders in the user's harness homes)
1504
+ const refused = notThisHomesOwner?.();
1505
+ if (refused) return [`skills not installed (${refused}); run \`supercode skills install\` as the home's owner`];
1501
1506
  return installSkills();
1502
1507
  } catch { return []; }
1503
1508
  }
package/board-events.mjs CHANGED
@@ -5,8 +5,8 @@
5
5
  // Each board home's cursor is kept beside the connector, so a restart resumes where it left off; a published event's id
6
6
  // is derived from its server, team, source and publication, so a follower that starts over republishes the same ids.
7
7
  import { spawn } from 'node:child_process';
8
- import { mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from 'node:fs';
9
- import { join } from 'node:path';
8
+ import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from 'node:fs';
9
+ import { join, resolve } from 'node:path';
10
10
  import { createHash } from 'node:crypto';
11
11
  import { createInterface } from 'node:readline';
12
12
  import { TeamsClient, retryAfterMs, spentClause } from '@volter/teams/http-client';
@@ -91,6 +91,23 @@ export class BoardEvents {
91
91
  }
92
92
  return { root, registered: true, refused: this.refused };
93
93
  }
94
+ /**
95
+ * A home no longer followed: its follower ended for good and its record dropped, so no restart brings it back
96
+ * (t_f7b219b8: 16 scratch proof homes on aarons-mac-mini, each with its own follower, after every daemon start). By an
97
+ * operator (`supercode teams machine boards unregister <root>`), or here for a home whose folder is gone. Its cards
98
+ * already published stay on the team.
99
+ */
100
+ unregister(path, why = 'unregistered') {
101
+ let root = resolve(String(path));
102
+ try { root = realpathSync(root); } catch { /* gone: named as it was registered */ }
103
+ if (!this.state.boards[root]) return { root, unregistered: false };
104
+ delete this.state.boards[root]; delete this.state.sources[root]; this.save();
105
+ this.waits.delete(root);
106
+ const child = this.followers.get(root);
107
+ if (child) { child.gone = true; end(child); }
108
+ this.log(`board events of ${root}: no longer followed (${why})`);
109
+ return { root, unregistered: true };
110
+ }
94
111
  start() {
95
112
  this.started = true;
96
113
  for (const root of Object.keys(this.state.boards)) this.follow(root);
@@ -101,6 +118,9 @@ export class BoardEvents {
101
118
  follow(root) {
102
119
  if (this.followers.has(root) || this.stopped || this.refused) return;
103
120
  const board = this.state.boards[root];
121
+ if (!board) return; // unregistered while it waited
122
+ // a home whose folder is gone (a proof's scratch home, removed) has no board left to follow: dropped, not retried
123
+ if (!existsSync(root)) { this.unregister(root, 'its folder is gone'); return; }
104
124
  // In a process group of its own (end): the follower is the native binary, the orchestrator it runs and the one that
105
125
  // hands the line to, and ending the first alone left the others running, holding its output open.
106
126
  const child = spawn(this.binary, ['workflow', 'events', '--root', root, ...(board.cursor ? ['--after', board.cursor] : [])], { env: this.env, stdio: ['ignore', 'pipe', 'pipe'], detached: true });
@@ -148,7 +168,7 @@ export class BoardEvents {
148
168
  child.on('close', async (code) => {
149
169
  await frames;
150
170
  this.followers.delete(root);
151
- if (this.stopped || this.refused) return;
171
+ if (this.stopped || this.refused || child.gone) return;
152
172
  if (child.again) { this.follow(root); return; } // registered again: on the orchestrator the home names now
153
173
  // its last lines, a stack among them when the store's own error ended it (orchestrator.mjs)
154
174
  if (code && !board.error) this.log(`board events of ${root}: workflow events ended (${code}): ${stderr.trim().split('\n').slice(-12).join('\n ')}`);
package/codex-startup.mjs CHANGED
@@ -42,7 +42,16 @@ export function codexStartupArguments(args = []) {
42
42
  }
43
43
  normalized.push(argument);
44
44
  }
45
- return ['-c', 'check_for_update_on_startup=false', ...normalized];
45
+ // Codex 0.160's `resume` does not take this override from before the subcommand: a resumed TUI with it there still
46
+ // stopped on "Update available" once another Codex had cached a newer version, and `codex resume <id> -c …` did not
47
+ // (yuerans, 2026-10-06). It goes after the subcommand and its session id (readers of the process take the word after
48
+ // `resume` as the id: codex_peer.rs `resumed_session_in`), in front when there is no subcommand. `fork` is placed the
49
+ // same way by extension; only `resume` was measured.
50
+ const subcommand = normalized.findIndex((argument, index) => ['resume', 'fork'].includes(argument)
51
+ && !['-c', '--config'].includes(normalized[index - 1]) && !normalized.slice(0, index).includes('--'));
52
+ const id = subcommand >= 0 && normalized[subcommand + 1] !== undefined && normalized[subcommand + 1] !== '--' && !normalized[subcommand + 1].startsWith('-');
53
+ const at = subcommand < 0 ? 0 : subcommand + (id ? 2 : 1);
54
+ return [...normalized.slice(0, at), '-c', 'check_for_update_on_startup=false', ...normalized.slice(at)];
46
55
  }
47
56
 
48
57
  export function codexUpdatePrompt(screen) {
package/machine.mjs CHANGED
@@ -2079,16 +2079,22 @@ export class TeamsMachine extends MachineCore {
2079
2079
  }
2080
2080
 
2081
2081
  #refuseForeign(pane, caller) {
2082
- if (typeof caller !== 'string' || !caller || /^sc:[^:]+:board:/.test(caller)) return;
2082
+ const starter = this.#foreignStarter(pane, caller);
2083
+ if (starter) throw Object.assign(new Error(`pane ${pane} ${starter}, not by ${caller}: a session acts only on the panes it started. Nothing was done; reach that session through its own doors (supercode message send)`), { code: 403 });
2084
+ }
2085
+
2086
+ /** Who started `pane`, said, when `caller` is a session that did not start it; null when the caller may act on it (a
2087
+ * person, a board, its starter). */
2088
+ #foreignStarter(pane, caller) {
2089
+ if (typeof caller !== 'string' || !caller || /^sc:[^:]+:board:/.test(caller)) return null;
2083
2090
  const launch = this.launches.all().find((entry) => entry.pane === pane);
2084
2091
  const by = launch?.by ?? null;
2085
- if (by === caller) return;
2092
+ if (by === caller) return null;
2086
2093
  // A pane that resumes a session is that session's as well: its first starter acts on it as before the resume
2087
2094
  // (t_09a795fd item 18). The starter is the oldest harness launch whose own transcript confirmed it started that
2088
2095
  // session, never a launch row's claim (any caller can launch a program with --session-id S).
2089
- if (launch?.resumes && this.#sessionOrigin(launch.resumes)?.by === caller) return;
2090
- const starter = by ? `was started by ${by}` : 'was not started by a session (a person or an earlier daemon started it)';
2091
- throw Object.assign(new Error(`pane ${pane} ${starter}, not by ${caller}: a session acts only on the panes it started. Nothing was done; reach that session through its own doors (supercode message send)`), { code: 403 });
2096
+ if (launch?.resumes && this.#sessionOrigin(launch.resumes)?.by === caller) return null;
2097
+ return by ? `was started by ${by}` : 'was not started by a session (a person or an earlier daemon started it)';
2092
2098
  }
2093
2099
 
2094
2100
  /** The launch that started native Claude session `session`: the oldest journal row of a Claude launch whose own
@@ -3307,7 +3313,7 @@ export class TeamsMachine extends MachineCore {
3307
3313
  }
3308
3314
  const contextKey = params.pane ?? `p_${randomBytes(9).toString('base64url')}`;
3309
3315
  // Who started the pane: the launching session's (or board's) address, null for a person. A session's later
3310
- // input on the pane is refused unless it is this one (#refuseForeign); close is not creator-restricted.
3316
+ // input on the pane is refused unless it is this one (#refuseForeign); its close needs `foreign` (panes.close).
3311
3317
  // Who to tell when the pane ends (`open --new … --notify-when-ended`): a session's mail address, and this
3312
3318
  // machine's name as the address a notice from its pane comes from.
3313
3319
  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 });
@@ -3459,14 +3465,28 @@ export class TeamsMachine extends MachineCore {
3459
3465
  case 'harness.v1.panes.close':
3460
3466
  case 'panes.close': {
3461
3467
  need('control', `pane:${params.pane}`);
3462
- for (const pending of this.pendingStartups.values()) if (pending.pane === params.pane) pending.abort.abort();
3468
+ // A session closes the panes it started and the one it runs in. Another's pane is closed only when the caller
3469
+ // says so (`foreign`), one pane at a time, by name: a listing piped into close is refused pane by pane rather
3470
+ // than ending every session on the machine (docs/adr/0004-pane-close-authority.md).
3471
+ const starting = [...this.pendingStartups.values()].filter((pending) => pending.pane === params.pane);
3472
+ // only a pane here (open, or still starting) is anyone's: one this machine lacks is answered as absent, before
3473
+ // the caller is asked for
3474
+ const here = starting.length > 0 || Boolean(await this.#paneByContext(params.pane));
3475
+ const closeBy = here ? await this.#callerOf(entry, params) : null;
3476
+ const starter = params.foreign === true || !here ? null : this.#foreignStarter(params.pane, closeBy);
3477
+ // the pane the caller runs in: a Claude row names it as its tmux locator (`p_…:@window.%pane`), the others as the pane
3478
+ if (starter && !this.live.sessions().some((live) => live.address === closeBy && String(live.pane ?? '').split(':')[0] === params.pane)) {
3479
+ act('close', `pane:${params.pane}`, 'refused', `foreign: ${starter}`);
3480
+ throw Object.assign(new Error(`pane ${params.pane} ${starter}, not by ${closeBy}. Nothing was closed. A session closes the panes it started; closing another's is deliberate: read what the pane holds, settle it with whoever started it, then close that one pane with --foreign`), { code: 403 });
3481
+ }
3482
+ for (const pending of starting) pending.abort.abort();
3463
3483
  const session = await this.#paneByContext(params.pane);
3464
3484
  if (!session) {
3465
3485
  act('close', `pane:${params.pane}`, 'ok', 'already absent');
3466
3486
  return { closed: params.pane, alreadyAbsent: true };
3467
3487
  }
3468
3488
  await this.host.closeSession(session.id);
3469
- act('close', `pane:${params.pane}`);
3489
+ act('close', `pane:${params.pane}`, 'ok', params.foreign === true ? `foreign, by ${closeBy ?? 'a person'}` : null);
3470
3490
  this.#panesChanged();
3471
3491
  return { closed: params.pane };
3472
3492
  }
@@ -3990,11 +4010,15 @@ export class TeamsMachine extends MachineCore {
3990
4010
  if (entry.principal !== 'local' || entry.appDoor) throw Object.assign(new Error('a caller is resolved only for a process on this machine'), { code: 403 });
3991
4011
  return { caller: await this.#callerOf(entry, params) };
3992
4012
  }
3993
- case 'harness.v1.boards.register': case 'harness.v1.boards.list': {
4013
+ case 'harness.v1.boards.register': case 'harness.v1.boards.list': case 'harness.v1.boards.unregister': {
3994
4014
  // A board home this machine keeps, published to its team by its connector (board-events.mjs).
3995
4015
  if (!operator) throw Object.assign(new Error('operators only'), { code: 403 });
3996
4016
  if (!this.boardEvents) throw Object.assign(new Error('this daemon serves no team: a board is published by a connected machine (`supercode teams connect`)'), { code: 409 });
3997
4017
  if (method === 'harness.v1.boards.list') return this.boardEvents.list();
4018
+ if (method === 'harness.v1.boards.unregister') {
4019
+ if (typeof params.root !== 'string' || !params.root) throw Object.assign(new Error('name the board home (root)'), { code: 400 });
4020
+ return this.boardEvents.unregister(params.root);
4021
+ }
3998
4022
  if (typeof params.root !== 'string' || !params.root) throw Object.assign(new Error('name the board home (root)'), { code: 400 });
3999
4023
  const registered = this.boardEvents.register(params.root);
4000
4024
  // The home's board owner acts as its boards: the caller it names for a board of that home is taken from it
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-teams",
3
- "version": "0.3.124",
3
+ "version": "0.3.126",
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",
@@ -70,7 +70,7 @@
70
70
  "@volter/supercode-health": "^0.1.0",
71
71
  "@volter/supercode-terminal": "^0.2.54",
72
72
  "@volter/supercode-trace": "^0.1.0",
73
- "@volter/teams": "0.2.34",
73
+ "@volter/teams": "0.2.35",
74
74
  "@xterm/addon-serialize": "^0.14.0",
75
75
  "@xterm/headless": "^6.0.0",
76
76
  "protobufjs": "8.8.0",
package/worker/index.mjs CHANGED
@@ -145,7 +145,7 @@ export class TeamsServer extends DurableObject {
145
145
 
146
146
  /** What the Durable Object's runtime said of a failed call (its error's own flags), for the log and the answer. */
147
147
  const objectFailure = (error) => ({
148
- name: String(error?.name ?? 'Error'),
148
+ name: String(error?.name ?? 'Error').slice(0, 100),
149
149
  message: String(error?.message ?? error).slice(0, 200),
150
150
  retryable: error?.retryable === true,
151
151
  overloaded: error?.overloaded === true,
@@ -173,12 +173,16 @@ export default {
173
173
  },
174
174
  };
175
175
 
176
+ /** The error names an unauthenticated caller is told; any other (a library's or the runtime's own) is said as
177
+ * `Error`, and is in the log line only (#1246 review). */
178
+ const PUBLIC_NAMES = new Set(['Error', 'TypeError', 'RangeError', 'ReferenceError', 'SyntaxError', 'AbortError', 'TimeoutError', 'DOMException']);
179
+
176
180
  /** Failures before the HTTP handler (object initialization, migrations, storage, a reset) as our API contract, not a
177
181
  * platform HTML page. The answer goes to callers not yet authenticated, so it names only the error's kind and the
178
182
  * runtime's flags; its message (storage or SQL text, internal paths) is in the log line its request id leads to. */
179
183
  function unavailable(failure, retried) {
180
184
  const requestId = crypto.randomUUID();
181
185
  console.error(JSON.stringify({ event: 'teams_unavailable', request_id: requestId, failure, ...(retried ? { first: retried } : {}) }));
182
- const cause = `${failure.name}${failure.reset ? ', object reset' : ''}${failure.overloaded ? ', overloaded' : ''}${retried ? ', after one retry' : ''}`;
183
- return Response.json({ version: 1, error: { code: 'unavailable', message: `Teams server is temporarily unavailable (${cause})`, request_id: requestId, retryable: true, cause: { name: failure.name, retryable: failure.retryable, overloaded: failure.overloaded, remote: failure.remote, reset: failure.reset } } }, { status: 503, headers: { 'Retry-After': failure.overloaded ? '15' : '5', 'X-Request-ID': requestId } });
186
+ const cause = `${PUBLIC_NAMES.has(failure.name) ? failure.name : 'Error'}${failure.reset ? ', object reset' : ''}${failure.overloaded ? ', overloaded' : ''}${retried ? ', after one retry' : ''}`;
187
+ return Response.json({ version: 1, error: { code: 'unavailable', message: `Teams server is temporarily unavailable (${cause})`, request_id: requestId, retryable: true, cause: { name: PUBLIC_NAMES.has(failure.name) ? failure.name : 'Error', retryable: failure.retryable, overloaded: failure.overloaded, remote: failure.remote, reset: failure.reset } } }, { status: 503, headers: { 'Retry-After': failure.overloaded ? '15' : '5', 'X-Request-ID': requestId } });
184
188
  }