@volter/supercode-teams 0.3.136 → 0.3.138

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 (3) hide show
  1. package/machine.mjs +158 -51
  2. package/package.json +2 -2
  3. package/terminal-mail.mjs +180 -16
package/machine.mjs CHANGED
@@ -1957,7 +1957,7 @@ export class TeamsMachine extends MachineCore {
1957
1957
  // app-server process boundary. Process ancestry alone cannot recover it.
1958
1958
  #nativeQuestionCommand(params, pane, by, nativeInput) {
1959
1959
  if (params.env?.SUPERCODE_BOARD_PROCESS_GUARD === '1' && process.platform === 'win32') throw new Error('board process guard is unavailable on Windows; no unguarded session was launched');
1960
- const args = boardGuardArguments(params.kind,params.arguments ?? [],{cwd:params.cwd,enabled:params.env?.SUPERCODE_BOARD_PROCESS_GUARD === '1'});
1960
+ let args = boardGuardArguments(params.kind,params.arguments ?? [],{cwd:params.cwd,enabled:params.env?.SUPERCODE_BOARD_PROCESS_GUARD === '1'});
1961
1961
  const name = String(params.program ?? '').split(/[\\/]/).pop();
1962
1962
  // Windows has no Unix endpoint for a pane's own native server, so a fresh Codex there starts with no session to
1963
1963
  // type into: its rollout appears only with its first turn. Its first input is therefore Codex's own argv prompt,
@@ -1980,37 +1980,55 @@ export class TeamsMachine extends MachineCore {
1980
1980
  cwd: params.cwd || null, continuing: verb >= 0, resumeId: named ?? null, typed });
1981
1981
  return launchCommand(params.program, typed ? args : [...args, '--', initialInput]);
1982
1982
  }
1983
- if (process.platform === 'win32' || !((params.kind === 'codex' && name === 'codex') ||
1984
- (params.kind === 'claude-code' && name === 'claude'))) return launchCommand(params.program, args);
1983
+ const claude = params.kind === 'claude-code' && /^claude(?:\.cmd|\.exe)?$/i.test(name);
1984
+ if ((process.platform === 'win32' && !claude) || !((params.kind === 'codex' && name === 'codex') || claude)) return launchCommand(params.program, args);
1985
+ if (claude && params.initialInput) {
1986
+ const delimiter = args.indexOf('--');
1987
+ if (delimiter >= 0) {
1988
+ if (delimiter !== args.length - 1) throw Object.assign(new Error('Claude first input conflicts with a prompt already supplied after --; provide one prompt'), { code: 400 });
1989
+ args = args.slice(0, delimiter);
1990
+ }
1991
+ }
1985
1992
  // the hook is the front door's (mail step 1b): taken when the machine's stable supercode serves it
1986
1993
  this.nativeQuestionHooksSupported ??= this.#frontDoorServes('message native-question-hook');
1987
- if (!this.nativeQuestionHooksSupported) return launchCommand(params.program, args);
1994
+ if (!this.nativeQuestionHooksSupported) {
1995
+ if (claude && params.initialInput) throw Object.assign(new Error('Claude first input requires the native SessionStart receipt hook; this front door does not serve it'), { code: 503 });
1996
+ return launchCommand(params.program, args);
1997
+ }
1988
1998
  const mailHome = this.env.SUPERCODE_HOME || join(this.env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'supercode');
1989
1999
  const dir = join(mailHome, 'mail', 'native-launches');
1990
2000
  mkdirSync(dir, { recursive: true, mode: 0o700 });
1991
2001
  // A reused pane key still gets a new receipt baseline. A late hook from
1992
2002
  // its previous process cannot publish evidence for this launch.
1993
2003
  const binding = join(dir, `${pane}-${randomBytes(9).toString('base64url')}.json`);
1994
- // Claude records an argv prompt verbatim when it runs at once, but trimmed
1995
- // when it queues behind work restored on resume (a pending task
1996
- // notification). Sending the trimmed text makes both records equal it.
2004
+ // Claude's first input uses the same retained composer delivery as later
2005
+ // mail. It never travels in a shell command line, including on Windows.
1997
2006
  const prompt = params.kind === 'claude-code' && typeof params.initialInput === 'string' ? params.initialInput.trim() : params.initialInput;
1998
- const initialInput = prompt && !args.includes('--') &&
2007
+ const initialInput = !claude && prompt && !args.includes('--') &&
1999
2008
  !args.some((arg) => arg === '--remote' || arg.startsWith('--remote=')) ? prompt : null;
2000
- writeFileSync(binding, JSON.stringify({ kind: params.kind, pane, by, program: params.program, arguments: args, initialInput }), { mode: 0o600 });
2001
- if (initialInput) nativeInput({ binding, kind: params.kind, text: initialInput, startedAt: Date.now() });
2002
- const claudeCommand = (next) => launchCommand(params.program, initialInput ? [...next, '--', initialInput] : next);
2003
- const quote = (value) => `'${String(value).replaceAll("'", "'\\''")}'`;
2004
- // the machine's stable supercode: the session outlives this daemon's install path (an upgrade or a move)
2005
- const command = `env SUPERCODE_HOME=${quote(mailHome)} ${quote(this.#stableSupercode())} message native-question-hook ${quote(binding)}`;
2006
- const handler = { type: 'command', command, timeout: 3600 };
2009
+ const claudeInput = claude && prompt ? { kind: params.kind, text: prompt, startedAt: Date.now(),
2010
+ deadlineMs: Date.now() + INITIAL_INPUT_READY_DEADLINE_MS + 10_000, typed: true } : null;
2011
+ writeFileSync(binding, JSON.stringify({ key: params.key, kind: params.kind, pane, by, program: params.program, arguments: args, initialInput, claudeInput }), { mode: 0o600 });
2012
+ // The first input is durable before createSession can start the program.
2013
+ // Its body stays in the private binding, never in the launch journal.
2014
+ if (claudeInput) {
2015
+ this.launches.record({ ...this.launches.find(params.key), nativeInputBinding: binding });
2016
+ nativeInput({ ...claudeInput, binding });
2017
+ }
2018
+ else if (initialInput) nativeInput({ binding, kind: params.kind, text: initialInput, startedAt: Date.now() });
2007
2019
  if (params.kind === 'claude-code') {
2020
+ // Claude's exec-form hook passes each argument unchanged on all platforms.
2021
+ // A Windows .cmd shim is resolved to its Node executable and front door.
2022
+ const stable = this.#stableSupercode();
2023
+ const direct = /\.(cmd|bat)$/i.test(stable) ? shimTarget(stable) : [stable];
2024
+ if (!direct) throw Object.assign(new Error('the stable supercode shim has no executable native hook target'), { code: 503 });
2025
+ const handler = { type: 'command', command: direct[0], args: [...direct.slice(1), 'message', 'native-question-hook', binding, '--home', mailHome], timeout: 3600 };
2008
2026
  const hooks = { SessionStart: [{ hooks: [handler] }], PreToolUse: [{ matcher: 'AskUserQuestion', hooks: [handler] }], PostToolUse: [{ matcher: 'AskUserQuestion', hooks: [handler] }] };
2009
2027
  // Claude accepts only one settings value. Preserve an existing inline
2010
2028
  // object or file, including its hook arrays, in that same argument.
2011
2029
  const next = [...args];
2012
2030
  const at = next.findIndex((arg) => arg === '--settings' || arg.startsWith('--settings='));
2013
- if (at < 0) return claudeCommand([...next, '--settings', JSON.stringify({ hooks })]);
2031
+ if (at < 0) return launchCommand(params.program, [...next, '--settings', JSON.stringify({ hooks })]);
2014
2032
  const inline = next[at].startsWith('--settings=');
2015
2033
  const value = inline ? next[at].slice('--settings='.length) : next[at + 1];
2016
2034
  const settings = JSON.parse(value?.trimStart().startsWith('{') ? value : readFileSync(resolve(params.cwd || process.cwd(), value), 'utf8'));
@@ -2018,7 +2036,7 @@ export class TeamsMachine extends MachineCore {
2018
2036
  settings.hooks ??= {};
2019
2037
  for (const [event, groups] of Object.entries(hooks)) settings.hooks[event] = [...(settings.hooks[event] ?? []), ...groups];
2020
2038
  next.splice(at, inline ? 1 : 2, '--settings', JSON.stringify(settings));
2021
- return claudeCommand(next);
2039
+ return launchCommand(params.program, next);
2022
2040
  }
2023
2041
  // The stock TUI's default embedded server has no attachable question
2024
2042
  // door. Give this pane its own native server, through Codex's --remote
@@ -2368,9 +2386,24 @@ export class TeamsMachine extends MachineCore {
2368
2386
  * where an unhandled rejection would take the daemon down over one pane.
2369
2387
  */
2370
2388
  #deliverInitialInput({ key, contextKey, session, kind, by, input, act, nativeInput, executable = {}, resumed = {} }) {
2389
+ if (this.pendingInitialInputs.has(key)) return;
2371
2390
  const abort = new AbortController();
2372
2391
  const task = (async () => {
2373
2392
  let nativeReadyBy = 'native-prompt';
2393
+ if (nativeInput?.typed && kind === 'claude-code') {
2394
+ nativeReadyBy = await this.#awaitInputReady(session, contextKey, kind, abort.signal, nativeInput.deadlineMs);
2395
+ if (nativeReadyBy === 'aborted') return;
2396
+ const result = nativeReadyBy === 'pane-gone' || nativeReadyBy === 'deadline'
2397
+ ? { delivered: false, refused: true, reason: nativeReadyBy }
2398
+ : await this.#deliverClaudeInitialInput(session, contextKey, nativeInput, abort.signal);
2399
+ this.launches.record({ key, pane: contextKey, kind, by, ...resumed, ...executable, status: 'ready', inputDelivered: result.delivered,
2400
+ inputReadyBy: nativeReadyBy, ...(result.delivered ? { inputReceipt: result.receipt } : { inputError: result.reason }),
2401
+ inputRefused: result.refused === true, inputPending: result.queued === true,
2402
+ ...(result.native_session ? { native_session: result.native_session } : {}) });
2403
+ act('launch', `pane:${contextKey}`, result.delivered ? 'ok' : result.queued ? 'input-pending' : 'failed',
2404
+ result.delivered ? 'first input confirmed by native user message' : `first input unconfirmed: ${result.reason}`);
2405
+ return;
2406
+ }
2374
2407
  if (nativeInput?.typed) {
2375
2408
  // A Windows Codex input its command line could not carry: pasted once the composer is up, and confirmed by
2376
2409
  // the same rollout receipt as an argv prompt. Its pane has no native session to mail yet.
@@ -2417,20 +2450,77 @@ export class TeamsMachine extends MachineCore {
2417
2450
  this.pendingInitialInputs.set(key, { abort, task });
2418
2451
  }
2419
2452
 
2453
+ async #deliverClaudeInitialInput(session, contextKey, input, signal) {
2454
+ const deadline = input.deadlineMs ?? Date.now() + INITIAL_INPUT_READY_DEADLINE_MS + 10_000;
2455
+ const pause = () => new Promise(resolve => setTimeout(resolve, 250));
2456
+ let identity;
2457
+ while (!signal.aborted && Date.now() < deadline) {
2458
+ if ((await this.#paneByContext(contextKey))?.id !== session.id) return { delivered: false, reason: 'pane-gone' };
2459
+ const start = readJson(input.binding.replace(/\.json$/, '.session.json'));
2460
+ const id = /([0-9a-f]{8}-[0-9a-f-]{27})\.jsonl$/i.exec(String(start?.path ?? ''))?.[1];
2461
+ if (id && Number.isSafeInteger(start.offset) && start.offset >= 0) {
2462
+ identity = { kind: 'claude-code', path: start.path, sessionId: id, offset: start.offset };
2463
+ break;
2464
+ }
2465
+ await pause();
2466
+ }
2467
+ if (!identity) return { delivered: false, refused: !signal.aborted, reason: signal.aborted ? 'aborted' : 'native-session-unresolved' };
2468
+ const options = { messageIds: [`first-${basename(input.binding, '.json')}`], deadlineMs: deadline };
2469
+ let result;
2470
+ while (!signal.aborted && Date.now() < deadline) {
2471
+ if ((await this.#paneByContext(contextKey))?.id !== session.id) return { delivered: false, queued: Boolean(result?.queued), reason: 'pane-gone' };
2472
+ result = await this.#terminalMail(session, contextKey, identity.kind, input.text, options, identity);
2473
+ if (result.delivered || result.refused) return { ...result, native_session: identity.sessionId };
2474
+ await pause();
2475
+ }
2476
+ return { ...result, delivered: false, refused: !signal.aborted && !result?.queued,
2477
+ reason: signal.aborted ? 'aborted' : result?.reason ?? 'native-input-unconfirmed' };
2478
+ }
2479
+
2480
+ /** Recover only the current launch's saved first input; the stable id joins
2481
+ * any queue already retained before this daemon ended. */
2482
+ #recoverClaudeInitialInputs(panes, onlyKey = null) {
2483
+ for (const launch of this.launches.all()) {
2484
+ if ((onlyKey && launch.key !== onlyKey) || launch.kind !== 'claude-code' || !launch.nativeInputBinding ||
2485
+ launch.inputDelivered || launch.inputRefused || this.pendingInitialInputs.has(launch.key) ||
2486
+ !['issued', 'ready'].includes(launch.status)) continue;
2487
+ const seed = readJson(launch.nativeInputBinding);
2488
+ if (seed?.key !== launch.key || seed.pane !== launch.pane || seed.by !== launch.by || !seed.claudeInput?.text) continue;
2489
+ const session = panes.find(row => row.contextKey === launch.pane && !row.dead);
2490
+ if (!session) {
2491
+ this.launches.record({ ...launch, inputDelivered: false, inputRefused: true, inputPending: false, inputError: 'pane-gone' });
2492
+ continue;
2493
+ }
2494
+ const { t: _previousAt, ...facts } = launch;
2495
+ this.#deliverInitialInput({ key: launch.key, contextKey: launch.pane, session, kind: launch.kind, by: launch.by,
2496
+ input: seed.claudeInput.text, nativeInput: { ...seed.claudeInput, binding: launch.nativeInputBinding },
2497
+ executable: { ...facts, nativeInputBinding: launch.nativeInputBinding },
2498
+ act: (cap, target, outcome, detail) => this.log.append({ principal: null, cap, target, outcome, detail }) });
2499
+ }
2500
+ }
2501
+
2420
2502
  async #drainTerminalMail() {
2421
2503
  // One pane listing per tick, shared by every mailbox: a listing per mailbox each second was most of this
2422
2504
  // daemon's tmux spawns (8 retained mailboxes, about 70 tmux processes a second, t_23e47b73).
2423
2505
  const retained = retainedTerminalMail(this.terminalMailRoot);
2424
- if (!retained.length && !this.terminalMailboxes.size) return;
2506
+ if (this.initialInputsRecovered && !retained.length && !this.terminalMailboxes.size) return;
2425
2507
  const panes = await this.host.listSessions();
2508
+ if (!this.initialInputsRecovered) {
2509
+ this.initialInputsRecovered = true;
2510
+ this.#recoverClaudeInitialInputs(panes);
2511
+ }
2426
2512
  const paneByContext = (contextKey) => panes.find((s) => s.contextKey === contextKey) ?? null;
2427
2513
  for (const saved of retained) {
2428
2514
  if (this.terminalMailboxes.has(saved.recipient) || !saved.binding) continue;
2429
2515
  const session = paneByContext(saved.binding.context);
2430
- if (!session || session.id !== saved.binding.terminal || session.rootPid !== saved.binding.pid) continue;
2431
- const queue = new TerminalMailbox(this.terminalMailRoot, saved.recipient, receipt => this.log.append({ principal: null, cap: 'control',
2432
- target: `pane:${saved.binding.context}`, outcome: receipt.delivered ? 'input-delivered' : 'input-pending', detail: JSON.stringify(receipt) }), (locator, options) => this.#serveRequest('harness.v1.sessions.load', { locator, options: { ...options, include_subagents: false } }, 10000));
2516
+ if (!session || session.rootPid !== saved.binding.pid) continue;
2517
+ const queue = new TerminalMailbox(this.terminalMailRoot, saved.recipient, receipt => this.#recordTerminalReceipt(saved.binding.context, receipt),
2518
+ (locator, options) => this.#serveRequest('harness.v1.sessions.load', { locator, options: { ...options, include_subagents: false } }, 10000));
2433
2519
  queue.identity = saved.binding.identity; queue.host = this.host; queue.session = session;
2520
+ // A tmux host remints its client IDs after restart. Context and the live
2521
+ // process still bind this queue; drain verifies its pane-held token
2522
+ // before adopting a pasted batch under the new client ID.
2523
+ queue.state.binding.terminal = session.id; queue.save();
2434
2524
  this.terminalMailboxes.set(saved.recipient, queue);
2435
2525
  }
2436
2526
  // A queue whose pane has ended is not retried every second into a terminal that no longer
@@ -2443,7 +2533,23 @@ export class TeamsMachine extends MachineCore {
2443
2533
  await Promise.all([...this.terminalMailboxes.values()].filter(queue => !this.inputRepairs[queue.state.binding?.context] && !this.inputInFlight.has(queue.session?.id)).map(queue => queue.kick()));
2444
2534
  }
2445
2535
 
2446
- async #terminalMail(session, contextKey, kind, text, options) {
2536
+ #recordTerminalReceipt(contextKey, receipt) {
2537
+ this.log.append({ principal: null, cap: 'control', target: `pane:${contextKey}`,
2538
+ outcome: receipt.delivered ? 'input-delivered' : receipt.refused ? 'input-refused' : 'input-pending', detail: JSON.stringify(receipt) });
2539
+ if (!receipt.delivered && !receipt.refused) return;
2540
+ for (const id of receipt.message_ids ?? []) {
2541
+ if (!/^first-[A-Za-z0-9_-]+$/.test(id)) continue;
2542
+ const path = join(this.terminalMailRoot, 'native-launches', `${id.slice(6)}.json`);
2543
+ const seed = readJson(path), launch = seed?.key && this.launches.find(seed.key);
2544
+ if (!launch || seed.pane !== contextKey || launch.pane !== contextKey) continue;
2545
+ const native = /([0-9a-f]{8}-[0-9a-f-]{27})\.jsonl$/i.exec(String(readJson(path.replace(/\.json$/, '.session.json'))?.path ?? ''))?.[1];
2546
+ const { t: _previousAt, ...facts } = launch;
2547
+ this.launches.record({ ...facts, inputDelivered: receipt.delivered, inputPending: false, inputRefused: receipt.refused === true,
2548
+ ...(receipt.delivered ? { inputReceipt: receipt.receipt, inputError: null, ...(native ? { native_session: native } : {}) } : { inputError: receipt.reason }) });
2549
+ }
2550
+ }
2551
+
2552
+ async #terminalMail(session, contextKey, kind, text, options, identity = null) {
2447
2553
  // Mail a queue of this terminal already holds (the watch asking again after the same message ids) is answered from
2448
2554
  // that queue, which types it once the composer frees: nothing is asked of the harness again for it.
2449
2555
  const ids = options.messageIds ?? [];
@@ -2453,29 +2559,33 @@ export class TeamsMachine extends MachineCore {
2453
2559
  if (binding?.context === contextKey && binding.terminal === session.id && ids.every(id => held.state.accepted?.[id])) return held.receipt(ids);
2454
2560
  }
2455
2561
  }
2456
- const activity = await this.#serveRequest('harness.v1.sessions.activity_under', { pids: [session.rootPid] });
2457
- const under = activity?.activities?.find(row => row.pid === session.rootPid);
2458
- const native = under?.activity;
2459
- if (!native?.session_id) return { delivered: false, reason: 'native-session-unresolved' };
2460
- const key = `${kind}:${native.session_id}`;
2562
+ let under;
2563
+ if (!identity) {
2564
+ const activity = await this.#serveRequest('harness.v1.sessions.activity_under', { pids: [session.rootPid] });
2565
+ under = activity?.activities?.find(row => row.pid === session.rootPid);
2566
+ const native = under?.activity;
2567
+ if (!native?.session_id) return { delivered: false, reason: 'native-session-unresolved' };
2568
+ identity = { kind, sessionId: native.session_id };
2569
+ }
2570
+ const key = `${kind}:${identity.sessionId}`;
2461
2571
  let queue = this.terminalMailboxes.get(key);
2462
2572
  // A session's transcript does not move: its queue knows it once found, and the harness names it with the session it
2463
2573
  // found under the pane (a supercode from t_09a795fd on). Only an older one leaves a discovery to make.
2464
2574
  const known = queue?.state.binding?.identity;
2465
- let path = known?.path, locator = known?.locator;
2575
+ let path = identity.path ?? known?.path, locator = identity.locator ?? known?.locator;
2466
2576
  if (!locator && under?.locator?.storage?.path) { locator = under.locator; path = under.locator.storage.path; }
2467
- if (!locator) {
2468
- const found = await this.#serveRequest('harness.v1.sessions.discover', { query: native.session_id, harnesses: [kind], limit: 20 });
2469
- const row = found?.sessions?.find(row => row.locator?.session_id === native.session_id);
2577
+ if (!locator && !path) {
2578
+ const found = await this.#serveRequest('harness.v1.sessions.discover', { query: identity.sessionId, harnesses: [kind], limit: 20 });
2579
+ const row = found?.sessions?.find(row => row.locator?.session_id === identity.sessionId);
2470
2580
  path = row?.locator?.storage?.path; locator = row?.locator;
2471
2581
  }
2472
- if (!locator || (kind === 'codex' && !path)) return { delivered: false, reason: 'native-transcript-unresolved' };
2582
+ if ((!locator && !['codex', 'claude-code'].includes(kind)) || (['codex', 'claude-code'].includes(kind) && !path)) return { delivered: false, reason: 'native-transcript-unresolved' };
2473
2583
  if (!queue) {
2474
- queue = new TerminalMailbox(this.terminalMailRoot, key, receipt => this.log.append({ principal: null, cap: 'control', target: `pane:${contextKey}`,
2475
- outcome: receipt.delivered ? 'input-delivered' : 'input-pending', detail: JSON.stringify(receipt) }), (locator, options) => this.#serveRequest('harness.v1.sessions.load', { locator, options: { ...options, include_subagents: false } }, 10000));
2584
+ queue = new TerminalMailbox(this.terminalMailRoot, key, receipt => this.#recordTerminalReceipt(contextKey, receipt),
2585
+ (locator, options) => this.#serveRequest('harness.v1.sessions.load', { locator, options: { ...options, include_subagents: false } }, 10000));
2476
2586
  this.terminalMailboxes.set(key, queue);
2477
2587
  }
2478
- const enqueued = queue.enqueue(text, options.messageIds, { kind, path, locator }, this.host, session);
2588
+ const enqueued = queue.enqueue(text, options.messageIds, { ...identity, kind, path, locator }, this.host, session, { deadlineMs: options.deadlineMs });
2479
2589
  return queue.receipt(enqueued);
2480
2590
  }
2481
2591
 
@@ -2610,22 +2720,11 @@ export class TeamsMachine extends MachineCore {
2610
2720
  async #deliverPaneText(session, contextKey, text, options = {}) {
2611
2721
  const kind = this.#paneKind(contextKey, session);
2612
2722
  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' };
2613
- if (['codex', 'grok', 'gemini', 'goose', 'opencode', 'pi', 'hermes', 'openclaw'].includes(kind)) return this.#terminalMail(session, contextKey, kind, submission(text, false), options);
2614
2723
  if(this.inputRepairs[contextKey])return {delivered:false,reason:'input-cleanup-pending',cleanup_required:true};
2724
+ if (['claude-code', 'codex', 'grok', 'gemini', 'goose', 'opencode', 'pi', 'hermes', 'openclaw'].includes(kind)) return this.#terminalMail(session, contextKey, kind, submission(text, false), options);
2615
2725
  if (this.inputInFlight.has(session.id)) return { delivered: false, reason: 'input-in-flight' };
2616
2726
  this.inputInFlight.add(session.id);
2617
2727
  try {
2618
- if (kind === 'claude-code') {
2619
- if (typeof terminal.deliverComposerInput !== 'function') return { delivered: false, reason: 'terminal-package-too-old' };
2620
- const result=await terminal.deliverComposerInput(this.host, session.id, kind, submission(text, false), options);
2621
- if(result.cleanup_required){
2622
- this.inputRepairs[contextKey]={session:session.id,kind,text:result.cleanup_remaining??submission(text,false),at:Date.now(),reason:result.cleanup_reason};
2623
- writeJson(this.inputRepairsPath,this.inputRepairs);this.#watchInputRepairs();
2624
- this.log.append({principal:null,cap:'control',target:`pane:${contextKey}`,outcome:'input-cleanup-pending',detail:result.cleanup_reason});
2625
- }
2626
- const {cleanup_remaining,...receipt}=result;
2627
- return receipt;
2628
- }
2629
2728
  const delivery = await this.host.sendInput(session.id, submission(text), { ...options, submitMode: 'single' });
2630
2729
  return { delivered: true, delivery };
2631
2730
  } catch (error) {
@@ -2737,9 +2836,9 @@ export class TeamsMachine extends MachineCore {
2737
2836
  * deadline neither, for `INITIAL_INPUT_READY_DEADLINE_MS`. The caller
2738
2837
  * leaves input undelivered without typing into that screen.
2739
2838
  */
2740
- async #awaitInputReady(session, contextKey, kind, signal) {
2839
+ async #awaitInputReady(session, contextKey, kind, signal, deadlineMs = null) {
2741
2840
  const composerKind = this.#paneKind(contextKey, session);
2742
- const until = Date.now() + INITIAL_INPUT_READY_DEADLINE_MS;
2841
+ const until = deadlineMs ?? Date.now() + INITIAL_INPUT_READY_DEADLINE_MS;
2743
2842
  let readySince = null;
2744
2843
  while (!signal.aborted && Date.now() < until) {
2745
2844
  const capture = await Promise.resolve().then(() => this.host.capture(session.id, { preserveEscapes: true })).catch(() => '');
@@ -3405,7 +3504,10 @@ export class TeamsMachine extends MachineCore {
3405
3504
  const session = await this.#paneByContext(existing.pane);
3406
3505
  const latest = this.launches.find(key);
3407
3506
  if (JSON.stringify(latest) !== JSON.stringify(existing)) { existing = latest; continue; }
3408
- if (session && !session.dead) return { key, pane: existing.pane, reused: true, status: existing.status };
3507
+ if (session && !session.dead) {
3508
+ this.#recoverClaudeInitialInputs([session], key);
3509
+ return { key, pane: existing.pane, reused: true, status: existing.status };
3510
+ }
3409
3511
  }
3410
3512
  break;
3411
3513
  }
@@ -3435,7 +3537,7 @@ export class TeamsMachine extends MachineCore {
3435
3537
  }
3436
3538
  if (params.kind === 'codex') params = { ...params, arguments: canonicalCodexFolderTrust(params.cwd, params.arguments) };
3437
3539
  session = await this.host.createSession({
3438
- command: this.#nativeQuestionCommand(params, contextKey, by, (value) => { nativeInput = value; }),
3540
+ command: this.#nativeQuestionCommand({ ...params, key }, contextKey, by, (value) => { nativeInput = value; }),
3439
3541
  contextKey,
3440
3542
  cwd: params.cwd,
3441
3543
  // A pane that runs one verb for its launcher (`close`/`stop` on another machine) is told who that is; what a
@@ -3452,6 +3554,7 @@ export class TeamsMachine extends MachineCore {
3452
3554
  act('launch', `pane:${contextKey}`, 'failed', error.code ?? error.message);
3453
3555
  throw error;
3454
3556
  }
3557
+ if (nativeInput?.binding) executable = { ...executable, nativeInputBinding: nativeInput.binding };
3455
3558
  // The agent the launch's environment names is kept with the launch: the pane's session is bound to it once the
3456
3559
  // session is seen in the pane (sessionBindings).
3457
3560
  const agent = launchAgent(params.env);
@@ -4241,7 +4344,11 @@ function initialInputFacts(record) {
4241
4344
  if (record?.inputDelivered === undefined) return {};
4242
4345
  return {
4243
4346
  inputDelivered: Boolean(record.inputDelivered),
4347
+ ...(record.inputPending !== undefined ? { inputPending: record.inputPending === true } : {}),
4348
+ ...(record.inputRefused !== undefined ? { inputRefused: record.inputRefused === true } : {}),
4244
4349
  inputReadyBy: record.inputReadyBy ?? null,
4350
+ ...(record.inputReceipt ? { inputReceipt: record.inputReceipt } : {}),
4351
+ ...(record.native_session ? { native_session: record.native_session } : {}),
4245
4352
  ...(record.inputDeliveredLate ? { inputDeliveredLate: true } : {}),
4246
4353
  ...(record.inputError ? { inputError: record.inputError } : {}),
4247
4354
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-teams",
3
- "version": "0.3.136",
3
+ "version": "0.3.138",
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.35",
73
+ "@volter/teams": "0.2.36",
74
74
  "@xterm/addon-serialize": "^0.14.0",
75
75
  "@xterm/headless": "^6.0.0",
76
76
  "protobufjs": "8.8.0",
package/terminal-mail.mjs CHANGED
@@ -6,6 +6,7 @@ import { createHash, randomUUID } from 'node:crypto';
6
6
  import { spawnSync } from 'node:child_process';
7
7
  import { StringDecoder } from 'node:string_decoder';
8
8
  import { codexUpdatePrompt, codexWindowsPastes } from './codex-startup.mjs';
9
+ import { composerText, restoreComposerInput } from '@volter/supercode-terminal';
9
10
 
10
11
  // When process `pid` started (ms), from `ps -o etime=` ([[dd-]hh:]mm:ss); null when it cannot be read.
11
12
  function startedAt(pid) {
@@ -19,9 +20,42 @@ function startedAt(pid) {
19
20
 
20
21
  const hash = value => createHash('sha256').update(value).digest('hex');
21
22
  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;
23
+ const incrementalKind = kind => kind === 'codex' || kind === 'claude-code';
24
+ const transcriptSize = path => { try { return statSync(path).size; } catch (error) { if (error.code === 'ENOENT') return 0; throw error; } };
25
+ const pause = ms => new Promise(resolve => setTimeout(resolve, ms));
26
+ const normalized = text => String(text).replace(/\r\n?/g, '\n').trim();
27
+
28
+ function claudeHolds(batch, shown, owned = false) {
29
+ if (shown == null || shown === '') return false;
30
+ const wanted = normalized(batch.text);
31
+ if (shown.trim() === wanted) return true;
32
+ // Screen wrapping may add row breaks or consume one original space/newline.
33
+ // Every other character must remain exact; whitespace is never erased.
34
+ const rows = shown.trim().split('\n');
35
+ let offsets = new Set([0]);
36
+ for (let row = 0; row < rows.length; row++) {
37
+ const next = new Set();
38
+ for (const offset of offsets) {
39
+ if (!wanted.startsWith(rows[row], offset)) continue;
40
+ const end = offset + rows[row].length;
41
+ if (row === rows.length - 1) next.add(end);
42
+ else { next.add(end); if (wanted[end] === ' ' || wanted[end] === '\n') next.add(end + 1); }
43
+ }
44
+ offsets = next;
45
+ }
46
+ if (offsets.has(wanted.length)) return true;
47
+ const placeholder = /^\[Pasted text #\d+(?: \+(\d+) lines?)?\]$/u.exec(shown.trim());
48
+ const breaks = text => (text.match(/\n/gu) ?? []).length;
49
+ if (!placeholder || (placeholder[1] === undefined
50
+ ? breaks(normalized(batch.text)) !== 0
51
+ : ![breaks(normalized(batch.text)), breaks(`${batch.text}\n`)].includes(Number(placeholder[1])))) return false;
52
+ if (owned && !batch.placeholder) batch.placeholder = placeholder[0];
53
+ return batch.placeholder === placeholder[0];
54
+ }
22
55
 
23
56
  function confirmed(batch) {
24
- const fd = openSync(batch.path, 'r');
57
+ let fd;
58
+ try { fd = openSync(batch.path, 'r'); } catch (error) { if (error.code === 'ENOENT') return false; throw error; }
25
59
  try {
26
60
  let offset = batch.offset, buffered = '';
27
61
  const decoder = new StringDecoder('utf8');
@@ -36,10 +70,15 @@ function confirmed(batch) {
36
70
  const line = buffered.slice(0, end); buffered = buffered.slice(end + 1);
37
71
  let row; try { row = JSON.parse(line); } catch { continue; }
38
72
  const content = batch.kind === 'codex' && row.type === 'response_item' && row.payload?.type === 'message' && row.payload.role === 'user'
39
- ? row.payload.content : batch.kind === 'grok' && row.type === 'user' ? row.content : null;
73
+ ? row.payload.content : batch.kind === 'claude-code' && row.type === 'user' && !row.isMeta && (!batch.sessionId || row.sessionId === batch.sessionId)
74
+ ? row.message?.content : batch.kind === 'grok' && row.type === 'user' ? row.content : null;
40
75
  const text = textOf(content);
41
76
  if (text === null || text.length === 0) continue;
42
77
  if (text === batch.text) return { fragments: 1 };
78
+ if (batch.kind === 'claude-code') {
79
+ if (normalized(text) === normalized(batch.text)) return { fragments: 1 };
80
+ continue;
81
+ }
43
82
  // A Windows Codex is pasted its text with line ends as `\n` (codexWindowsPastes).
44
83
  if (batch.kind === 'codex' && text === batch.text.replace(/\r\n?/g, '\n')) return { fragments: 1 };
45
84
  // Older mail delivery omitted the paste delimiter, so terminal newlines
@@ -78,11 +117,13 @@ export class TerminalMailbox {
78
117
  }
79
118
  receipt(ids) {
80
119
  const delivered = ids.every(id => this.state.receipts.some(r => r.message_ids.includes(id)));
120
+ const refused = (this.state.refusals ?? []).find(r => r.message_ids.some(id => ids.includes(id)));
121
+ if (!delivered && refused) return { delivered: false, queued: false, refused: true, message_ids: ids, reason: refused.reason };
81
122
  const batch = this.state.active;
82
123
  return { delivered, queued: !delivered, message_ids: ids,
83
124
  ...(delivered ? { receipt: this.state.receipts.some(r => r.receipt === 'native-user-messages' && r.message_ids.some(id => ids.includes(id))) ? 'native-user-messages' : 'native-user-message' } : { reason: batch?.error ?? 'queued', batch: batch?.id ?? null }) };
84
125
  }
85
- enqueue(text, ids, identity, host, session) {
126
+ enqueue(text, ids, identity, host, session, { deadlineMs = null } = {}) {
86
127
  const messageIds = ids?.length ? ids : [`input-${randomUUID()}`];
87
128
  if (messageIds.some(id => typeof id !== 'string' || !/^[A-Za-z0-9_-]{1,160}$/.test(id))) throw new Error('invalid terminal message id');
88
129
  const digest = hash(text);
@@ -92,7 +133,7 @@ export class TerminalMailbox {
92
133
  if (!previous.length) {
93
134
  for (const id of messageIds) accepted[id] = digest;
94
135
  // New mail joins the next paste while the current one awaits its receipt.
95
- this.state.pending.push({ ids: messageIds, text }); this.save();
136
+ this.state.pending.push({ ids: messageIds, text, deadlineMs }); this.save();
96
137
  }
97
138
  this.identity = identity; this.host = host; this.session = session;
98
139
  this.state.binding = { context: session.contextKey, terminal: session.id, pid: session.rootPid, identity }; this.save();
@@ -113,48 +154,149 @@ export class TerminalMailbox {
113
154
  }).finally(() => { this.work = null; });
114
155
  return this.work;
115
156
  }
157
+ async refuseClaude(batch) {
158
+ if (!batch.inputOwner || !await this.host.ownsInput?.(this.session.id, batch.inputOwner)) {
159
+ await this.recordRefusal(batch, `${batch.confirmationError ?? 'native transcript unconfirmed'}; input ownership unavailable; composer preserved`);
160
+ return;
161
+ }
162
+ const screen = await this.host.capture(this.session.id, { lines: 200, preserveEscapes: true });
163
+ const shown = composerText(batch.kind, screen);
164
+ const own = claudeHolds(batch, shown, true);
165
+ // One final read/Enter at the deadline, then a bounded native receipt
166
+ // grace. A badge that painted late never goes straight to refusal.
167
+ if (own && !batch.confirmationError && !batch.finalEnter && !/\besc to (?:interrupt|cancel)\b/iu.test(screen)) {
168
+ batch.finalEnter = true; batch.phase = 'pasted'; batch.expiresAt = Date.now() + 5000;
169
+ batch.enters++; batch.last_enter = Date.now(); this.save();
170
+ await this.host.sendInput(this.session.id, '\r', { submitMode: 'single', inputOwner: batch.inputOwner });
171
+ this.log({ delivered: false, queued: true, batch: batch.id, message_ids: batch.message_ids, attempt: batch.enters, final: true, at: Date.now() });
172
+ return;
173
+ }
174
+ const reason = batch.confirmationError ?? 'native transcript unconfirmed at delivery deadline';
175
+ if (shown === null || (!own && shown !== '')) {
176
+ await this.recordRefusal(batch, `${reason}; composer changed or unreadable; composer preserved`); return;
177
+ }
178
+ if (own) {
179
+ const result = await restoreComposerInput(this.host, this.session.id, batch.kind, normalized(batch.text), { pasted: batch.placeholder ?? null, inputOwner: batch.inputOwner });
180
+ if (!result.restored) {
181
+ await this.recordRefusal(batch, `${reason}; cleanup unconfirmed (${result.reason}); composer preserved`); return;
182
+ }
183
+ }
184
+ await this.recordRefusal(batch, `${reason}; ${own ? 'own unsent input restored' : 'composer empty'}`);
185
+ }
186
+ async recordRefusal(batch, reason) {
187
+ const receipt = { delivered: false, queued: false, refused: true, reason,
188
+ batch: batch.id, message_ids: batch.message_ids, at: Date.now() };
189
+ if (batch.inputOwner) await this.releaseOwner(batch);
190
+ (this.state.refusals ??= []).push(receipt);
191
+ this.state.active = null; this.save(); this.log(receipt);
192
+ }
193
+ async releaseOwner(batch) {
194
+ try { await this.host.releaseInput?.(this.session.id, batch.inputOwner); }
195
+ catch (error) {
196
+ // An older pane server never acquired this token. A closed pane has no
197
+ // composer to release. Other failures remain explicit in the receipt.
198
+ if (!['unsupported', 'session_not_found'].includes(error.code)) throw error;
199
+ }
200
+ }
116
201
  async drain() {
117
202
  let batch = this.state.active;
118
203
  if (!batch && this.state.pending.length) {
119
204
  const items = []; let size = 0;
120
205
  for (const message of this.state.pending) {
121
206
  if (items.length && size + Buffer.byteLength(message.text) > 32768) break;
207
+ if (items.length && (items[0].deadlineMs ?? null) !== (message.deadlineMs ?? null)) break;
122
208
  items.push(message); size += Buffer.byteLength(message.text);
123
209
  }
124
210
  const text = items.map(m => m.text).join('\n\n');
125
- const incremental = this.identity.kind === 'codex';
211
+ const incremental = incrementalKind(this.identity.kind);
126
212
  const baseline = incremental ? null : await this.load(this.identity.locator, { message_tail: 1 });
127
213
  batch = { id: randomUUID(), message_ids: items.flatMap(m => m.ids), text, kind: this.identity.kind,
128
- path: this.identity.path, offset: incremental ? statSync(this.identity.path).size : null,
129
- locator: this.identity.locator, messageOffset: baseline?.window?.total_messages ?? null, phase: 'prepared', enters: 0, last_enter: 0 };
214
+ path: this.identity.path, sessionId: this.identity.sessionId ?? this.identity.locator?.session_id,
215
+ offset: incremental ? this.identity.offset ?? transcriptSize(this.identity.path) : null,
216
+ locator: this.identity.locator, messageOffset: baseline?.window?.total_messages ?? null, phase: 'prepared', enters: 0, last_enter: 0,
217
+ ...(this.identity.kind === 'claude-code' ? { inputOwner: randomUUID(), expiresAt: items[0].deadlineMs ?? null } : {}) };
130
218
  this.state.active = batch; this.state.pending = this.state.pending.filter(message => !items.includes(message)); this.save();
131
219
  }
132
220
  if (!batch) return;
133
- const seen = batch.offset !== null ? confirmed(batch) : (await this.load(batch.locator, { message_offset: batch.messageOffset, message_limit: 1000 })).session?.messages?.some(message => message.role === 'user' && textOf(message.content) === batch.text);
221
+ let seen;
222
+ try {
223
+ seen = batch.offset !== null ? confirmed(batch) : (await this.load(batch.locator, { message_offset: batch.messageOffset, message_limit: 1000 })).session?.messages?.some(message => message.role === 'user' && textOf(message.content) === batch.text);
224
+ batch.confirmationError = null;
225
+ } catch (error) {
226
+ if (batch.kind !== 'claude-code') throw error;
227
+ // A failed receipt read must reach the same guarded cleanup as expiry.
228
+ // It never justifies another paste or an Enter we cannot confirm.
229
+ batch.confirmationError = `native transcript unavailable: ${error.code ?? error.message}`;
230
+ if (batch.phase === 'prepared') await this.recordRefusal(batch, `${batch.confirmationError}; no input pasted`);
231
+ else await this.refuseClaude(batch);
232
+ return;
233
+ }
134
234
  if (seen) {
135
235
  const receipt = { batch: batch.id, message_ids: batch.message_ids, delivered: true, receipt: seen.fragments > 1 ? 'native-user-messages' : 'native-user-message', fragments: seen.fragments ?? 1, at: Date.now() };
136
236
  appendFileSync(join(this.dir, 'receipts.jsonl'), JSON.stringify(receipt) + '\n', { mode: 0o600 });
237
+ if (batch.inputOwner) await this.releaseOwner(batch);
137
238
  this.state.receipts.push(receipt);
138
239
  this.state.active = null; this.save(); this.log(receipt); return;
139
240
  }
241
+ if (batch.kind === 'claude-code' && batch.phase !== 'prepared') {
242
+ if (!batch.inputOwner || !await this.host.ownsInput?.(this.session.id, batch.inputOwner)) {
243
+ await this.recordRefusal(batch, 'native transcript unconfirmed; input ownership unavailable; composer preserved'); return;
244
+ }
245
+ // tmux host IDs belong to a client, not the lasting pane. Only the same
246
+ // live process AND the pane's saved token authorize adopting that ID.
247
+ if (batch.pid === this.session.rootPid && batch.terminal !== this.session.id) {
248
+ batch.terminal = this.session.id; this.save();
249
+ }
250
+ }
140
251
  if (batch.phase !== 'prepared' && this.session && this.pastedElsewhere(batch)) {
141
252
  // The paste went to a terminal the session no longer runs in (its pane's process was replaced, a resume): that
142
253
  // composer died with its process and the transcript never got the text, so the batch is pasted again here.
143
254
  // Nothing is guessed: the transcript was read above, from the paste's own offset, and holds no such message.
144
255
  this.log({ delivered: false, queued: true, batch: batch.id, message_ids: batch.message_ids, reason: `pasted into terminal ${batch.terminal ?? 'unrecorded'}, which the session no longer runs in; pasting again into ${this.session.id}` });
145
- const baseline = batch.kind === 'codex' ? null : await this.load(this.identity.locator, { message_tail: 1 });
256
+ const baseline = incrementalKind(batch.kind) ? null : await this.load(this.identity.locator, { message_tail: 1 });
146
257
  Object.assign(batch, { phase: 'prepared', enters: 0, last_enter: 0, error: null, path: this.identity.path, locator: this.identity.locator,
147
- offset: batch.kind === 'codex' ? statSync(this.identity.path).size : null, messageOffset: baseline?.window?.total_messages ?? null });
258
+ offset: incrementalKind(batch.kind) ? transcriptSize(this.identity.path) : null, messageOffset: baseline?.window?.total_messages ?? null,
259
+ expiresAt: batch.expiresAt ?? null, finalEnter: false, placeholder: null,
260
+ ...(batch.kind === 'claude-code' ? { inputOwner: randomUUID() } : {}) });
148
261
  this.save();
149
262
  }
150
263
  if (batch.kind === 'codex' && codexUpdatePrompt(await this.host.capture(this.session.id, { lines: 120 }))) {
151
264
  batch.error = 'codex-update-prompt: delivery retained; restart Codex with check_for_update_on_startup=false';
152
265
  this.save(); this.log({ delivered: false, queued: true, reason: batch.error, message_ids: batch.message_ids }); return;
153
266
  }
267
+ if (batch.kind === 'claude-code' && batch.phase === 'prepared' && batch.expiresAt && Date.now() >= batch.expiresAt) {
268
+ await this.recordRefusal(batch, 'delivery deadline elapsed; no input pasted'); return;
269
+ }
270
+ if (batch.kind === 'claude-code' && (batch.phase === 'refusing' || (batch.expiresAt && Date.now() >= batch.expiresAt))) {
271
+ await this.refuseClaude(batch); return;
272
+ }
154
273
  if (batch.phase === 'prepared') {
274
+ if (batch.kind === 'claude-code') {
275
+ if (!normalized(batch.text) || /[\u0000-\u0008\u000b-\u001f\u007f]/u.test(normalized(batch.text))) {
276
+ await this.recordRefusal(batch, 'unsupported-text; no input pasted'); return;
277
+ }
278
+ try {
279
+ batch.expiresAt ??= Date.now() + 90_000; this.save();
280
+ if (!this.host.acquireInput || !await this.host.acquireInput(this.session.id, batch.inputOwner)) {
281
+ batch.error = 'input ownership busy; no input pasted'; this.save(); return;
282
+ }
283
+ } catch (error) {
284
+ await this.recordRefusal(batch, `input ownership unavailable (${error.code ?? error.message}); no input pasted`); return;
285
+ }
286
+ for (let check = 0; check < 2; check++) {
287
+ const screen = await this.host.capture(this.session.id, { lines: 200, preserveEscapes: true });
288
+ const held = composerText(batch.kind, screen);
289
+ if (held !== '' || /\besc to (?:interrupt|cancel)\b/iu.test(screen)) {
290
+ await this.host.releaseInput(this.session.id, batch.inputOwner);
291
+ batch.error = held == null ? 'no-composer' : held ? 'draft' : 'working'; this.save(); return;
292
+ }
293
+ if (check === 0) await pause(200);
294
+ }
295
+ }
155
296
  // Persist uncertainty before I/O. A daemon restart checks the transcript;
156
297
  // it never guesses that an unacknowledged paste did not happen.
157
- Object.assign(batch, { phase: 'paste_started', terminal: this.session.id, pid: this.session.rootPid ?? null, pasted_at: Date.now() }); this.save();
298
+ Object.assign(batch, { phase: 'paste_started', terminal: this.session.id, pid: this.session.rootPid ?? null, pasted_at: Date.now(),
299
+ ...(batch.kind === 'claude-code' ? { expiresAt: batch.expiresAt ?? Date.now() + 90_000 } : {}) }); this.save();
158
300
  // The terminal host's final newline selects one bracketed paste;
159
301
  // submitMode:none holds the separate Enter until the paste completes. A Windows Codex takes its own pastes:
160
302
  // at most 8000 characters each, non-ASCII as key events (codex-startup.mjs codexWindowsPastes).
@@ -163,23 +305,45 @@ export class TerminalMailbox {
163
305
  await this.host.sendInput(this.session.id, paste, { submitMode: 'none' });
164
306
  await new Promise((resolve) => setTimeout(resolve, 300));
165
307
  }
166
- } else await this.host.sendInput(this.session.id, `${batch.text}\n`, { submitMode: 'none' });
308
+ } else await this.host.sendInput(this.session.id, `${batch.kind === 'claude-code' ? normalized(batch.text) : batch.text}\n`, {
309
+ submitMode: 'none', ...(batch.kind === 'claude-code' ? { inputOwner: batch.inputOwner, deadlineMs: batch.expiresAt } : {}) });
167
310
  batch.phase = 'pasted'; this.save();
168
311
  }
169
312
  if (batch.phase === 'paste_started') {
170
- batch.error = 'paste outcome uncertain; native transcript confirmation pending'; this.save(); return;
313
+ if (batch.kind === 'claude-code' && claudeHolds(batch, composerText(batch.kind, await this.host.capture(this.session.id, { lines: 200, preserveEscapes: true })), true)) {
314
+ batch.phase = 'pasted'; this.save();
315
+ } else {
316
+ batch.error = 'paste outcome uncertain; native transcript confirmation pending'; this.save(); return;
317
+ }
171
318
  }
172
- if (batch.enters < 3 && Date.now() - batch.last_enter >= 5000) {
319
+ if ((batch.kind === 'claude-code' || batch.enters < 3) && Date.now() - batch.last_enter >= 5000) {
320
+ if (batch.kind === 'claude-code') {
321
+ // A receipt read before the paste can become unavailable while the
322
+ // terminal paints it. Re-read before Enter; uncertainty never submits.
323
+ try { if (confirmed(batch)) return; }
324
+ catch (error) {
325
+ batch.confirmationError = `native transcript unavailable: ${error.code ?? error.message}`;
326
+ await this.refuseClaude(batch); return;
327
+ }
328
+ if (batch.expiresAt && Date.now() >= batch.expiresAt) { await this.refuseClaude(batch); return; }
329
+ const screen = await this.host.capture(this.session.id, { lines: 200, preserveEscapes: true });
330
+ // Re-read until the deadline: a Windows paste badge can appear well
331
+ // after one second. Ownership is held by the pane host, across daemon
332
+ // restart, from before the empty reads through receipt/refusal.
333
+ if (!claudeHolds(batch, composerText(batch.kind, screen), true) || /\besc to (?:interrupt|cancel)\b/iu.test(screen)) {
334
+ batch.error = 'composer changed or unreadable; native transcript confirmation pending'; this.save(); return;
335
+ }
336
+ }
173
337
  if (batch.kind === 'codex' && codexUpdatePrompt(await this.host.capture(this.session.id, { lines: 120 }))) {
174
338
  batch.error = 'codex-update-prompt: Enter refused'; this.save(); return;
175
339
  }
176
340
  batch.enters += 1; batch.last_enter = Date.now(); this.save();
177
- await this.host.sendInput(this.session.id, '\r', { submitMode: 'single' });
341
+ await this.host.sendInput(this.session.id, '\r', { submitMode: 'single', ...(batch.kind === 'claude-code' ? { inputOwner: batch.inputOwner } : {}) });
178
342
  const attempt = { delivered: false, queued: true, batch: batch.id, message_ids: batch.message_ids, attempt: batch.enters, at: Date.now() };
179
343
  appendFileSync(join(this.dir, 'attempts.jsonl'), JSON.stringify(attempt) + '\n', { mode: 0o600 });
180
344
  this.log(attempt);
181
345
  }
182
- batch.error = batch.enters >= 3 ? 'native transcript unconfirmed after Enter retries' : 'native transcript confirmation pending';
346
+ batch.error = batch.kind !== 'claude-code' && batch.enters >= 3 ? 'native transcript unconfirmed after Enter retries' : 'native transcript confirmation pending';
183
347
  this.save();
184
348
  }
185
349
  }