@volter/supercode-teams 0.3.137 → 0.3.139
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/connector/index.mjs +2 -1
- package/connector/native-effects.mjs +2 -1
- package/connector/path.mjs +9 -0
- package/machine.mjs +158 -51
- package/package.json +1 -1
- package/terminal-mail.mjs +180 -16
package/connector/index.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { mkdirSync, readFileSync, writeFileSync, renameSync, existsSync, chmodSync,
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync, renameSync, existsSync, chmodSync, statSync, openSync, closeSync, fsyncSync, unlinkSync, readSync, readdirSync, rmSync } from 'node:fs';
|
|
2
|
+
import { realpathSync } from './path.mjs';
|
|
2
3
|
import { redactionBasis } from '@volter/teams/redact';
|
|
3
4
|
import { Redactor } from './redactor.mjs';
|
|
4
5
|
import { basename, dirname, join, sep } from 'node:path';
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
-
import { mkdtempSync, mkdirSync, writeFileSync, rmSync,
|
|
2
|
+
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, statSync } from 'node:fs';
|
|
3
|
+
import { realpathSync } from './path.mjs';
|
|
3
4
|
import { join, resolve, relative, dirname, isAbsolute, sep } from 'node:path';
|
|
4
5
|
|
|
5
6
|
const digest = value => createHash('sha256').update(value).digest('hex');
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { realpathSync as nodeRealpathSync } from 'node:fs';
|
|
2
|
+
|
|
3
|
+
// Rust discovery returns Windows namespace paths (\\?\C:\...). Node's JS
|
|
4
|
+
// resolver tries to lstat the drive component as "C:" and refuses those paths.
|
|
5
|
+
// The OS-native resolver accepts them and preserves filesystem resolution,
|
|
6
|
+
// including junctions and UNC paths. Keep the existing POSIX resolver.
|
|
7
|
+
export const realpathSync = process.platform === 'win32'
|
|
8
|
+
? nodeRealpathSync.native
|
|
9
|
+
: nodeRealpathSync;
|
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
|
-
|
|
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
|
-
|
|
1984
|
-
|
|
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)
|
|
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
|
|
1995
|
-
//
|
|
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
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
//
|
|
2005
|
-
|
|
2006
|
-
|
|
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
|
|
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
|
|
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.
|
|
2431
|
-
const queue = new TerminalMailbox(this.terminalMailRoot, saved.recipient, receipt => this.
|
|
2432
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
|
-
|
|
2460
|
-
|
|
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:
|
|
2469
|
-
const row = found?.sessions?.find(row => row.locator?.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
|
|
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
|
|
2475
|
-
|
|
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)
|
|
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.
|
|
3
|
+
"version": "0.3.139",
|
|
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
|
@@ -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
|
-
|
|
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 === '
|
|
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
|
|
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,
|
|
129
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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()
|
|
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`, {
|
|
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.
|
|
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
|
}
|