@volter/supercode-teams 0.2.105 → 0.2.107

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.
Files changed (2) hide show
  1. package/machine.mjs +53 -41
  2. package/package.json +1 -1
package/machine.mjs CHANGED
@@ -51,12 +51,8 @@ import { JobOwner, executionCapabilities, streamProgram, serveWorkspace } from '
51
51
 
52
52
  /** The preload every launched pane carries: it announces the ports a pane's node processes listen on. */
53
53
  const PORTHOLE_HOOK = fileURLToPath(new URL('./porthole-hook.cjs', import.meta.url));
54
- /** How long a launch's `initialInput` waits for its pane to become ready for
55
- * input before it is delivered anyway. Long enough for the slowest cold
56
- * start anyone has reported (a first-run agent TUI fetching its config over a
57
- * slow link); past it the input goes in regardless and the launch record says
58
- * so, because silently dropping a caller's first message is worse than an
59
- * imperfectly-timed one. */
54
+ /** How long launch input waits for a ready composer. At the deadline it stays
55
+ * undelivered; startup dialogs must never receive the queued message. */
60
56
  const INITIAL_INPUT_READY_DEADLINE_MS = 90_000;
61
57
  /** How often the readiness evidence is re-read while waiting. Same cadence the
62
58
  * terminal controller polls its own composer at: fast enough that a ready pane
@@ -187,6 +183,7 @@ export class TeamsMachine extends MachineCore {
187
183
  //. The launch reply does not wait for delivery — a cold TUI can take
188
184
  // longer than the caller's own RPC timeout — so the wait lives here, where
189
185
  // `stop()` can abandon it rather than leave a timer running past the daemon.
186
+ this.inputInFlight = new Set(); // one input transaction per pane
190
187
  this.pendingInitialInputs = new Map(); // launch key → { abort, task }
191
188
  this.registerDoor('harness', (entry, stream, accept, refuse, index) => this.#serveHarness(index, entry, stream, accept, refuse));
192
189
  this.registerDoor('terminal', (entry, stream, accept, refuse) => this.#attachTerminal(entry, stream, accept, refuse));
@@ -752,30 +749,16 @@ export class TeamsMachine extends MachineCore {
752
749
  const task = (async () => {
753
750
  const readyBy = await this.#awaitInputReady(session, contextKey, kind, abort.signal);
754
751
  if (readyBy === 'aborted') return;
755
- if (readyBy === 'pane-gone') {
752
+ if (readyBy === 'pane-gone' || readyBy === 'deadline') {
756
753
  this.launches.record({ key, pane: contextKey, kind, by, status: 'ready', inputDelivered: false, inputReadyBy: readyBy });
757
- act('launch', `pane:${contextKey}`, 'failed', 'first input undelivered: the pane ended before it was ready');
754
+ act('launch', `pane:${contextKey}`, 'failed', `first input undelivered: ${readyBy}`);
758
755
  return;
759
756
  }
760
- // `single` stays `single`: one paste, one submit. The repeated Enter of
761
- // `retry` exists for a composer that swallowed the first one, and pressing
762
- // Enter again at an agent that already took the message sends an empty
763
- // turn. The fix for a lost submit is waiting until there is something to
764
- // submit into, which is what happened above.
765
- try {
766
- await this.host.sendInput(session.id, submission(input), { submitMode: 'single' });
767
- } catch (error) {
768
- this.launches.record({ key, pane: contextKey, kind, by, status: 'ready', inputDelivered: false, inputReadyBy: readyBy, inputError: error.message });
769
- act('launch', `pane:${contextKey}`, 'failed', `first input undelivered: ${error.code ?? error.message}`);
770
- return;
771
- }
772
- // `inputDeliveredLate` is the deadline's own confession: the pane never
773
- // showed us it was ready, and the message went in on a screen nobody
774
- // vouched for. A launch that reads `ready` without it was given its
775
- // message by an input box that existed.
776
- const late = readyBy === 'deadline';
777
- this.launches.record({ key, pane: contextKey, kind, by, status: 'ready', inputDelivered: true, inputReadyBy: readyBy, ...(late ? { inputDeliveredLate: true } : {}) });
778
- act('launch', `pane:${contextKey}`, 'ok', `first input delivered (${readyBy})`);
757
+ const result = await this.#deliverPaneText(session, contextKey, input, { signal: abort.signal });
758
+ this.launches.record({ key, pane: contextKey, kind, by, status: 'ready', inputDelivered: result.delivered,
759
+ inputReadyBy: readyBy, ...(!result.delivered ? { inputError: result.reason } : {}) });
760
+ act('launch', `pane:${contextKey}`, result.delivered ? 'ok' : 'failed',
761
+ result.delivered ? `first input delivered (${readyBy})` : `first input unconfirmed: ${result.reason}`);
779
762
  })().catch((error) => {
780
763
  // Nothing above is expected to throw; if something does, the launch record
781
764
  // is where it is answerable, not a crashed daemon.
@@ -786,6 +769,24 @@ export class TeamsMachine extends MachineCore {
786
769
  this.pendingInitialInputs.set(key, { abort, task });
787
770
  }
788
771
 
772
+ async #deliverPaneText(session, contextKey, text, options = {}) {
773
+ if (this.inputInFlight.has(session.id)) return { delivered: false, reason: 'input-in-flight' };
774
+ this.inputInFlight.add(session.id);
775
+ try {
776
+ const kind = this.#paneKind(contextKey, session);
777
+ if (kind === 'claude-code' || kind === 'codex') {
778
+ if (typeof terminal.deliverComposerInput !== 'function') return { delivered: false, reason: 'terminal-package-too-old' };
779
+ return await terminal.deliverComposerInput(this.host, session.id, kind, submission(text, false), options);
780
+ }
781
+ const delivery = await this.host.sendInput(session.id, submission(text), { ...options, submitMode: 'single' });
782
+ return { delivered: true, delivery };
783
+ } catch (error) {
784
+ return { delivered: false, reason: error?.message ?? String(error) };
785
+ } finally {
786
+ this.inputInFlight.delete(session.id);
787
+ }
788
+ }
789
+
789
790
  /** The program a pane runs, as the terminal package names kinds: what it was
790
791
  * launched as, else what its foreground command is. */
791
792
  #paneKind(contextKey, session) {
@@ -813,13 +814,14 @@ export class TeamsMachine extends MachineCore {
813
814
  * the foreground command (`awaiting_me`'s evidence: a prompt,
814
815
  * turn over).
815
816
  * deadline neither, for `INITIAL_INPUT_READY_DEADLINE_MS`. The caller
816
- * delivers anyway and says so.
817
+ * leaves input undelivered without typing into that screen.
817
818
  */
818
819
  async #awaitInputReady(session, contextKey, kind, signal) {
819
- const composerKind = typeof kind === 'string' ? kind.trim().toLowerCase() : '';
820
+ const composerKind = this.#paneKind(contextKey, session);
820
821
  const until = Date.now() + INITIAL_INPUT_READY_DEADLINE_MS;
822
+ let readySince = null;
821
823
  while (!signal.aborted && Date.now() < until) {
822
- const capture = await this.#safeCapture(session.id);
824
+ const capture = await Promise.resolve().then(() => this.host.capture(session.id, { preserveEscapes: true })).catch(() => '');
823
825
  const unchangedMs = this.#screenUnchangedMs(session.id, capture, Date.now());
824
826
  const blank = capture.trim() === '';
825
827
  // A blank screen is the one reading that can mean the pane is GONE rather
@@ -832,7 +834,8 @@ export class TeamsMachine extends MachineCore {
832
834
  // never "not yet", or an unrecognized kind would wait out the whole
833
835
  // deadline on a screen nobody is reading.
834
836
  const composer = composerReadyForInput(composerKind, capture);
835
- if (composer === true) return 'composer';
837
+ readySince = composer === true ? (readySince ?? Date.now()) : null;
838
+ if (composer === true && Date.now() - readySince >= INITIAL_INPUT_SETTLE_MS) return 'composer';
836
839
  if (composer === null && unchangedMs >= INITIAL_INPUT_SETTLE_MS && (!blank || livenessOf(live.activeCommand) === 'waiting')) return 'settled';
837
840
  await new Promise((resolve) => setTimeout(resolve, INITIAL_INPUT_POLL_MS));
838
841
  }
@@ -1305,13 +1308,22 @@ export class TeamsMachine extends MachineCore {
1305
1308
  const session = await this.#paneByContext(params.pane);
1306
1309
  if (!session) throw Object.assign(new Error(`no pane with context ${params.pane}`), { code: 404 });
1307
1310
  if (this.suspended.has(params.pane)) throw Object.assign(new Error(`pane ${params.pane} is suspended: remote input is refused until resume`), { code: 423 });
1308
- // `deadlineMs` is how long the caller will wait (default 10 s, which covers `retry`'s 5.5 s of
1309
- // submit presses; at most 30 s); the terminal host takes an absolute deadline.
1310
- // `keys`: the bytes of named keys, delivered as they are (a trailing Enter is a key, not a line ending).
1311
- const data = params.keys ? String(params.text ?? '') : submission(params.text, params.submit !== false);
1312
- const delivery = await this.host.sendInput(session.id, data, { submitMode: params.submit === false || params.keys ? 'single' : 'retry', deadlineMs: Date.now() + Math.min(Number(params.deadlineMs) || 10_000, 30_000) });
1313
- act('control', `pane:${params.pane}`, 'input');
1314
- return { delivered: true, delivery };
1311
+ // Named keys and no-submit input are raw terminal writes. A submitted
1312
+ // Claude/Codex turn needs evidence from the composer before claiming delivery.
1313
+ const deadlineMs = Date.now() + Math.min(Number(params.deadlineMs) || 10_000, 30_000);
1314
+ if (this.inputInFlight.has(session.id)) return { delivered: false, reason: 'input-in-flight' };
1315
+ let result;
1316
+ if (params.keys || params.submit === false) {
1317
+ this.inputInFlight.add(session.id);
1318
+ try {
1319
+ const delivery = await this.host.sendInput(session.id, params.keys ? String(params.text ?? '') : submission(params.text, false), { submitMode: 'single', deadlineMs });
1320
+ result = { delivered: true, delivery };
1321
+ } finally { this.inputInFlight.delete(session.id); }
1322
+ } else {
1323
+ result = await this.#deliverPaneText(session, params.pane, params.text, { deadlineMs });
1324
+ }
1325
+ act('control', `pane:${params.pane}`, result.delivered ? 'input' : 'failed', result.reason);
1326
+ return result;
1315
1327
  }
1316
1328
  case 'panes.turn': {
1317
1329
  // A user's own turn, as its own submitted turn: typed only into an empty
@@ -1332,9 +1344,9 @@ export class TeamsMachine extends MachineCore {
1332
1344
  if (first !== false || second !== false) {
1333
1345
  return { delivered: false, reason: first === true || second === true ? 'draft' : 'no-composer' };
1334
1346
  }
1335
- const delivery = await this.host.sendInput(session.id, submission(params.text, true), { submitMode: 'single', deadlineMs: Date.now() + 10_000 });
1336
- act('control', `pane:${params.pane}`, 'input');
1337
- return { delivered: true, delivery };
1347
+ const result = await this.#deliverPaneText(session, params.pane, params.text, { deadlineMs: Date.now() + 10_000 });
1348
+ act('control', `pane:${params.pane}`, result.delivered ? 'input' : 'failed', result.reason);
1349
+ return result;
1338
1350
  }
1339
1351
  case 'panes.capture': {
1340
1352
  need('observe', `pane:${params.pane}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-teams",
3
- "version": "0.2.105",
3
+ "version": "0.2.107",
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",