@volter/supercode-teams 0.2.104 → 0.2.106

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 -35
  2. package/package.json +1 -1
package/machine.mjs CHANGED
@@ -187,6 +187,7 @@ export class TeamsMachine extends MachineCore {
187
187
  //. The launch reply does not wait for delivery — a cold TUI can take
188
188
  // longer than the caller's own RPC timeout — so the wait lives here, where
189
189
  // `stop()` can abandon it rather than leave a timer running past the daemon.
190
+ this.inputInFlight = new Set(); // one input transaction per pane
190
191
  this.pendingInitialInputs = new Map(); // launch key → { abort, task }
191
192
  this.registerDoor('harness', (entry, stream, accept, refuse, index) => this.#serveHarness(index, entry, stream, accept, refuse));
192
193
  this.registerDoor('terminal', (entry, stream, accept, refuse) => this.#attachTerminal(entry, stream, accept, refuse));
@@ -596,7 +597,11 @@ export class TeamsMachine extends MachineCore {
596
597
  let out = '';
597
598
  let child;
598
599
  try {
599
- child = spawn(this.supercodeBin, ['teams', 'caller', '--peer-fd', '3'], { stdio: ['ignore', 'pipe', 'ignore', entry.socket], env: this.env, windowsHide: true });
600
+ // The descriptor's number, not the socket: a stream handed to spawn stops being read here, and the
601
+ // connection's next request would never be answered. The child gets its own duplicate of the descriptor.
602
+ const fd = entry.socket._handle?.fd;
603
+ if (!Number.isInteger(fd) || fd < 0) { resolve({ unsupported: true }); return; }
604
+ child = spawn(this.supercodeBin, ['teams', 'caller', '--peer-fd', '3'], { stdio: ['ignore', 'pipe', 'ignore', fd], env: this.env, windowsHide: true });
600
605
  } catch { resolve({ unknown: true }); return; }
601
606
  child.stdout.on('data', (chunk) => { out += chunk; });
602
607
  child.on('error', () => resolve({ unknown: true }));
@@ -748,30 +753,16 @@ export class TeamsMachine extends MachineCore {
748
753
  const task = (async () => {
749
754
  const readyBy = await this.#awaitInputReady(session, contextKey, kind, abort.signal);
750
755
  if (readyBy === 'aborted') return;
751
- if (readyBy === 'pane-gone') {
756
+ if (readyBy === 'pane-gone' || readyBy === 'deadline') {
752
757
  this.launches.record({ key, pane: contextKey, kind, by, status: 'ready', inputDelivered: false, inputReadyBy: readyBy });
753
- act('launch', `pane:${contextKey}`, 'failed', 'first input undelivered: the pane ended before it was ready');
758
+ act('launch', `pane:${contextKey}`, 'failed', `first input undelivered: ${readyBy}`);
754
759
  return;
755
760
  }
756
- // `single` stays `single`: one paste, one submit. The repeated Enter of
757
- // `retry` exists for a composer that swallowed the first one, and pressing
758
- // Enter again at an agent that already took the message sends an empty
759
- // turn. The fix for a lost submit is waiting until there is something to
760
- // submit into, which is what happened above.
761
- try {
762
- await this.host.sendInput(session.id, submission(input), { submitMode: 'single' });
763
- } catch (error) {
764
- this.launches.record({ key, pane: contextKey, kind, by, status: 'ready', inputDelivered: false, inputReadyBy: readyBy, inputError: error.message });
765
- act('launch', `pane:${contextKey}`, 'failed', `first input undelivered: ${error.code ?? error.message}`);
766
- return;
767
- }
768
- // `inputDeliveredLate` is the deadline's own confession: the pane never
769
- // showed us it was ready, and the message went in on a screen nobody
770
- // vouched for. A launch that reads `ready` without it was given its
771
- // message by an input box that existed.
772
- const late = readyBy === 'deadline';
773
- this.launches.record({ key, pane: contextKey, kind, by, status: 'ready', inputDelivered: true, inputReadyBy: readyBy, ...(late ? { inputDeliveredLate: true } : {}) });
774
- act('launch', `pane:${contextKey}`, 'ok', `first input delivered (${readyBy})`);
761
+ const result = await this.#deliverPaneText(session, contextKey, input, { signal: abort.signal });
762
+ this.launches.record({ key, pane: contextKey, kind, by, status: 'ready', inputDelivered: result.delivered,
763
+ inputReadyBy: readyBy, ...(!result.delivered ? { inputError: result.reason } : {}) });
764
+ act('launch', `pane:${contextKey}`, result.delivered ? 'ok' : 'failed',
765
+ result.delivered ? `first input delivered (${readyBy})` : `first input unconfirmed: ${result.reason}`);
775
766
  })().catch((error) => {
776
767
  // Nothing above is expected to throw; if something does, the launch record
777
768
  // is where it is answerable, not a crashed daemon.
@@ -782,6 +773,24 @@ export class TeamsMachine extends MachineCore {
782
773
  this.pendingInitialInputs.set(key, { abort, task });
783
774
  }
784
775
 
776
+ async #deliverPaneText(session, contextKey, text, options = {}) {
777
+ if (this.inputInFlight.has(session.id)) return { delivered: false, reason: 'input-in-flight' };
778
+ this.inputInFlight.add(session.id);
779
+ try {
780
+ const kind = this.#paneKind(contextKey, session);
781
+ if (kind === 'claude-code' || kind === 'codex') {
782
+ if (typeof terminal.deliverComposerInput !== 'function') return { delivered: false, reason: 'terminal-package-too-old' };
783
+ return await terminal.deliverComposerInput(this.host, session.id, kind, submission(text, false), options);
784
+ }
785
+ const delivery = await this.host.sendInput(session.id, submission(text), { ...options, submitMode: 'single' });
786
+ return { delivered: true, delivery };
787
+ } catch (error) {
788
+ return { delivered: false, reason: error?.message ?? String(error) };
789
+ } finally {
790
+ this.inputInFlight.delete(session.id);
791
+ }
792
+ }
793
+
785
794
  /** The program a pane runs, as the terminal package names kinds: what it was
786
795
  * launched as, else what its foreground command is. */
787
796
  #paneKind(contextKey, session) {
@@ -809,13 +818,13 @@ export class TeamsMachine extends MachineCore {
809
818
  * the foreground command (`awaiting_me`'s evidence: a prompt,
810
819
  * turn over).
811
820
  * deadline neither, for `INITIAL_INPUT_READY_DEADLINE_MS`. The caller
812
- * delivers anyway and says so.
821
+ * leaves input undelivered without typing into that screen.
813
822
  */
814
823
  async #awaitInputReady(session, contextKey, kind, signal) {
815
- const composerKind = typeof kind === 'string' ? kind.trim().toLowerCase() : '';
824
+ const composerKind = this.#paneKind(contextKey, session);
816
825
  const until = Date.now() + INITIAL_INPUT_READY_DEADLINE_MS;
817
826
  while (!signal.aborted && Date.now() < until) {
818
- const capture = await this.#safeCapture(session.id);
827
+ const capture = await Promise.resolve().then(() => this.host.capture(session.id, { preserveEscapes: true })).catch(() => '');
819
828
  const unchangedMs = this.#screenUnchangedMs(session.id, capture, Date.now());
820
829
  const blank = capture.trim() === '';
821
830
  // A blank screen is the one reading that can mean the pane is GONE rather
@@ -1301,13 +1310,22 @@ export class TeamsMachine extends MachineCore {
1301
1310
  const session = await this.#paneByContext(params.pane);
1302
1311
  if (!session) throw Object.assign(new Error(`no pane with context ${params.pane}`), { code: 404 });
1303
1312
  if (this.suspended.has(params.pane)) throw Object.assign(new Error(`pane ${params.pane} is suspended: remote input is refused until resume`), { code: 423 });
1304
- // `deadlineMs` is how long the caller will wait (default 10 s, which covers `retry`'s 5.5 s of
1305
- // submit presses; at most 30 s); the terminal host takes an absolute deadline.
1306
- // `keys`: the bytes of named keys, delivered as they are (a trailing Enter is a key, not a line ending).
1307
- const data = params.keys ? String(params.text ?? '') : submission(params.text, params.submit !== false);
1308
- 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) });
1309
- act('control', `pane:${params.pane}`, 'input');
1310
- return { delivered: true, delivery };
1313
+ // Named keys and no-submit input are raw terminal writes. A submitted
1314
+ // Claude/Codex turn needs evidence from the composer before claiming delivery.
1315
+ const deadlineMs = Date.now() + Math.min(Number(params.deadlineMs) || 10_000, 30_000);
1316
+ if (this.inputInFlight.has(session.id)) return { delivered: false, reason: 'input-in-flight' };
1317
+ let result;
1318
+ if (params.keys || params.submit === false) {
1319
+ this.inputInFlight.add(session.id);
1320
+ try {
1321
+ const delivery = await this.host.sendInput(session.id, params.keys ? String(params.text ?? '') : submission(params.text, false), { submitMode: 'single', deadlineMs });
1322
+ result = { delivered: true, delivery };
1323
+ } finally { this.inputInFlight.delete(session.id); }
1324
+ } else {
1325
+ result = await this.#deliverPaneText(session, params.pane, params.text, { deadlineMs });
1326
+ }
1327
+ act('control', `pane:${params.pane}`, result.delivered ? 'input' : 'failed', result.reason);
1328
+ return result;
1311
1329
  }
1312
1330
  case 'panes.turn': {
1313
1331
  // A user's own turn, as its own submitted turn: typed only into an empty
@@ -1328,9 +1346,9 @@ export class TeamsMachine extends MachineCore {
1328
1346
  if (first !== false || second !== false) {
1329
1347
  return { delivered: false, reason: first === true || second === true ? 'draft' : 'no-composer' };
1330
1348
  }
1331
- const delivery = await this.host.sendInput(session.id, submission(params.text, true), { submitMode: 'single', deadlineMs: Date.now() + 10_000 });
1332
- act('control', `pane:${params.pane}`, 'input');
1333
- return { delivered: true, delivery };
1349
+ const result = await this.#deliverPaneText(session, params.pane, params.text, { deadlineMs: Date.now() + 10_000 });
1350
+ act('control', `pane:${params.pane}`, result.delivered ? 'input' : 'failed', result.reason);
1351
+ return result;
1334
1352
  }
1335
1353
  case 'panes.capture': {
1336
1354
  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.104",
3
+ "version": "0.2.106",
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",