@volter/supercode-teams 0.2.99 → 0.2.101

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 CHANGED
@@ -85,6 +85,10 @@ 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
+ // Who starts the pane (the session or board supercode resolved; unset for a person), and whether the pane
89
+ // runs its one verb as that caller (`close`/`stop` on another machine).
90
+ ...(process.env.SUPERCODE_CALLER ? { caller: process.env.SUPERCODE_CALLER } : {}),
91
+ ...(flags['act-for-caller'] ? { actForCaller: true } : {}),
88
92
  // `open --notify-when-ended`: the launching session's address, told once when the pane ends, and the
89
93
  // machine's mail name the notice comes from.
90
94
  ...(typeof flags.notify === 'string' ? { notify: { to: flags.notify, machine: String(flags['notify-machine'] ?? flags.on ?? '') } } : {}),
package/bin/teams.mjs CHANGED
@@ -106,6 +106,10 @@ async function machineChannel() {
106
106
  return openMachine({ machine: flags.machine ? String(flags.machine) : null, context: flags.context ? String(flags.context) : null, home });
107
107
  }
108
108
 
109
+ // The session (or board) this command acts for, as supercode resolved it: the pane doors refuse a session acting on
110
+ // a pane it did not start. Unset for a person.
111
+ const caller = process.env.SUPERCODE_CALLER || undefined;
112
+
109
113
  async function withNode(fn) {
110
114
  const channel = await machineChannel();
111
115
  try {
@@ -149,7 +153,7 @@ async function main() {
149
153
  case 'panes': {
150
154
  if (sub === 'ls') return withNode(async (rpc) => out(await rpc.call('panes.list')));
151
155
  if (sub === 'capture') return withNode(async (rpc) => out((await rpc.call('panes.capture', { pane: rest[0], lines: Number(flags.lines ?? 200) })).text));
152
- if (sub === 'close') return withNode(async (rpc) => out(await rpc.call('panes.close', { pane: rest[0] })));
156
+ if (sub === 'close') return withNode(async (rpc) => out(await rpc.call('panes.close', { pane: rest[0], caller })));
153
157
  die('panes ls|capture|close');
154
158
  }
155
159
  case 'title': {
@@ -173,9 +177,9 @@ async function main() {
173
177
  die('attention ls | rank | request <pane> [--urgency now|soon|whenever] [--reason text] | clear <pane>');
174
178
  }
175
179
  case 'suspend':
176
- return withNode(async (rpc) => out(await rpc.call('panes.suspend', { pane: sub, interrupt: Boolean(flags.interrupt) })));
180
+ return withNode(async (rpc) => out(await rpc.call('panes.suspend', { pane: sub, interrupt: Boolean(flags.interrupt), caller })));
177
181
  case 'resume':
178
- return withNode(async (rpc) => out(await rpc.call('panes.resume', { pane: sub, input: flags.input ? String(flags.input) : null })));
182
+ return withNode(async (rpc) => out(await rpc.call('panes.resume', { pane: sub, input: flags.input ? String(flags.input) : null, caller })));
179
183
  case 'files': {
180
184
  const path = rest[0];
181
185
  if (sub === 'ls') return withNode(async (rpc) => out(await rpc.call('files.list', { path })));
@@ -214,10 +218,10 @@ async function main() {
214
218
  // (answers `delivered: false` with the reason when it holds a draft).
215
219
  if (flags.key !== undefined) {
216
220
  if (flags.key === true || rest.length) die('input <pane> --key NAMES: keys by tmux name, space- or comma-separated (Escape, Enter, Tab, BTab, BSpace, Space, Up, Down, Left, Right, Home, End, PPage, NPage, DC, C-a … C-z)');
217
- return withNode(async (rpc) => out(await rpc.call('panes.input', { pane: sub, text: keyBytes(String(flags.key)), submit: false, keys: true })));
221
+ return withNode(async (rpc) => out(await rpc.call('panes.input', { pane: sub, text: keyBytes(String(flags.key)), submit: false, keys: true, caller })));
218
222
  }
219
- if (flags['when-composer-empty']) return withNode(async (rpc) => out(await rpc.call('panes.turn', { pane: sub, text: rest.join(' ') })));
220
- return withNode(async (rpc) => out(await rpc.call('panes.input', { pane: sub, text: rest.join(' '), submit: !flags['no-enter'] })));
223
+ if (flags['when-composer-empty']) return withNode(async (rpc) => out(await rpc.call('panes.turn', { pane: sub, text: rest.join(' '), caller })));
224
+ return withNode(async (rpc) => out(await rpc.call('panes.input', { pane: sub, text: rest.join(' '), submit: !flags['no-enter'], caller })));
221
225
  case 'port': {
222
226
  // `port <pane>/<n> [--listen host:port]`: a machine port forwarded here. Stays up until ctrl-c.
223
227
  const spec = String(sub ?? '');
package/bin/workspace.mjs CHANGED
@@ -19,6 +19,9 @@ import { duration, parseArgs, positiveInteger, requireArgs, usage } from './work
19
19
  import { reserveArtifactDirectory, reserveProtected, writeProtected } from './workspace/files.mjs';
20
20
  import { acquireLifecycle, activeSocket, lifecycleHeld, requestStop } from './workspace/lifecycle.mjs';
21
21
  import { retireLinklessDaemon, watchCodeChange } from './workspace/upgrade-watch.mjs';
22
+
23
+ /** How long a service-run connector's machine link may stay down before the connector restarts itself. */
24
+ const LINK_LOST_MS = 120_000;
22
25
  import { volterDeviceSignIn } from './workspace/volter-device.mjs';
23
26
  import { loginSummary, signInToTeams } from './workspace/volter-login.mjs';
24
27
  import { readSharedSignIn, signOut } from '@volter/identity';
@@ -733,6 +736,15 @@ async function connectorCommand(verb, { flags, rest, output, client, context, te
733
736
  // A connected machine is a person's: each pane it launches opens a window on its screen (SUPERCODE_POPUPS=0: none).
734
737
  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) });
735
738
  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`); });
739
+ // The link's state for the watch after start: down until it is up, down again (since when) when it drops. A
740
+ // `replaced` link is left alone: another connector for this machine owns it, by design.
741
+ let linkDownSince = Date.now();
742
+ let linkReplaced = false;
743
+ machine.on('link', (event) => {
744
+ if (event.event === 'up') linkDownSince = null;
745
+ else if (event.event === 'dropped') linkDownSince ??= Date.now();
746
+ else if (event.event === 'replaced') linkReplaced = true;
747
+ });
736
748
  // The connector is the service's daemon and keeps serving; a daemon that took its socket path is the one to stop.
737
749
  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`));
738
750
  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); };
@@ -762,6 +774,19 @@ async function connectorCommand(verb, { flags, rest, output, client, context, te
762
774
  if (process.platform !== 'win32') setTimeout(async () => { await stopConnector?.(); process.exit(75); }, 60_000);
763
775
  });
764
776
  }
777
+ // Run by a service, a connector whose machine link stays down restarts itself, so a machine is never left
778
+ // unreachable with no remote way back (the Mac mini sat unlinked for hours while its connector lived on): a
779
+ // daemon that died, a start that half failed or a link its backoff never recovered all end in a fresh start.
780
+ // It exits non-zero, which every service manager here restarts.
781
+ if (saved.service) {
782
+ const linkWatch = setInterval(() => {
783
+ if (linkReplaced || linkDownSince === null || Date.now() - linkDownSince < LINK_LOST_MS) return;
784
+ clearInterval(linkWatch);
785
+ process.stderr.write(`supercode teams: this machine has had no link to its Teams server for ${Math.round((Date.now() - linkDownSince) / 1000)} s; restarting the connector\n`);
786
+ void (async () => { await stopConnector?.(); process.exit(75); })();
787
+ }, 15_000);
788
+ linkWatch.unref?.();
789
+ }
765
790
  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); });
766
791
  return;
767
792
  }
package/machine.mjs CHANGED
@@ -577,6 +577,19 @@ export class TeamsMachine extends MachineCore {
577
577
  return env;
578
578
  }
579
579
 
580
+ /**
581
+ * A session acts only on the panes it started: input, a turn, a suspend or a close from a session (`caller`, its
582
+ * address) on a pane another session, a board or a person started is refused, naming who started it. A person (no
583
+ * caller) and a board (its cards' own door) act on any pane.
584
+ */
585
+ #refuseForeign(pane, caller) {
586
+ if (typeof caller !== 'string' || !caller || /^sc:[^:]+:board:/.test(caller)) return;
587
+ const by = this.launches.all().find((entry) => entry.pane === pane)?.by ?? null;
588
+ if (by === caller) return;
589
+ const starter = by ? `was started by ${by}` : 'was not started by a session (a person or an earlier daemon started it)';
590
+ 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 });
591
+ }
592
+
580
593
  async #paneByContext(contextKey) {
581
594
  const sessions = await this.host.listSessions();
582
595
  return sessions.find((s) => s.contextKey === contextKey) ?? null;
@@ -701,13 +714,13 @@ export class TeamsMachine extends MachineCore {
701
714
  * Never rejects. The only caller is a fire-and-forget from the request path,
702
715
  * where an unhandled rejection would take the daemon down over one pane.
703
716
  */
704
- #deliverInitialInput({ key, contextKey, session, kind, input, act }) {
717
+ #deliverInitialInput({ key, contextKey, session, kind, by, input, act }) {
705
718
  const abort = new AbortController();
706
719
  const task = (async () => {
707
720
  const readyBy = await this.#awaitInputReady(session, contextKey, kind, abort.signal);
708
721
  if (readyBy === 'aborted') return;
709
722
  if (readyBy === 'pane-gone') {
710
- this.launches.record({ key, pane: contextKey, kind, status: 'ready', inputDelivered: false, inputReadyBy: readyBy });
723
+ this.launches.record({ key, pane: contextKey, kind, by, status: 'ready', inputDelivered: false, inputReadyBy: readyBy });
711
724
  act('launch', `pane:${contextKey}`, 'failed', 'first input undelivered: the pane ended before it was ready');
712
725
  return;
713
726
  }
@@ -719,7 +732,7 @@ export class TeamsMachine extends MachineCore {
719
732
  try {
720
733
  await this.host.sendInput(session.id, submission(input), { submitMode: 'single' });
721
734
  } catch (error) {
722
- this.launches.record({ key, pane: contextKey, kind, status: 'ready', inputDelivered: false, inputReadyBy: readyBy, inputError: error.message });
735
+ this.launches.record({ key, pane: contextKey, kind, by, status: 'ready', inputDelivered: false, inputReadyBy: readyBy, inputError: error.message });
723
736
  act('launch', `pane:${contextKey}`, 'failed', `first input undelivered: ${error.code ?? error.message}`);
724
737
  return;
725
738
  }
@@ -728,12 +741,12 @@ export class TeamsMachine extends MachineCore {
728
741
  // vouched for. A launch that reads `ready` without it was given its
729
742
  // message by an input box that existed.
730
743
  const late = readyBy === 'deadline';
731
- this.launches.record({ key, pane: contextKey, kind, status: 'ready', inputDelivered: true, inputReadyBy: readyBy, ...(late ? { inputDeliveredLate: true } : {}) });
744
+ this.launches.record({ key, pane: contextKey, kind, by, status: 'ready', inputDelivered: true, inputReadyBy: readyBy, ...(late ? { inputDeliveredLate: true } : {}) });
732
745
  act('launch', `pane:${contextKey}`, 'ok', `first input delivered (${readyBy})`);
733
746
  })().catch((error) => {
734
747
  // Nothing above is expected to throw; if something does, the launch record
735
748
  // is where it is answerable, not a crashed daemon.
736
- this.launches.record({ key, pane: contextKey, kind, status: 'ready', inputDelivered: false, inputError: error?.message ?? String(error) });
749
+ this.launches.record({ key, pane: contextKey, kind, by, status: 'ready', inputDelivered: false, inputError: error?.message ?? String(error) });
737
750
  }).finally(() => {
738
751
  if (this.pendingInitialInputs.get(key)?.task === task) this.pendingInitialInputs.delete(key);
739
752
  });
@@ -1140,10 +1153,13 @@ export class TeamsMachine extends MachineCore {
1140
1153
  return { key, pane: existing.pane, reused: true, status: existing.status };
1141
1154
  }
1142
1155
  const contextKey = params.pane ?? `p_${randomBytes(9).toString('base64url')}`;
1156
+ // Who started the pane: the launching session's (or board's) address, null for a person. A session's later
1157
+ // input and close on the pane are refused unless it is this one (#refuseForeign).
1158
+ const by = typeof params.caller === 'string' && params.caller ? params.caller : null;
1143
1159
  // Who to tell when the pane ends (`open --new … --notify-when-ended`): a session's mail address, and this
1144
1160
  // machine's name as the address a notice from its pane comes from.
1145
1161
  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 });
1146
- this.launches.record({ key, pane: contextKey, kind: params.kind ?? null, status: 'issued' });
1162
+ this.launches.record({ key, pane: contextKey, kind: params.kind ?? null, by, status: 'issued' });
1147
1163
  if (params.kind === 'claude-code' && params.cwd && (params.arguments ?? []).includes('--dangerously-skip-permissions')) trustClaudeFolder(params.cwd);
1148
1164
  let session;
1149
1165
  try {
@@ -1151,17 +1167,18 @@ export class TeamsMachine extends MachineCore {
1151
1167
  command: launchCommand(params.program, params.arguments ?? []),
1152
1168
  contextKey,
1153
1169
  cwd: params.cwd,
1154
- env: this.#paneEnv(contextKey, params.env),
1170
+ // A pane that runs one verb for its launcher (`close`/`stop` on another machine) acts as that launcher.
1171
+ env: this.#paneEnv(contextKey, params.actForCaller && by ? { ...params.env, SUPERCODE_CALLER: by } : params.env),
1155
1172
  // The pane's own tmux session is always its key: the exit hooks record and clean up p_* sessions only, and
1156
1173
  // a pane named otherwise lingered dead with its exit status lost. A launch's name is its title.
1157
1174
  name: contextKey,
1158
1175
  });
1159
1176
  } catch (error) {
1160
- this.launches.record({ key, pane: contextKey, kind: params.kind ?? null, status: 'failed', error: error.message });
1177
+ this.launches.record({ key, pane: contextKey, kind: params.kind ?? null, by, status: 'failed', error: error.message });
1161
1178
  act('launch', `pane:${contextKey}`, 'failed', error.code ?? error.message);
1162
1179
  throw error;
1163
1180
  }
1164
- this.launches.record({ key, pane: contextKey, kind: params.kind ?? null, status: 'ready' });
1181
+ this.launches.record({ key, pane: contextKey, kind: params.kind ?? null, by, status: 'ready' });
1165
1182
  act('launch', `pane:${contextKey}`, 'ok', params.kind ?? params.program);
1166
1183
  if (typeof params.name === 'string' && params.name.trim()) {
1167
1184
  const titles = readJson(this.p.titles, { v: 1, titles: {} });
@@ -1177,7 +1194,7 @@ export class TeamsMachine extends MachineCore {
1177
1194
  // outcome is recorded against the launch key, so `launches.get|list`
1178
1195
  // answers "did my message go in, and was it late?" without anyone
1179
1196
  // reading the screen.
1180
- if (params.initialInput) this.#deliverInitialInput({ key, contextKey, session, kind: params.kind ?? null, input: params.initialInput, act });
1197
+ if (params.initialInput) this.#deliverInitialInput({ key, contextKey, session, kind: params.kind ?? null, by, input: params.initialInput, act });
1181
1198
  // A program that ends at once (not found, not runnable, failing on its first line) takes its pane with it:
1182
1199
  // the caller is told, with its exit status, instead of being handed a pane that is already gone.
1183
1200
  await new Promise((resolve) => setTimeout(resolve, LAUNCH_SETTLE_MS));
@@ -1186,7 +1203,7 @@ export class TeamsMachine extends MachineCore {
1186
1203
  // Finished (it ran to a clean end) is an answer; exited (it failed at once) is the caller's error.
1187
1204
  const exit = settled?.dead ? deadPaneExit(settled) : paneExit(contextKey);
1188
1205
  const status = exit && !exit.failed ? 'finished' : 'exited';
1189
- this.launches.record({ key, pane: contextKey, kind: params.kind ?? null, status, error: exit?.failed ? exit.how : undefined });
1206
+ this.launches.record({ key, pane: contextKey, kind: params.kind ?? null, by, status, error: exit?.failed ? exit.how : undefined });
1190
1207
  return { key, pane: contextKey, reused: false, status, exit: exit?.how ?? null };
1191
1208
  }
1192
1209
  if (this.popups && params.popup !== false) this.#popupWindow(contextKey, session);
@@ -1210,12 +1227,13 @@ export class TeamsMachine extends MachineCore {
1210
1227
  const rows = [];
1211
1228
  for (const found of this.launches.all()) {
1212
1229
  const session = found.pane ? await this.#paneByContext(found.pane) : null;
1213
- rows.push({ key: found.key, pane: found.pane ?? null, kind: found.kind ?? null, status: found.status ?? null, at: found.t, live: Boolean(session), ...initialInputFacts(found) });
1230
+ 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), ...initialInputFacts(found) });
1214
1231
  }
1215
1232
  return rows;
1216
1233
  }
1217
1234
  case 'panes.close': {
1218
1235
  need(operator ? 'control' : 'control', `pane:${params.pane}`);
1236
+ this.#refuseForeign(params.pane, params.caller);
1219
1237
  const session = await this.#paneByContext(params.pane);
1220
1238
  if (!session) throw new Error(`no pane with context ${params.pane}`);
1221
1239
  await this.host.closeSession(session.id);
@@ -1228,6 +1246,7 @@ export class TeamsMachine extends MachineCore {
1228
1246
  // working, only `{input}` is refused — until `resume`, which may carry
1229
1247
  // the input that hands the pane back. Local tmux input is never touched.
1230
1248
  need('control', `pane:${params.pane}`);
1249
+ this.#refuseForeign(params.pane, params.caller);
1231
1250
  const session = await this.#paneByContext(params.pane);
1232
1251
  if (!session) throw new Error(`no pane with context ${params.pane}`);
1233
1252
  const doc = readJson(this.p.suspended, { v: 1, panes: {} });
@@ -1249,6 +1268,7 @@ export class TeamsMachine extends MachineCore {
1249
1268
  // mechanics) for callers that are services rather than terminals. Same gate as an
1250
1269
  // attachment in control mode, same suspend rule as remote input everywhere.
1251
1270
  need('control', `pane:${params.pane}`);
1271
+ this.#refuseForeign(params.pane, params.caller);
1252
1272
  const session = await this.#paneByContext(params.pane);
1253
1273
  if (!session) throw Object.assign(new Error(`no pane with context ${params.pane}`), { code: 404 });
1254
1274
  if (this.suspended.has(params.pane)) throw Object.assign(new Error(`pane ${params.pane} is suspended: remote input is refused until resume`), { code: 423 });
@@ -1267,6 +1287,7 @@ export class TeamsMachine extends MachineCore {
1267
1287
  // answer, not an error: the caller keeps the turn and asks again. Its own
1268
1288
  // method, so a daemon without it refuses instead of typing into a draft.
1269
1289
  need('control', `pane:${params.pane}`);
1290
+ this.#refuseForeign(params.pane, params.caller);
1270
1291
  const session = await this.#paneByContext(params.pane);
1271
1292
  if (!session) throw Object.assign(new Error(`no pane with context ${params.pane}`), { code: 404 });
1272
1293
  if (this.suspended.has(params.pane)) throw Object.assign(new Error(`pane ${params.pane} is suspended: remote input is refused until resume`), { code: 423 });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-teams",
3
- "version": "0.2.99",
3
+ "version": "0.2.101",
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",