@volter/supercode-teams 0.2.158 → 0.2.160

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/workspace.mjs CHANGED
@@ -680,7 +680,8 @@ async function connectorCommand(verb, { flags, rest, output, client, context, te
680
680
  }
681
681
  if (verb === 'connect') {
682
682
  let saved = state.read();
683
- const path = realpathSync(resolve(flags.cwd ?? '.'));
683
+ const path = realpathSync(resolve(flags.cwd ?? flags.root ?? '.'));
684
+ if (flags.cwd && flags.root && path !== realpathSync(resolve(flags.root))) usage('teams connect: --cwd and --root must name the same folder');
684
685
  // A broad root is refused before enrolling anything. The service reruns connect without a terminal, and
685
686
  // without --force, for the root accepted here; a broad root enrolled before rules covered subfolders keeps
686
687
  // its exact-folder sync there.
@@ -707,7 +708,7 @@ async function connectorCommand(verb, { flags, rest, output, client, context, te
707
708
  if (flags.install || (!flags.foreground && process.stdin.isTTY && process.stdout.isTTY)) {
708
709
  const serviceBin = process.env.SUPERCODE_BIN;
709
710
  if (!serviceBin) unsupported('--install requires invocation through the native supercode CLI');
710
- const serviceEnv = { ...process.env, SUPERCODE_TEAMS_ENTRY: fileURLToPath(new URL('./teams.mjs', import.meta.url)) };
711
+ const serviceEnv = { ...process.env, SUPERCODE_POPUPS: process.env.SUPERCODE_POPUPS ?? '0', SUPERCODE_TEAMS_ENTRY: fileURLToPath(new URL('./teams.mjs', import.meta.url)) };
711
712
  // Refuse source-backed installs before Windows stops the running connector.
712
713
  try {
713
714
  execFileSync(serviceBin, ['__teams-connector-service', 'validate', contextName, path, context.server_id, context.team_id], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], env: serviceEnv });
@@ -754,7 +755,7 @@ async function connectorCommand(verb, { flags, rest, output, client, context, te
754
755
  const connector = new TeamsConnector({ server: context.origin, teamId: context.team_id, machineId: saved.machine.id, sourceId: saved.source.id, credential: saved.credential.token, stateDir: state.directory, core, largeCore, slowCore, liveCore, hostKeyId });
755
756
  // A connected machine is a person's: each pane it launches opens a window on its screen (SUPERCODE_POPUPS=0: none).
756
757
  const machineEvents = new MachineEvents({home:daemonHome,server:context.origin,teamId:context.team_id,credential:saved.credential.token,machineName:saved.machine.name,binary});
757
- 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) });
758
+ const machine = new TeamsMachine({ home: daemonHome, supercode: binary, popups: (process.env.SUPERCODE_POPUPS ?? '0') !== '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) });
758
759
  machine.machineEvents = machineEvents;
759
760
  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`); });
760
761
  // The link's state for the watch after start: down until it is up, down again (since when) when it drops. A
package/codex-startup.mjs CHANGED
@@ -1,9 +1,58 @@
1
1
  // Codex can still show hook review on resume despite its invocation trust flag.
2
2
  // Act only on this exact startup menu, and never submit without reading selection.
3
3
  import { composerText } from '@volter/supercode-terminal';
4
- import { realpathSync } from 'node:fs';
4
+ import { realpathSync, accessSync, constants, statSync } from 'node:fs';
5
+ import { join, isAbsolute } from 'node:path';
6
+ import { homedir } from 'node:os';
7
+ import { spawnSync } from 'node:child_process';
5
8
  const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
6
9
 
10
+ export function codexProgram(env = process.env) {
11
+ const program = env.SUPERCODE_CODEX_BIN?.trim() || join(homedir(), '.local', 'bin', process.platform === 'win32' ? 'codex.cmd' : 'codex');
12
+ if (!isAbsolute(program)) throw new Error('SUPERCODE_CODEX_BIN must name an absolute executable path');
13
+ try {
14
+ accessSync(program, constants.X_OK);
15
+ if (!statSync(program).isFile()) throw new Error('not a file');
16
+ } catch {
17
+ throw new Error(`consolidated Codex executable is unavailable at ${program}; install it there or set SUPERCODE_CODEX_BIN on this machine`);
18
+ }
19
+ return program;
20
+ }
21
+
22
+ export function codexVersion(program, env) {
23
+ const command = process.platform === 'win32' && /\.cmd$/i.test(program)
24
+ ? { program: 'powershell.exe', args: ['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(`& '${program.replaceAll("'", "''")}' --version`, 'utf16le').toString('base64')] }
25
+ : { program, args: ['--version'] };
26
+ const observed = spawnSync(command.program, command.args, { encoding: 'utf8', timeout: 3000, env, maxBuffer: 4096 });
27
+ if (observed.error || observed.status !== 0 || !/^codex(?:-cli)?\s+\S+/.test(observed.stdout?.trim() ?? '')) throw new Error(`consolidated Codex at ${program} did not report its CLI version`);
28
+ return observed.stdout.trim();
29
+ }
30
+
31
+ export function codexStartupArguments(args = []) {
32
+ const normalized = [];
33
+ for (let index = 0; index < args.length; index += 1) {
34
+ const argument = args[index];
35
+ if (argument === '--') { normalized.push(...args.slice(index)); break; }
36
+ if (argument === '-c' || argument === '--config') {
37
+ const value = args[index + 1];
38
+ if (value?.split('=')[0].trim() === 'check_for_update_on_startup') { index += 1; continue; }
39
+ } else if (/^(?:--config=|-c=?)/.test(argument)) {
40
+ const value = argument.replace(/^(?:--config=|-c=?)/, '');
41
+ if (value.split('=')[0].trim() === 'check_for_update_on_startup') continue;
42
+ }
43
+ normalized.push(argument);
44
+ }
45
+ return ['-c', 'check_for_update_on_startup=false', ...normalized];
46
+ }
47
+
48
+ export function codexUpdatePrompt(screen) {
49
+ const text = String(screen).replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '');
50
+ const start = text.toLowerCase().lastIndexOf('update available');
51
+ if (start < 0) return false;
52
+ const menu = text.slice(start);
53
+ return /1\.\s*Update now/i.test(menu) && /2\.\s*Skip/i.test(menu) && !/(?:\? for shortcuts|context left|esc to interrupt)/i.test(menu);
54
+ }
55
+
7
56
  // CLI and portable SDK callers can name a directory through a filesystem alias.
8
57
  // Resolve only on the executing machine, before the native seed/server is built.
9
58
  export function canonicalCodexFolderTrust(cwd, args = []) {
@@ -514,16 +514,36 @@ export class TeamsConnector {
514
514
  }
515
515
  labelTurns(key,entries,messages){
516
516
  const labels={...this.state.turn_labels?.[key]},notes=[...(this.state.turn_notes?.[key]??[])];let found=false;
517
+ const annotations=source=>Object.fromEntries([['source_via',source.via],['source_principal_id',source.from?.principalId],['source_name',source.from?.name]].filter(([,item])=>typeof item==='string'&&item));
518
+ const identities=messages.map(message=>{
519
+ if(message.value?.role!=='user')return null;
520
+ const stamp=message.value.metadata?.timestamp,native=typeof stamp==='string'?Date.parse(stamp):NaN;
521
+ const at=Number.isFinite(native)?native:message.recorded_at;
522
+ if(!Number.isFinite(at))return null;
523
+ return {at,key:`turn:${hash(canonicalJson([Number.isFinite(native)?stamp:at,messageText(message.value)]))}`};
524
+ });
525
+ // Positional native keys can be reused after compaction. Upgrade a legacy
526
+ // label only when its dated, fully annotated row matches the prior capture.
527
+ const legacy=new Set(Object.keys(labels).filter(id=>!id.startsWith('turn:'))),prior=new Map();
528
+ const origin=this.state.origins[key];
529
+ if(legacy.size)for(const [index,id]of(origin?.observed_keys??[]).entries())if(legacy.has(id))prior.set(id,origin.observed_digests?.[index]);
530
+ messages.forEach((message,index)=>{
531
+ const identity=identities[index],source=labels[message.native_key];
532
+ if(!identity||labels[identity.key]||!source||!Number.isFinite(Date.parse(message.value.metadata?.timestamp??'')))return;
533
+ const value={...message.value,metadata:{...message.value.metadata,...annotations(source)}};
534
+ if(prior.get(message.native_key)!==hash(canonicalJson(value)))return;
535
+ labels[identity.key]=source;delete labels[message.native_key];found=true;
536
+ });
517
537
  for(const note of [...notes]){
518
- const match=messages.find(message=>!labels[message.native_key]&&message.value?.role==='user'&&(message.recorded_at==null||message.recorded_at>=note.at-60000)&&messageText(message.value)===note.text);
519
- if(match){labels[match.native_key]=note.source;notes.splice(notes.indexOf(note),1);found=true;}
538
+ const index=messages.findIndex((message,index)=>identities[index]&&!labels[identities[index].key]&&identities[index].at>=note.at-60000&&messageText(message.value)===note.text);
539
+ if(index>=0){labels[identities[index].key]=note.source;notes.splice(notes.indexOf(note),1);found=true;}
520
540
  }
521
541
  if(found){(this.state.turn_labels??={})[key]=labels;if(notes.length)this.state.turn_notes[key]=notes;else delete this.state.turn_notes[key];this.save();}
522
542
  messages.forEach((message,index)=>{
523
- const source=labels[message.native_key];if(!source)return;
524
- const annotations=Object.fromEntries([['source_via',source.via],['source_principal_id',source.from?.principalId],['source_name',source.from?.name]].filter(([,item])=>typeof item==='string'&&item));
525
- message.value={...message.value,metadata:{...message.value.metadata,...annotations}};
526
- const entry=entries[index];if(entry.value)entry.value={...entry.value,metadata:{...entry.value.metadata,...annotations}};else entry.metadata={...entry.metadata,...annotations};
543
+ const identity=identities[index],source=identity&&labels[identity.key];if(!source)return;
544
+ const value=annotations(source);
545
+ message.value={...message.value,metadata:{...message.value.metadata,...value}};
546
+ const entry=entries[index];if(entry.value)entry.value={...entry.value,metadata:{...entry.value.metadata,...value}};else entry.metadata={...entry.metadata,...value};
527
547
  });
528
548
  }
529
549
  /**
package/machine.mjs CHANGED
@@ -52,7 +52,7 @@ import { popupTerminal, openPopup } from './popup-terminals.mjs';
52
52
  import { JobOwner, executionCapabilities, streamProgram, serveWorkspace } from './execution.mjs';
53
53
  import { TerminalMailbox, retainedTerminalMail } from './terminal-mail.mjs';
54
54
  import { confirmNativeInitialInput } from './native-initial-input.mjs';
55
- import { canonicalCodexFolderTrust, settleCodexHookReview } from './codex-startup.mjs';
55
+ import { canonicalCodexFolderTrust, codexProgram, codexStartupArguments, codexUpdatePrompt, codexVersion, settleCodexHookReview } from './codex-startup.mjs';
56
56
 
57
57
  /** The preload every launched pane carries: it announces the ports a pane's node processes listen on. */
58
58
  const PORTHOLE_HOOK = fileURLToPath(new URL('./porthole-hook.cjs', import.meta.url));
@@ -869,13 +869,13 @@ export class TeamsMachine extends MachineCore {
869
869
  * Never rejects. The only caller is a fire-and-forget from the request path,
870
870
  * where an unhandled rejection would take the daemon down over one pane.
871
871
  */
872
- #deliverInitialInput({ key, contextKey, session, kind, by, input, act, nativeInput }) {
872
+ #deliverInitialInput({ key, contextKey, session, kind, by, input, act, nativeInput, executable = {} }) {
873
873
  const abort = new AbortController();
874
874
  const task = (async () => {
875
875
  if (nativeInput) {
876
876
  const result = await confirmNativeInitialInput(nativeInput, { signal: abort.signal,
877
877
  deadlineMs: Date.now() + INITIAL_INPUT_READY_DEADLINE_MS, live: async () => Boolean(await this.#paneByContext(contextKey)) });
878
- this.launches.record({ key, pane: contextKey, kind, by, status: 'ready', inputDelivered: result.delivered,
878
+ this.launches.record({ key, pane: contextKey, kind, by, ...executable, status: 'ready', inputDelivered: result.delivered,
879
879
  inputReadyBy: 'native-prompt', ...(result.delivered ? { inputReceipt: result.receipt } : { inputError: result.reason }) });
880
880
  act('launch', `pane:${contextKey}`, result.delivered ? 'ok' : 'failed',
881
881
  result.delivered ? 'first input confirmed by native user message' : `native first input unconfirmed: ${result.reason}`);
@@ -884,19 +884,19 @@ export class TeamsMachine extends MachineCore {
884
884
  const readyBy = await this.#awaitInputReady(session, contextKey, kind, abort.signal);
885
885
  if (readyBy === 'aborted') return;
886
886
  if (readyBy === 'pane-gone' || readyBy === 'deadline') {
887
- this.launches.record({ key, pane: contextKey, kind, by, status: 'ready', inputDelivered: false, inputReadyBy: readyBy });
887
+ this.launches.record({ key, pane: contextKey, kind, by, ...executable, status: 'ready', inputDelivered: false, inputReadyBy: readyBy });
888
888
  act('launch', `pane:${contextKey}`, 'failed', `first input undelivered: ${readyBy}`);
889
889
  return;
890
890
  }
891
891
  const result = await this.#deliverPaneText(session, contextKey, input, { signal: abort.signal });
892
- this.launches.record({ key, pane: contextKey, kind, by, status: 'ready', inputDelivered: result.delivered,
892
+ this.launches.record({ key, pane: contextKey, kind, by, ...executable, status: 'ready', inputDelivered: result.delivered,
893
893
  inputReadyBy: readyBy, ...(!result.delivered ? { inputError: result.reason } : {}) });
894
894
  act('launch', `pane:${contextKey}`, result.delivered ? 'ok' : 'failed',
895
895
  result.delivered ? `first input delivered (${readyBy})` : `first input unconfirmed: ${result.reason}`);
896
896
  })().catch((error) => {
897
897
  // Nothing above is expected to throw; if something does, the launch record
898
898
  // is where it is answerable, not a crashed daemon.
899
- this.launches.record({ key, pane: contextKey, kind, by, status: 'ready', inputDelivered: false, inputError: error?.message ?? String(error) });
899
+ this.launches.record({ key, pane: contextKey, kind, by, ...executable, status: 'ready', inputDelivered: false, inputError: error?.message ?? String(error) });
900
900
  }).finally(() => {
901
901
  if (this.pendingInitialInputs.get(key)?.task === task) this.pendingInitialInputs.delete(key);
902
902
  });
@@ -937,6 +937,7 @@ export class TeamsMachine extends MachineCore {
937
937
 
938
938
  async #deliverPaneText(session, contextKey, text, options = {}) {
939
939
  const kind = this.#paneKind(contextKey, session);
940
+ if (kind === 'codex' && codexUpdatePrompt(await this.host.capture(session.id, { lines: 120 }))) return { delivered: false, reason: 'codex-update-prompt: delivery refused; restart Codex with check_for_update_on_startup=false' };
940
941
  if (['codex', 'grok', 'gemini', 'goose', 'opencode', 'pi', 'hermes', 'openclaw'].includes(kind)) return this.#terminalMail(session, contextKey, kind, submission(text, false), options);
941
942
  if(this.inputRepairs[contextKey])return {delivered:false,reason:'input-cleanup-pending',cleanup_required:true};
942
943
  if (this.inputInFlight.has(session.id)) return { delivered: false, reason: 'input-in-flight' };
@@ -1435,6 +1436,8 @@ export class TeamsMachine extends MachineCore {
1435
1436
  case 'launch': {
1436
1437
  // A pane launch. Operators only; the body may carry secrets and is never logged.
1437
1438
  if (!operator) throw Object.assign(new Error('launch is for operators'), { code: 403 });
1439
+ const programName = String(params.program).split(/[\\/]/).pop().replace(/\.(?:exe|cmd)$/i, '');
1440
+ if (programName === 'codex') params = { ...params, kind: 'codex' };
1438
1441
  const key = params.key ?? `lk_${randomBytes(9).toString('base64url')}`;
1439
1442
  const existing = this.launches.find(key);
1440
1443
  // A key whose launch is in flight, or whose pane still lives, is reused; a failed
@@ -1453,7 +1456,12 @@ export class TeamsMachine extends MachineCore {
1453
1456
  if (params.kind === 'claude-code' && params.cwd && (params.arguments ?? []).includes('--dangerously-skip-permissions')) trustClaudeFolder(params.cwd);
1454
1457
  let session;
1455
1458
  let nativeInput;
1459
+ let executable = {};
1456
1460
  try {
1461
+ if (params.kind === 'codex') {
1462
+ params = { ...params, program: codexProgram(this.env), arguments: codexStartupArguments(params.arguments) };
1463
+ executable = { program: params.program, cliVersion: codexVersion(params.program, this.env) };
1464
+ }
1457
1465
  if (process.platform !== 'win32' && !String(params.program).includes('/')) {
1458
1466
  const search = params.env?.PATH ?? this.env.PATH ?? '';
1459
1467
  const program = search.split(':').map(dir => resolve(dir || params.cwd || process.cwd(), params.program)).find(path => {
@@ -1474,11 +1482,11 @@ export class TeamsMachine extends MachineCore {
1474
1482
  name: contextKey,
1475
1483
  });
1476
1484
  } catch (error) {
1477
- this.launches.record({ key, pane: contextKey, kind: params.kind ?? null, by, status: 'failed', error: error.message });
1485
+ this.launches.record({ key, pane: contextKey, kind: params.kind ?? null, by, ...executable, status: 'failed', error: error.message });
1478
1486
  act('launch', `pane:${contextKey}`, 'failed', error.code ?? error.message);
1479
1487
  throw error;
1480
1488
  }
1481
- this.launches.record({ key, pane: contextKey, kind: params.kind ?? null, by, status: 'ready' });
1489
+ this.launches.record({ key, pane: contextKey, kind: params.kind ?? null, by, ...executable, status: 'ready' });
1482
1490
  if (params.kind === 'codex' && (params.arguments ?? []).includes('--dangerously-bypass-hook-trust')) this.#settleCodexStartup(key, contextKey, session, act);
1483
1491
  act('launch', `pane:${contextKey}`, 'ok', params.kind ?? params.program);
1484
1492
  if (typeof params.name === 'string' && params.name.trim()) {
@@ -1495,7 +1503,7 @@ export class TeamsMachine extends MachineCore {
1495
1503
  // outcome is recorded against the launch key, so `launches.get|list`
1496
1504
  // answers "did my message go in, and was it late?" without anyone
1497
1505
  // reading the screen.
1498
- if (params.initialInput) this.#deliverInitialInput({ key, contextKey, session, kind: params.kind ?? null, by, input: params.initialInput, act, nativeInput });
1506
+ if (params.initialInput) this.#deliverInitialInput({ key, contextKey, session, kind: params.kind ?? null, by, input: params.initialInput, act, nativeInput, executable });
1499
1507
  // A program that ends at once (not found, not runnable, failing on its first line) takes its pane with it:
1500
1508
  // the caller is told, with its exit status, instead of being handed a pane that is already gone.
1501
1509
  await new Promise((resolve) => setTimeout(resolve, LAUNCH_SETTLE_MS));
@@ -1504,7 +1512,7 @@ export class TeamsMachine extends MachineCore {
1504
1512
  // Finished (it ran to a clean end) is an answer; exited (it failed at once) is the caller's error.
1505
1513
  const exit = settled?.dead ? deadPaneExit(settled) : paneExit(contextKey);
1506
1514
  const status = exit && !exit.failed ? 'finished' : 'exited';
1507
- this.launches.record({ key, pane: contextKey, kind: params.kind ?? null, by, status, error: exit?.failed ? exit.how : undefined });
1515
+ this.launches.record({ key, pane: contextKey, kind: params.kind ?? null, by, ...executable, status, error: exit?.failed ? exit.how : undefined });
1508
1516
  return { key, pane: contextKey, reused: false, status, exit: exit?.how ?? null };
1509
1517
  }
1510
1518
  if (this.popups && params.popup !== false) this.#popupWindow(contextKey, session);
@@ -1585,6 +1593,7 @@ export class TeamsMachine extends MachineCore {
1585
1593
  if (this.inputInFlight.has(session.id)) return { delivered: false, reason: 'input-in-flight' };
1586
1594
  let result;
1587
1595
  if (params.keys || params.submit === false) {
1596
+ if (!params.keys && this.#paneKind(params.pane, session) === 'codex' && codexUpdatePrompt(await this.host.capture(session.id, { lines: 120 }))) return { delivered: false, reason: 'codex-update-prompt: delivery paste refused' };
1588
1597
  if (!params.keys && [...this.terminalMailboxes.values()].some(queue => queue.session?.id === session.id && (queue.state.active || queue.state.pending.length))) return { delivered: false, reason: 'terminal mailbox has an unconfirmed paste' };
1589
1598
  this.inputInFlight.add(session.id);
1590
1599
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-teams",
3
- "version": "0.2.158",
3
+ "version": "0.2.160",
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",
package/terminal-mail.mjs CHANGED
@@ -4,6 +4,7 @@ import { mkdirSync, readFileSync, writeFileSync, renameSync, appendFileSync, sta
4
4
  import { join } from 'node:path';
5
5
  import { createHash, randomUUID } from 'node:crypto';
6
6
  import { StringDecoder } from 'node:string_decoder';
7
+ import { codexUpdatePrompt } from './codex-startup.mjs';
7
8
 
8
9
  const hash = value => createHash('sha256').update(value).digest('hex');
9
10
  const textOf = content => typeof content === 'string' ? content : Array.isArray(content) && content.every(p => ['text', 'input_text'].includes(p.type)) ? content.map(p => p.text).join('') : null;
@@ -116,6 +117,10 @@ export class TerminalMailbox {
116
117
  this.state.receipts.push(receipt);
117
118
  this.state.active = null; this.save(); this.log(receipt); return;
118
119
  }
120
+ if (batch.kind === 'codex' && codexUpdatePrompt(await this.host.capture(this.session.id, { lines: 120 }))) {
121
+ batch.error = 'codex-update-prompt: delivery retained; restart Codex with check_for_update_on_startup=false';
122
+ this.save(); this.log({ delivered: false, queued: true, reason: batch.error, message_ids: batch.message_ids }); return;
123
+ }
119
124
  if (batch.phase === 'prepared') {
120
125
  // Persist uncertainty before I/O. A daemon restart checks the transcript;
121
126
  // it never guesses that an unacknowledged paste did not happen.
@@ -129,6 +134,9 @@ export class TerminalMailbox {
129
134
  batch.error = 'paste outcome uncertain; native transcript confirmation pending'; this.save(); return;
130
135
  }
131
136
  if (batch.enters < 3 && Date.now() - batch.last_enter >= 5000) {
137
+ if (batch.kind === 'codex' && codexUpdatePrompt(await this.host.capture(this.session.id, { lines: 120 }))) {
138
+ batch.error = 'codex-update-prompt: Enter refused'; this.save(); return;
139
+ }
132
140
  batch.enters += 1; batch.last_enter = Date.now(); this.save();
133
141
  await this.host.sendInput(this.session.id, '\r', { submitMode: 'single' });
134
142
  const attempt = { delivered: false, queued: true, batch: batch.id, message_ids: batch.message_ids, attempt: batch.enters, at: Date.now() };