@volter/supercode-teams 0.3.105 → 0.3.107
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/codex-startup.mjs +27 -0
- package/machine.mjs +116 -14
- package/mail/carrier.mjs +311 -0
- package/mail/send.mjs +3 -1
- package/mail/store.mjs +7 -0
- package/native-initial-input.mjs +7 -1
- package/package.json +1 -1
- package/terminal-mail.mjs +9 -3
package/codex-startup.mjs
CHANGED
|
@@ -131,3 +131,30 @@ export async function settleCodexHookReview(host, sessionId, { signal, deadlineM
|
|
|
131
131
|
}
|
|
132
132
|
return signal.aborted ? 'aborted' : 'deadline';
|
|
133
133
|
}
|
|
134
|
+
|
|
135
|
+
/** The most one bracketed paste carries into a Windows Codex: ConPTY and Codex drop a whole paste past about 8190
|
|
136
|
+
* characters (measured 2026-10-06, codex-cli 0.160.1: 8180 arrived, 8200 did not), while consecutive pastes join
|
|
137
|
+
* into one composer entry. */
|
|
138
|
+
export const CODEX_WINDOWS_PASTE_CHARS = 8000;
|
|
139
|
+
|
|
140
|
+
/** One character as a Windows Codex reads it from its console. Codex drops some BMP characters that arrive as plain
|
|
141
|
+
* text through ConPTY (measured: `—`, `…`, `✓` vanish while `é`, `日`, and astral `🙂` arrive), so every BMP
|
|
142
|
+
* character past ASCII goes as a win32-input-mode key event carrying its UTF-16 unit (VK_PACKET, 231), which ConPTY
|
|
143
|
+
* hands Codex as exactly that character. ASCII and astral characters go as they are. */
|
|
144
|
+
function codexWindowsCharacter(character) {
|
|
145
|
+
const unit = character.codePointAt(0);
|
|
146
|
+
if (unit < 0x80 || unit > 0xffff) return character;
|
|
147
|
+
return `\u001b[231;0;${unit};1;0;1_\u001b[231;0;${unit};0;0;1_`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Text as the raw writes that paste it into a Windows Codex composer: bracketed pastes of at most
|
|
151
|
+
* CODEX_WINDOWS_PASTE_CHARS characters each, non-ASCII BMP characters as key events. Written as they are (a terminal
|
|
152
|
+
* host turns `\n` into `\r`, which Codex keeps as a line break inside a paste); never submitted here. */
|
|
153
|
+
export function codexWindowsPastes(text) {
|
|
154
|
+
const characters = Array.from(String(text));
|
|
155
|
+
const pastes = [];
|
|
156
|
+
for (let at = 0; at < characters.length; at += CODEX_WINDOWS_PASTE_CHARS) {
|
|
157
|
+
pastes.push(`\u001b[200~${characters.slice(at, at + CODEX_WINDOWS_PASTE_CHARS).map(codexWindowsCharacter).join('')}\u001b[201~`);
|
|
158
|
+
}
|
|
159
|
+
return pastes;
|
|
160
|
+
}
|
package/machine.mjs
CHANGED
|
@@ -41,6 +41,7 @@ import { render } from './mail/envelope.mjs';
|
|
|
41
41
|
import { Agents } from './mail/agents.mjs';
|
|
42
42
|
import { Relays } from './mail/relay.mjs';
|
|
43
43
|
import { serveRelayEndpoint } from './mail/relay-endpoint.mjs';
|
|
44
|
+
import { MailCarrier } from './mail/carrier.mjs';
|
|
44
45
|
import { MailSend } from './mail/send.mjs';
|
|
45
46
|
import { AgentVerbs } from './mail/verbs.mjs';
|
|
46
47
|
import { deliverToRuntime } from './mail/runtime.mjs';
|
|
@@ -92,7 +93,7 @@ import { JobOwner, executionCapabilities, streamProgram, serveWorkspace } from '
|
|
|
92
93
|
import { TerminalMailbox, retainedTerminalMail } from './terminal-mail.mjs';
|
|
93
94
|
import { confirmNativeInitialInput } from './native-initial-input.mjs';
|
|
94
95
|
import { sessionPeople } from './session-people.mjs';
|
|
95
|
-
import { canonicalCodexFolderTrust, codexProgram, codexStartupArguments, codexUpdatePrompt, codexVersion, settleCodexHookReview } from './codex-startup.mjs';
|
|
96
|
+
import { canonicalCodexFolderTrust, codexProgram, codexStartupArguments, codexUpdatePrompt, codexVersion, codexWindowsPastes, settleCodexHookReview } from './codex-startup.mjs';
|
|
96
97
|
import { sessionNoticeTarget } from './session-notice-target.mjs';
|
|
97
98
|
import { serveAppDoor } from './app-door.mjs';
|
|
98
99
|
|
|
@@ -202,6 +203,26 @@ const TURN_CONFIRM_MS = 60_000;
|
|
|
202
203
|
* as the program (`open --new "npm run build"`), that line through this machine's own shell, so a caller on
|
|
203
204
|
* another machine need not know which shell or platform this one has.
|
|
204
205
|
*/
|
|
206
|
+
/** How long the Windows pane host's command line for `program args` is, quoted as it quotes a batch program's
|
|
207
|
+
* arguments (sdk/terminal `batchArgument`: `"` doubled, a `\` run before a `"` doubled, `%` as `%%cd:~,%`), plus
|
|
208
|
+
* the `cmd /e:ON /v:OFF /d /s /c "…"` around it. An upper bound for a program cmd does not run. */
|
|
209
|
+
function windowsCommandLineLength(program, args) {
|
|
210
|
+
const quoted = (value) => {
|
|
211
|
+
let length = 2;
|
|
212
|
+
let backslashes = 0;
|
|
213
|
+
for (const character of String(value)) {
|
|
214
|
+
if (character === '\\') backslashes += 1;
|
|
215
|
+
else {
|
|
216
|
+
if (character === '"') length += backslashes + 1;
|
|
217
|
+
backslashes = 0;
|
|
218
|
+
}
|
|
219
|
+
length += character === '%' ? 8 : 1;
|
|
220
|
+
}
|
|
221
|
+
return length + backslashes;
|
|
222
|
+
};
|
|
223
|
+
return [program, ...args].reduce((total, value) => total + quoted(value) + 1, 0) + 32;
|
|
224
|
+
}
|
|
225
|
+
|
|
205
226
|
function launchCommand(program, args) {
|
|
206
227
|
const line = typeof program === 'string' ? program.trim() : '';
|
|
207
228
|
if (!args.length && /\s/.test(line) && !existsSync(line)) {
|
|
@@ -729,19 +750,44 @@ export class TeamsMachine extends MachineCore {
|
|
|
729
750
|
endpointOf: async () => (await this.relayEndpoint)?.url ?? null,
|
|
730
751
|
arrived: (sessionId, landed) => claudeTranscriptHolds([...live.claudeDirs.keys()].map((dir) => join(dirname(dir), 'projects')), sessionId, landed),
|
|
731
752
|
log: (line) => this.emit('mail-log', line) });
|
|
753
|
+
// a turn or a wake typed into a daemon pane's empty composer, in process (as `panes.turn` types one)
|
|
754
|
+
const typeTurn = async (pane, harness, text, messageIds) => {
|
|
755
|
+
const session = await this.#paneByContext(pane);
|
|
756
|
+
if (!session) throw new Error(`no pane with context ${pane}`);
|
|
757
|
+
const result = await this.#paneTurn(session, pane, text, { messageIds, harness });
|
|
758
|
+
if (result.refused) throw new Error(result.refused.reason);
|
|
759
|
+
return result.waits ?? result;
|
|
760
|
+
};
|
|
732
761
|
this.mailSend = new MailSend({ machine, mailRoot, live, store: this.mailStore, agents: this.agents, relays: this.relays,
|
|
733
762
|
// a send waits on its receiver's door there (a relay's receipt takes up to 90 s), never past MAIL_SEND_MS
|
|
734
763
|
askMachine: (to, request) => this.#askMailDoor(to, request, MAIL_SEND_MS),
|
|
735
764
|
deliverRuntime: deliverToRuntime, codexAnswer: (request) => codexQuestions(request),
|
|
736
765
|
// a user's own turn typed into its pane, in process (as `panes.turn` types one)
|
|
737
|
-
typeTurn
|
|
738
|
-
const session = await this.#paneByContext(pane);
|
|
739
|
-
if (!session) throw new Error(`no pane with context ${pane}`);
|
|
740
|
-
const result = await this.#paneTurn(session, pane, text, { messageIds, harness });
|
|
741
|
-
if (result.refused) throw new Error(result.refused.reason);
|
|
742
|
-
return result.waits ?? result;
|
|
743
|
-
},
|
|
766
|
+
typeTurn,
|
|
744
767
|
log: (line) => this.emit('mail-log', line) });
|
|
768
|
+
// the mailbox's own delivery (step 4b): each wake attempted by its session's door, or carried to its machine
|
|
769
|
+
this.carrier = new MailCarrier({ machine, mailRoot, store: this.mailStore, live,
|
|
770
|
+
handOver: (address, id) => this.mailSend.handOver(address, id),
|
|
771
|
+
typeTurn, askMachine: (to, request) => this.#askMailDoor(to, request, MAIL_SEND_MS),
|
|
772
|
+
log: (line) => this.emit('mail-log', line) }).start();
|
|
773
|
+
// the user's own turns that wait in a pane session's mailbox (a draft in its composer when they came) are typed once
|
|
774
|
+
// its composer is free: tried at each change of its mailbox, and every 2 s while one waits (mail_watch.rs
|
|
775
|
+
// `deliver_waiting_user_turns` read every pane's mailbox every 2 s)
|
|
776
|
+
const turnsWaiting = new Map(); // address → timer
|
|
777
|
+
const typeWaiting = async (address) => {
|
|
778
|
+
clearTimeout(turnsWaiting.get(address)); turnsWaiting.delete(address);
|
|
779
|
+
const session = live.sessions().find((row) => row.address === address);
|
|
780
|
+
const pane = session ? this.mail.paneOf(session) : null;
|
|
781
|
+
if (!pane || !this.mailStore.userTurns(address).length) return;
|
|
782
|
+
try { await this.mailSend.typeUserTurns(address, pane); } catch (error) { this.emit('mail-log', `the user's turns for ${address} wait: ${error.message}`); }
|
|
783
|
+
if (this.mailStore.userTurns(address).length) { const timer = setTimeout(() => void typeWaiting(address), 2000); timer.unref?.(); turnsWaiting.set(address, timer); }
|
|
784
|
+
};
|
|
785
|
+
this.mailStore.on('changed', (address) => { if (address && !turnsWaiting.has(address)) void typeWaiting(address); });
|
|
786
|
+
// the turns already waiting when this listener came (the store read every mailbox at its start, before it), and every
|
|
787
|
+
// mailbox again once per 5 min, so a turn no event named is typed within that bound
|
|
788
|
+
const everyMailbox = () => { for (const address of this.mailStore.addresses()) if (!turnsWaiting.has(address)) void typeWaiting(address); };
|
|
789
|
+
everyMailbox();
|
|
790
|
+
this.turnsSweep = setInterval(everyMailbox, 300_000); this.turnsSweep.unref?.();
|
|
745
791
|
// an agent's verbs (3b-2): its threads, a delegation, its record
|
|
746
792
|
this.agentVerbs = new AgentVerbs({ machine, store: this.mailStore, agents: this.agents, live, supercodeBin: this.supercodeBin, env: this.env,
|
|
747
793
|
paneOf: (session) => this.mail.paneOf(session), mailRoot,
|
|
@@ -1053,6 +1099,8 @@ export class TeamsMachine extends MachineCore {
|
|
|
1053
1099
|
this.stoppingMailWatch = true;
|
|
1054
1100
|
this.mailWatch?.kill('SIGTERM');
|
|
1055
1101
|
void this.relayEndpoint?.then((endpoint) => endpoint?.close()).catch(() => {});
|
|
1102
|
+
this.carrier?.stop();
|
|
1103
|
+
clearInterval(this.turnsSweep);
|
|
1056
1104
|
this.stoppingServe = true;
|
|
1057
1105
|
clearTimeout(this.serveRestart);
|
|
1058
1106
|
// said, so a stop reads as one in the log and never as a serve that broke
|
|
@@ -1754,20 +1802,24 @@ export class TeamsMachine extends MachineCore {
|
|
|
1754
1802
|
const name = String(params.program ?? '').split(/[\\/]/).pop();
|
|
1755
1803
|
// Windows has no Unix endpoint for a pane's own native server, so a fresh Codex there starts with no session to
|
|
1756
1804
|
// type into: its rollout appears only with its first turn. Its first input is therefore Codex's own argv prompt,
|
|
1757
|
-
// as it is on the other platforms, and the receipt is the rollout that records it under CODEX_HOME.
|
|
1758
|
-
// command line cannot carry a line break
|
|
1805
|
+
// as it is on the other platforms, and the receipt is the rollout that records it under CODEX_HOME. Input a
|
|
1806
|
+
// command line cannot carry — a line break (a batch line ends there), or past cmd's 8191 characters
|
|
1807
|
+
// (CreateProcess's 32767 for an .exe) — is pasted into the composer once it is up instead
|
|
1808
|
+
// (#typeCodexFirstInput), under the same receipt.
|
|
1759
1809
|
if (process.platform === 'win32' && params.kind === 'codex' && /^codex(?:\.cmd|\.exe)?$/i.test(name)) {
|
|
1760
|
-
const initialInput = params.initialInput &&
|
|
1810
|
+
const initialInput = params.initialInput && !args.includes('--') &&
|
|
1761
1811
|
!args.some((arg) => arg === '--remote' || arg.startsWith('--remote=')) ? params.initialInput : null;
|
|
1762
1812
|
if (!initialInput) return launchCommand(params.program, args);
|
|
1813
|
+
const typed = /[\r\n\0]/.test(initialInput) || windowsCommandLineLength(params.program, [...args, '--', initialInput]) >
|
|
1814
|
+
(/\.(cmd|bat)$/i.test(params.program) ? 8000 : 32000);
|
|
1763
1815
|
const codexHome = params.env?.CODEX_HOME || this.env.CODEX_HOME || join(homedir(), '.codex');
|
|
1764
1816
|
// A resume appends to the rollout it names (`resume <id>`; `--last` names none). A fork writes a new
|
|
1765
1817
|
// rollout, so its receipt is found as a fresh session's is.
|
|
1766
1818
|
const verb = args.indexOf('resume');
|
|
1767
1819
|
const named = verb >= 0 ? args.slice(verb + 1).find((arg) => /^[0-9a-f]{8}-[0-9a-f-]{27}$/i.test(arg)) : null;
|
|
1768
1820
|
nativeInput({ kind: params.kind, text: initialInput, startedAt: Date.now(), codexSessions: join(codexHome, 'sessions'),
|
|
1769
|
-
cwd: params.cwd || null, continuing: verb >= 0, resumeId: named ?? null });
|
|
1770
|
-
return launchCommand(params.program, [...args, '--', initialInput]);
|
|
1821
|
+
cwd: params.cwd || null, continuing: verb >= 0, resumeId: named ?? null, typed });
|
|
1822
|
+
return launchCommand(params.program, typed ? args : [...args, '--', initialInput]);
|
|
1771
1823
|
}
|
|
1772
1824
|
if (process.platform === 'win32' || !((params.kind === 'codex' && name === 'codex') ||
|
|
1773
1825
|
(params.kind === 'claude-code' && name === 'claude'))) return launchCommand(params.program, args);
|
|
@@ -2065,11 +2117,26 @@ export class TeamsMachine extends MachineCore {
|
|
|
2065
2117
|
#deliverInitialInput({ key, contextKey, session, kind, by, input, act, nativeInput, executable = {}, resumed = {} }) {
|
|
2066
2118
|
const abort = new AbortController();
|
|
2067
2119
|
const task = (async () => {
|
|
2120
|
+
let nativeReadyBy = 'native-prompt';
|
|
2121
|
+
if (nativeInput?.typed) {
|
|
2122
|
+
// A Windows Codex input its command line could not carry: pasted once the composer is up, and confirmed by
|
|
2123
|
+
// the same rollout receipt as an argv prompt. Its pane has no native session to mail yet.
|
|
2124
|
+
nativeReadyBy = await this.#awaitInputReady(session, contextKey, kind, abort.signal);
|
|
2125
|
+
if (nativeReadyBy === 'aborted') return;
|
|
2126
|
+
const typed = nativeReadyBy === 'pane-gone' || nativeReadyBy === 'deadline' ? { delivered: false, reason: nativeReadyBy }
|
|
2127
|
+
: await this.#typeCodexFirstInput(session, input, abort.signal);
|
|
2128
|
+
if (!typed.delivered) {
|
|
2129
|
+
this.launches.record({ key, pane: contextKey, kind, by, ...resumed, ...executable, status: 'ready', inputDelivered: false,
|
|
2130
|
+
inputReadyBy: nativeReadyBy, inputError: typed.reason });
|
|
2131
|
+
act('launch', `pane:${contextKey}`, 'failed', `first input undelivered: ${typed.reason}`);
|
|
2132
|
+
return;
|
|
2133
|
+
}
|
|
2134
|
+
}
|
|
2068
2135
|
if (nativeInput) {
|
|
2069
2136
|
const result = await confirmNativeInitialInput(nativeInput, { signal: abort.signal,
|
|
2070
2137
|
deadlineMs: Date.now() + INITIAL_INPUT_READY_DEADLINE_MS, live: async () => Boolean(await this.#paneByContext(contextKey)) });
|
|
2071
2138
|
this.launches.record({ key, pane: contextKey, kind, by, ...resumed, ...executable, status: 'ready', inputDelivered: result.delivered,
|
|
2072
|
-
inputReadyBy:
|
|
2139
|
+
inputReadyBy: nativeReadyBy, ...(result.delivered ? { inputReceipt: result.receipt } : { inputError: result.reason }),
|
|
2073
2140
|
...(result.native_session ? { native_session: result.native_session } : {}) });
|
|
2074
2141
|
act('launch', `pane:${contextKey}`, result.delivered ? 'ok' : result.queued ? 'input-pending' : 'failed',
|
|
2075
2142
|
result.delivered ? 'first input confirmed by native user message' : `native first input unconfirmed: ${result.reason}`);
|
|
@@ -2211,6 +2278,41 @@ export class TeamsMachine extends MachineCore {
|
|
|
2211
2278
|
return filed?.code === 0 ? null : `the line was typed but its sender was not recorded: ${filed?.text ?? 'the filing door answered nothing'}`;
|
|
2212
2279
|
}
|
|
2213
2280
|
|
|
2281
|
+
/**
|
|
2282
|
+
* A fresh Windows Codex's first input, pasted into its empty composer and submitted once: the input its command
|
|
2283
|
+
* line could not carry. Each bracketed paste stays under CODEX_WINDOWS_PASTE_CHARS, and Codex joins consecutive
|
|
2284
|
+
* ones into one entry. Delivery is not claimed here — the caller's rollout receipt confirms what Codex took.
|
|
2285
|
+
*/
|
|
2286
|
+
async #typeCodexFirstInput(session, text, signal) {
|
|
2287
|
+
const body = String(text).replace(/\r\n?/g, '\n').replace(/\n+$/u, '');
|
|
2288
|
+
if (!body.trim() || /[\u0000-\u0008\u000b-\u001f\u007f]/u.test(body)) return { delivered: false, reason: 'unsupported-text' };
|
|
2289
|
+
if (this.inputInFlight.has(session.id)) return { delivered: false, reason: 'input-in-flight' };
|
|
2290
|
+
this.inputInFlight.add(session.id);
|
|
2291
|
+
try {
|
|
2292
|
+
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
2293
|
+
// Styled, so the composer's dim placeholder ("Ask Codex to do anything") reads as empty.
|
|
2294
|
+
const composer = async () => terminal.composerText?.('codex', await this.host.capture(session.id, { preserveEscapes: true })) ?? null;
|
|
2295
|
+
if (await composer() !== '') return { delivered: false, reason: 'composer-not-empty' };
|
|
2296
|
+
for (const paste of codexWindowsPastes(body)) {
|
|
2297
|
+
if (signal.aborted) return { delivered: false, reason: 'aborted' };
|
|
2298
|
+
await this.host.sendInput(session.id, paste, { submitMode: 'none' });
|
|
2299
|
+
await pause(300);
|
|
2300
|
+
}
|
|
2301
|
+
let held = false;
|
|
2302
|
+
for (let attempt = 0; attempt < 20 && !held && !signal.aborted; attempt += 1) {
|
|
2303
|
+
held = Boolean(await composer());
|
|
2304
|
+
if (!held) await pause(150);
|
|
2305
|
+
}
|
|
2306
|
+
if (!held) return { delivered: false, reason: 'paste-unconfirmed' };
|
|
2307
|
+
await this.host.sendInput(session.id, '\r', { submitMode: 'single' });
|
|
2308
|
+
return { delivered: true };
|
|
2309
|
+
} catch (error) {
|
|
2310
|
+
return { delivered: false, reason: error?.message ?? String(error) };
|
|
2311
|
+
} finally {
|
|
2312
|
+
this.inputInFlight.delete(session.id);
|
|
2313
|
+
}
|
|
2314
|
+
}
|
|
2315
|
+
|
|
2214
2316
|
async #deliverPaneText(session, contextKey, text, options = {}) {
|
|
2215
2317
|
const kind = this.#paneKind(contextKey, session);
|
|
2216
2318
|
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' };
|
package/mail/carrier.mjs
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
// The mailbox's own delivery (D140; docs/architecture/overview.md, step 4b): every message filed with a wake (by a filer
|
|
2
|
+
// that does not wait, a board's dispatcher, a send to a hooked session) is handed to its session by the session's own
|
|
3
|
+
// door, by this daemon. Mail for another machine is carried there through its mail door and filed with the same wake,
|
|
4
|
+
// so that machine's daemon delivers it. The native watch did this by reading every mailbox every two seconds
|
|
5
|
+
// (mail_watch.rs `MailCarrier`); here a wake is followed from its own file event (one read of the mail folder at start),
|
|
6
|
+
// and each is attempted when it is due. Its rules and its records are the native carrier's, file for file:
|
|
7
|
+
// - a failed attempt waits FIRST_RETRY_MS doubling to LAST_RETRY_MS, kept in the wake itself (`wake/<id>`);
|
|
8
|
+
// - a wake expires once it has failed WAKE_ATTEMPTS counted times and its first counted failure is older than its bound
|
|
9
|
+
// (LOCAL_WAKE_LIFETIME_MS here, REMOTE_WAKE_LIFETIME_MS for another machine): the message stays filed and unread, and
|
|
10
|
+
// the expiry is recorded in its mailbox (`expired/<id>.json`, read by `supercode message expired`);
|
|
11
|
+
// - mail for a machine that is offline is not failing: the machine is probed once per LAST_RETRY_MS (its wait kept once
|
|
12
|
+
// per machine, `machines/<machine>.json`, read by `supercode message waiting`), all its mail is carried when it
|
|
13
|
+
// answers, and the mail expires only past OFFLINE_CEILING_MS;
|
|
14
|
+
// - a hand-over is waited for at most HANDOVER_BOUND_MS, and one mailbox's attempt never waits on another's;
|
|
15
|
+
// - each attempt is a line of `carrier.jsonl`.
|
|
16
|
+
import { appendFileSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
17
|
+
import { join } from 'node:path';
|
|
18
|
+
import { render } from './envelope.mjs';
|
|
19
|
+
|
|
20
|
+
const FIRST_RETRY_MS = 2_000;
|
|
21
|
+
const LAST_RETRY_MS = 300_000;
|
|
22
|
+
const WAKE_ATTEMPTS = 8;
|
|
23
|
+
const LOCAL_WAKE_LIFETIME_MS = 30 * 60_000;
|
|
24
|
+
const REMOTE_WAKE_LIFETIME_MS = 3 * 24 * 60 * 60_000;
|
|
25
|
+
const OFFLINE_CEILING_MS = 30 * 24 * 60 * 60_000;
|
|
26
|
+
// a hand-over is waited for this long (mail_watch.rs HANDOVER_BOUND); one still running past it is attempted again later,
|
|
27
|
+
// and the hand-over itself answers 'later' until it settles (MailSend.handOver), so a frozen door holds only its own
|
|
28
|
+
// mailbox's wakes, and only for this long
|
|
29
|
+
const HANDOVER_BOUND_MS = 150_000;
|
|
30
|
+
|
|
31
|
+
const readJson = (path) => { try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return null; } };
|
|
32
|
+
const offline = (text) => String(text).includes('machine_offline');
|
|
33
|
+
const safeMachine = (machine) => String(machine).replace(/[^A-Za-z0-9._-]/g, '_');
|
|
34
|
+
|
|
35
|
+
export class MailCarrier {
|
|
36
|
+
/**
|
|
37
|
+
* `store` the mail store (its events name each wake); `live` the live-session map (a hooked session's pane);
|
|
38
|
+
* `handOver(address, id)` the daemon's hand-over of a filed message to its session's door (mail/send.mjs);
|
|
39
|
+
* `typeTurn(pane, harness, text, ids)` a wake typed into a hooked session's pane; `askMachine(machine, request)` one
|
|
40
|
+
* request to another machine's mail door.
|
|
41
|
+
*/
|
|
42
|
+
constructor({ machine, mailRoot, store, live, handOver, typeTurn, askMachine, log = () => {} }) {
|
|
43
|
+
Object.assign(this, { machine, mailRoot, store, live, handOver, typeTurn, askMachine, log });
|
|
44
|
+
this.timers = new Map(); // `${folder}\0${id}` → timer of a wake here, due
|
|
45
|
+
this.running = new Set(); // mailbox folders with an attempt in flight
|
|
46
|
+
this.machines = new Map(); // machine → timer of its next carry
|
|
47
|
+
this.carrying = new Set(); // machines with a carry in flight
|
|
48
|
+
this.remote = new Map(); // machine → Map(`${folder}\0${id}` → { folder, address, id }): wakes for another machine
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Follow every wake: those filed before this daemon (one read of the mail folder), each as the store names it (its
|
|
52
|
+
* file's event, or a mailbox the store read whole), and every wake again once per LAST_RETRY_MS, so a wake no event
|
|
53
|
+
* named is carried within that bound. */
|
|
54
|
+
start() {
|
|
55
|
+
this.store.on('wake', ({ folder, id }) => this.#wake(folder, id));
|
|
56
|
+
this.#sweep();
|
|
57
|
+
this.sweeper = setInterval(() => this.#sweep(), LAST_RETRY_MS);
|
|
58
|
+
this.sweeper.unref?.();
|
|
59
|
+
return this;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Every mailbox's wakes read from its folder and followed (one already due or scheduled is not scheduled twice). */
|
|
63
|
+
#sweep() {
|
|
64
|
+
let folders = [];
|
|
65
|
+
try { folders = readdirSync(this.mailRoot); } catch { return; }
|
|
66
|
+
for (const folder of folders) this.#rewake(folder);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
stop() { clearInterval(this.sweeper); for (const timer of [...this.timers.values(), ...this.machines.values()]) clearTimeout(timer); this.timers.clear(); this.machines.clear(); }
|
|
70
|
+
|
|
71
|
+
#address(folder) { try { return readFileSync(join(this.mailRoot, folder, 'address'), 'utf8').trim(); } catch { return null; } }
|
|
72
|
+
|
|
73
|
+
/** A wake seen (its file written or changed): attempted when it is due. */
|
|
74
|
+
#wake(folder, id) {
|
|
75
|
+
if (!/^[A-Za-z0-9_-]+$/.test(id)) return;
|
|
76
|
+
const address = this.#address(folder);
|
|
77
|
+
if (!address) return;
|
|
78
|
+
const machine = address.split(':')[1];
|
|
79
|
+
if (machine !== this.machine) {
|
|
80
|
+
if (!this.remote.has(machine)) this.remote.set(machine, new Map());
|
|
81
|
+
this.remote.get(machine).set(`${folder}\0${id}`, { folder, address, id });
|
|
82
|
+
this.#scheduleMachine(machine);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
const key = `${folder}\0${id}`;
|
|
86
|
+
if (this.timers.has(key)) return;
|
|
87
|
+
const state = this.store.wakeState(address, id);
|
|
88
|
+
const timer = setTimeout(() => { this.timers.delete(key); void this.#local(folder, address); }, Math.max(0, (state.next_at_ms ?? 0) - Date.now()));
|
|
89
|
+
timer.unref?.();
|
|
90
|
+
this.timers.set(key, timer);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The wakes of one mailbox here that are due, attempted (mail_watch.rs `tick`, its local half). */
|
|
94
|
+
async #local(folder, address) {
|
|
95
|
+
if (this.running.has(folder)) { setTimeout(() => this.#rewake(folder), 1000).unref?.(); return; }
|
|
96
|
+
this.running.add(folder);
|
|
97
|
+
try {
|
|
98
|
+
const now = Date.now();
|
|
99
|
+
const due = this.#pending(folder, address).filter((id) => (this.store.wakeState(address, id).next_at_ms ?? 0) <= now);
|
|
100
|
+
if (!due.length) return;
|
|
101
|
+
// a session in a daemon pane whose mail is typed there: a hooked one (its hook shows it the rest), and one with no
|
|
102
|
+
// hook and no relay (Grok), whose mail that found its composer busy waits here until it is free
|
|
103
|
+
const session = this.live.sessions().find((row) => row.address === address);
|
|
104
|
+
if ((session?.door === 'hook' || session?.door === 'pane') && session.pane) {
|
|
105
|
+
for (const id of due) {
|
|
106
|
+
const stored = this.store.findIn(address, id);
|
|
107
|
+
const envelope = stored ? readJson(stored.path) : null;
|
|
108
|
+
let carried;
|
|
109
|
+
if (!envelope) carried = { kind: 'filed' };
|
|
110
|
+
else {
|
|
111
|
+
try {
|
|
112
|
+
const typed = await this.typeTurn(session.pane, address.split(':')[2], render(envelope), [id]);
|
|
113
|
+
// held: the daemon's queue for the session types it once the composer frees; nothing asks it again
|
|
114
|
+
carried = typed?.delivered ? { kind: 'delivered' } : typed?.queued ? { kind: 'filed' } : { kind: 'later', reason: 'its pane was not at its prompt' };
|
|
115
|
+
} catch (error) { carried = { kind: 'later', reason: error.message }; }
|
|
116
|
+
}
|
|
117
|
+
this.#record({ to: address, id, path: `${session.door}-pane`, carried });
|
|
118
|
+
this.#settle(folder, address, id, carried);
|
|
119
|
+
}
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
for (const id of due) {
|
|
123
|
+
const found = this.store.findIn(address, id);
|
|
124
|
+
let carried;
|
|
125
|
+
// its recipient has it already (its own read, or a hand-over by an attempt that ended before it was settled):
|
|
126
|
+
// never delivered twice; a read by anyone else does not count
|
|
127
|
+
if (!found || (found.state === 'read' && this.store.claimOf(address, id)?.recipient === true)) carried = { kind: 'filed' };
|
|
128
|
+
else {
|
|
129
|
+
try {
|
|
130
|
+
let bound;
|
|
131
|
+
const answer = await Promise.race([
|
|
132
|
+
this.handOver(address, id),
|
|
133
|
+
new Promise((settle) => { bound = setTimeout(() => settle({ outcome: 'later', detail: `its hand-over was still running after ${HANDOVER_BOUND_MS / 1000} s` }), HANDOVER_BOUND_MS); bound.unref?.(); }),
|
|
134
|
+
]).finally(() => clearTimeout(bound));
|
|
135
|
+
carried = answer?.outcome === 'delivered' ? { kind: 'delivered' } : answer?.outcome === 'filed' ? { kind: 'filed' } : { kind: 'later', reason: answer?.detail ?? 'not handed over' };
|
|
136
|
+
} catch (error) { carried = { kind: 'later', reason: `this machine's daemon did not hand it over: ${error.message}` }; }
|
|
137
|
+
}
|
|
138
|
+
this.#record({ to: address, id, path: 'door', carried });
|
|
139
|
+
this.#settle(folder, address, id, carried);
|
|
140
|
+
}
|
|
141
|
+
} finally {
|
|
142
|
+
this.running.delete(folder);
|
|
143
|
+
this.#rewake(folder);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** A mailbox's wakes followed again (each one scheduled at its own due time). */
|
|
148
|
+
#rewake(folder) {
|
|
149
|
+
let ids = [];
|
|
150
|
+
try { ids = readdirSync(join(this.mailRoot, folder, 'wake')); } catch { return; }
|
|
151
|
+
for (const id of ids) this.#wake(folder, id);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** A mailbox's wakes whose message is still filed; a wake whose message is gone is let go (mailbox.rs `pending_wakes`). */
|
|
155
|
+
#pending(folder, address) {
|
|
156
|
+
let ids = [];
|
|
157
|
+
try { ids = readdirSync(join(this.mailRoot, folder, 'wake')); } catch { return []; }
|
|
158
|
+
return ids.filter((id) => {
|
|
159
|
+
if (this.store.findIn(address, id)) return true;
|
|
160
|
+
try { rmSync(join(this.mailRoot, folder, 'wake', id), { force: true }); } catch { /* gone */ }
|
|
161
|
+
return false;
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ---- another machine's mail ------------------------------------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
#machineWaitPath(machine) { return join(this.mailRoot, 'machines', `${safeMachine(machine)}.json`); }
|
|
168
|
+
|
|
169
|
+
/** A machine's mail carried at its next due time: its wait's next probe when it is offline, else its earliest wake. */
|
|
170
|
+
#scheduleMachine(machine) {
|
|
171
|
+
if (this.machines.has(machine) || this.carrying.has(machine)) return;
|
|
172
|
+
const wait = readJson(this.#machineWaitPath(machine));
|
|
173
|
+
let at = wait?.next_probe_ms ?? null;
|
|
174
|
+
if (at == null) {
|
|
175
|
+
at = Infinity;
|
|
176
|
+
for (const { address, id } of this.#remoteWakes(machine)) at = Math.min(at, this.store.wakeState(address, id).next_at_ms ?? 0);
|
|
177
|
+
if (!Number.isFinite(at)) return;
|
|
178
|
+
}
|
|
179
|
+
const timer = setTimeout(() => { this.machines.delete(machine); void this.#carry(machine); }, Math.max(0, at - Date.now()));
|
|
180
|
+
timer.unref?.();
|
|
181
|
+
this.machines.set(machine, timer);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Every wake filed here for `machine` (as their events named them), with when its message was filed, oldest first; a
|
|
185
|
+
* wake whose file or message is gone is let go. */
|
|
186
|
+
#remoteWakes(machine) {
|
|
187
|
+
const items = [];
|
|
188
|
+
for (const [key, item] of this.remote.get(machine) ?? []) {
|
|
189
|
+
let present = true;
|
|
190
|
+
try { readFileSync(join(this.mailRoot, item.folder, 'wake', item.id)); } catch { present = false; }
|
|
191
|
+
const found = present ? this.store.findIn(item.address, item.id) : null;
|
|
192
|
+
if (!found) { this.remote.get(machine).delete(key); if (present) { try { rmSync(join(this.mailRoot, item.folder, 'wake', item.id), { force: true }); } catch { /* gone */ } } continue; }
|
|
193
|
+
const envelope = readJson(found.path);
|
|
194
|
+
items.push({ ...item, filed: Number(envelope?.created_at_ms) || 0 });
|
|
195
|
+
}
|
|
196
|
+
return items.sort((a, b) => a.filed - b.filed);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** One machine's mail carried (mail_watch.rs `tick`, its remote half): its oldest due message probes the machine, and
|
|
200
|
+
* when it answers, the rest follow in this pass. */
|
|
201
|
+
async #carry(machine) {
|
|
202
|
+
this.carrying.add(machine);
|
|
203
|
+
try {
|
|
204
|
+
const now = Date.now();
|
|
205
|
+
const items = this.#remoteWakes(machine);
|
|
206
|
+
if (!items.length) return;
|
|
207
|
+
const wait = readJson(this.#machineWaitPath(machine));
|
|
208
|
+
if (wait) {
|
|
209
|
+
// a machine away longer than the ceiling is not coming back for this mail: it expires, saying so
|
|
210
|
+
if (now - (wait.offline_since_ms ?? now) >= OFFLINE_CEILING_MS) {
|
|
211
|
+
const reason = `${machine} has been offline for ${OFFLINE_CEILING_MS / 86_400_000} days (since epoch ms ${wait.offline_since_ms}): ${wait.last_error ?? 'not linked'}`;
|
|
212
|
+
for (const item of items) this.#expire(item.folder, item.address, item.id, this.store.wakeState(item.address, item.id).attempts ?? 0, reason);
|
|
213
|
+
this.#setMachineWait(machine, null);
|
|
214
|
+
this.#line({ t: now, machine, outcome: 'expired', messages: items.length, reason });
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
if ((wait.next_probe_ms ?? 0) > now) return;
|
|
218
|
+
}
|
|
219
|
+
let carriedHere = 0, offlineAnswer = null, called = false;
|
|
220
|
+
for (const item of items) {
|
|
221
|
+
if (!wait && (this.store.wakeState(item.address, item.id).next_at_ms ?? 0) > now) continue;
|
|
222
|
+
const found = this.store.findIn(item.address, item.id);
|
|
223
|
+
let carried;
|
|
224
|
+
// carried already (this carrier's own record): never carried twice
|
|
225
|
+
if (!found || (found.state === 'read' && this.store.claimOf(item.address, item.id)?.recipient === true)) carried = { kind: 'filed' };
|
|
226
|
+
else {
|
|
227
|
+
called = true;
|
|
228
|
+
carried = await this.#carryOne(machine, item.address, readJson(found.path));
|
|
229
|
+
}
|
|
230
|
+
if (carried.kind === 'offline') { offlineAnswer = carried.reason; break; }
|
|
231
|
+
carriedHere++;
|
|
232
|
+
this.#settle(item.folder, item.address, item.id, carried);
|
|
233
|
+
}
|
|
234
|
+
if (called) this.#line({ t: now, machine, outcome: offlineAnswer ? 'offline' : 'carried', waiting: items.length, carried: carriedHere, reason: offlineAnswer });
|
|
235
|
+
if (offlineAnswer) this.#setMachineWait(machine, { machine, offline_since_ms: wait?.offline_since_ms ?? now, next_probe_ms: now + LAST_RETRY_MS, probes: (wait?.probes ?? 0) + 1, last_error: offlineAnswer });
|
|
236
|
+
else if (called) this.#setMachineWait(machine, null);
|
|
237
|
+
} finally {
|
|
238
|
+
this.carrying.delete(machine);
|
|
239
|
+
this.#scheduleMachine(machine);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** One message carried to its machine's mail door, filed there with a wake. */
|
|
244
|
+
async #carryOne(machine, to, envelope) {
|
|
245
|
+
if (!envelope) return { kind: 'filed' };
|
|
246
|
+
try {
|
|
247
|
+
const answer = await this.askMachine(machine, { op: 'file', to, envelope, wake: true });
|
|
248
|
+
if (answer?.code === 0) return { kind: 'delivered' };
|
|
249
|
+
const reason = `${machine} did not take it: ${answer?.text ?? answer?.detail ?? 'no reason given'}`;
|
|
250
|
+
return offline(JSON.stringify(answer)) ? { kind: 'offline', reason } : { kind: 'later', reason };
|
|
251
|
+
} catch (error) {
|
|
252
|
+
return offline(error.code ?? error.message) || offline(error.message)
|
|
253
|
+
? { kind: 'offline', reason: `${machine} is offline: ${error.message}` }
|
|
254
|
+
: { kind: 'later', reason: `its mail door did not carry it to ${machine}: ${error.message}` };
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
#setMachineWait(machine, wait) {
|
|
259
|
+
const path = this.#machineWaitPath(machine);
|
|
260
|
+
if (!wait) { try { rmSync(path, { force: true }); } catch { /* gone */ } return; }
|
|
261
|
+
try { mkdirSync(join(this.mailRoot, 'machines'), { recursive: true }); writeFileSync(`${path}.tmp`, JSON.stringify(wait)); renameSync(`${path}.tmp`, path); }
|
|
262
|
+
catch (error) { this.log(`carrier: ${machine}'s wait was not kept: ${error.message}`); }
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ---- how an attempt went ---------------------------------------------------------------------------------------
|
|
266
|
+
|
|
267
|
+
/** Record how a wake went: done, waiting for its next attempt, or expired at its bound (mail_watch.rs `settle`). */
|
|
268
|
+
#settle(folder, address, id, carried) {
|
|
269
|
+
const wakePath = join(this.mailRoot, folder, 'wake', id);
|
|
270
|
+
if (carried.kind === 'delivered' || carried.kind === 'filed') {
|
|
271
|
+
if (carried.kind === 'delivered') {
|
|
272
|
+
// the carrier's own hand-off record: delivered here by its door, or carried to its machine
|
|
273
|
+
try { this.store.claimFor(address, id, { pid: process.pid, via: 'carrier', caller: null, recipient: true, at_ms: Date.now() }); } catch { /* best kept */ }
|
|
274
|
+
const found = this.store.findIn(address, id);
|
|
275
|
+
if (found && found.state !== 'read') { try { this.store.markRead(address, found.path); } catch { /* read already */ } }
|
|
276
|
+
}
|
|
277
|
+
try { rmSync(wakePath, { force: true }); } catch { /* gone */ }
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
if (carried.kind === 'offline') return;
|
|
281
|
+
const now = Date.now();
|
|
282
|
+
const state = this.store.wakeState(address, id);
|
|
283
|
+
const attempts = (state.attempts ?? 0) + 1;
|
|
284
|
+
const since = state.failing_since_ms ?? now;
|
|
285
|
+
const lifetime = address.split(':')[1] === this.machine ? LOCAL_WAKE_LIFETIME_MS : REMOTE_WAKE_LIFETIME_MS;
|
|
286
|
+
if (attempts >= WAKE_ATTEMPTS && now - since >= lifetime) { this.#expire(folder, address, id, attempts, carried.reason); return; }
|
|
287
|
+
const wait = Math.min(LAST_RETRY_MS, FIRST_RETRY_MS * 2 ** Math.min(attempts - 1, 16));
|
|
288
|
+
this.store.setWakeState(address, id, { ...state, attempts, failing_since_ms: since, next_at_ms: now + wait, last_error: carried.reason });
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** End a wake undelivered: the message stays filed and unread, and the expiry is recorded in its mailbox. */
|
|
292
|
+
#expire(folder, address, id, attempts, reason) {
|
|
293
|
+
const found = this.store.findIn(address, id);
|
|
294
|
+
const envelope = found ? readJson(found.path) : null;
|
|
295
|
+
const expiry = { id, to: address, from: envelope?.from ?? '', subject: envelope?.subject ?? null, filed_at_ms: Number(envelope?.created_at_ms) || 0, expired_at_ms: Date.now(), attempts, reason };
|
|
296
|
+
try {
|
|
297
|
+
mkdirSync(join(this.mailRoot, folder, 'expired'), { recursive: true });
|
|
298
|
+
const temporary = join(this.mailRoot, folder, 'tmp', `expired.${id}`);
|
|
299
|
+
writeFileSync(temporary, JSON.stringify(expiry));
|
|
300
|
+
renameSync(temporary, join(this.mailRoot, folder, 'expired', `${id}.json`));
|
|
301
|
+
rmSync(join(this.mailRoot, folder, 'wake', id), { force: true });
|
|
302
|
+
} catch (error) { this.log(`carrier: the expiry of ${id} was not recorded: ${error.message}`); }
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** One local delivery attempt in the carrier's record (`carrier.jsonl`). */
|
|
306
|
+
#record({ to, id, path, carried }) {
|
|
307
|
+
this.#line({ t: Date.now(), to, id, path, outcome: carried.kind, reason: carried.reason ?? null });
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
#line(line) { try { appendFileSync(join(this.mailRoot, 'carrier.jsonl'), `${JSON.stringify(line)}\n`); } catch { /* the record is best kept */ } }
|
|
311
|
+
}
|
package/mail/send.mjs
CHANGED
|
@@ -353,7 +353,9 @@ export class MailSend {
|
|
|
353
353
|
if (typed?.delivered) { this.store.deliverRead(to, sent); delivered = 'typed'; }
|
|
354
354
|
// held by the daemon's queue for the pane, typed once its composer frees: not typed yet, so kept unread
|
|
355
355
|
else if (typed?.queued) { this.store.deliver(to, sent); delivered = 'pane_held'; }
|
|
356
|
-
|
|
356
|
+
// not typed (its composer held a draft or a turn ran): it waits unread with a wake, so the carrier types it once the
|
|
357
|
+
// composer is empty (mail/carrier.mjs), never left for a read a session with no hook never makes
|
|
358
|
+
else { this.store.deliver(to, sent); if (wake) this.store.requestWake(to, sent.id); delivered = wake ? 'pane_waits' : 'queued'; }
|
|
357
359
|
} else if (door.kind === 'stored') { this.store.deliver(to, sent); delivered = 'stored'; }
|
|
358
360
|
else { this.store.deliver(to, sent); delivered = 'operator'; }
|
|
359
361
|
// a hand-over to the recipient's own door is its delivery, recorded as such
|
package/mail/store.mjs
CHANGED
|
@@ -68,6 +68,8 @@ export class MailStore extends EventEmitter {
|
|
|
68
68
|
const [folder, part, file] = name.split(/[\\/]/);
|
|
69
69
|
if (!folder) return;
|
|
70
70
|
if (!part) return this.#readBox(folder);
|
|
71
|
+
// a wake written or changed: the carrier's (mail/carrier.mjs) to attempt
|
|
72
|
+
if (part === 'wake' && file) { this.emit('wake', { folder, id: file }); return; }
|
|
71
73
|
if (!STATES[part] || !file) { if (part === 'address') this.#readBox(folder); return; }
|
|
72
74
|
if (!this.boxes.has(folder)) return this.#readBox(folder);
|
|
73
75
|
this.#readFile(folder, part, file);
|
|
@@ -103,6 +105,11 @@ export class MailStore extends EventEmitter {
|
|
|
103
105
|
for (const file of files) this.#take(box, part, file);
|
|
104
106
|
}
|
|
105
107
|
this.emit('changed', address);
|
|
108
|
+
// every wake the mailbox holds, read with it: a mailbox read whole (at start, or on a rescan once a lost watch is back)
|
|
109
|
+
// names each one to the carrier, so a wake written while no event could say so is carried all the same
|
|
110
|
+
let wakes = [];
|
|
111
|
+
try { wakes = readdirSync(join(box.dir, 'wake')); } catch { /* none */ }
|
|
112
|
+
for (const id of wakes) this.emit('wake', { folder, id });
|
|
106
113
|
}
|
|
107
114
|
|
|
108
115
|
/** One envelope file read again on its event: held while it is there, let go once it is gone. */
|
package/native-initial-input.mjs
CHANGED
|
@@ -7,6 +7,9 @@ import { codexQuestions } from './native-questions.mjs';
|
|
|
7
7
|
|
|
8
8
|
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
9
9
|
|
|
10
|
+
/** Input as a composer takes it: line ends as `\n`, without the outer whitespace a submit trims. */
|
|
11
|
+
const normalizedInput = (text) => String(text).replace(/\r\n?/g, '\n').trim();
|
|
12
|
+
|
|
10
13
|
/** Rollouts one daemon's launches have already taken as their receipt, with when: a launch that began before a
|
|
11
14
|
* claim does not answer from that transcript, while a later launch (a resume of the same session) still can, since
|
|
12
15
|
* its receipt must be newer than it anyway. This orders launches; it does not prove ownership — two panes in one
|
|
@@ -100,7 +103,10 @@ export async function confirmNativeInitialInput({ binding, kind, text, startedAt
|
|
|
100
103
|
content.every((part) => ['text', 'input_text'].includes(part.type)) ? content.map((part) => part.text).join('') : null;
|
|
101
104
|
// The transcript that confirms the input also names the session it belongs to: the launch records it,
|
|
102
105
|
// so a session can be told apart as one an agent launched (Claude Code's sessionId; Codex's rollout id).
|
|
103
|
-
|
|
106
|
+
// A Windows Codex input may have been pasted: its line ends normalized and trailing newlines dropped.
|
|
107
|
+
const same = codexSessions && typeof value === 'string'
|
|
108
|
+
? normalizedInput(value) === normalizedInput(text) : value === text;
|
|
109
|
+
if (same) {
|
|
104
110
|
const native = kind === 'claude-code' ? record.sessionId : /([0-9a-f]{8}-[0-9a-f-]{27})\.jsonl$/.exec(entry.path)?.[1];
|
|
105
111
|
if (codexSessions) claimedRollouts.set(entry.path, Date.now());
|
|
106
112
|
return { delivered: true, receipt: 'native-user-message', ...(typeof native === 'string' && native ? { native_session: native } : {}) };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/supercode-teams",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.107",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "A machine daemon serving this machine's doors, and a local or remote Teams server for shared session discovery, OpenTelemetry ingestion and access to enrolled machines.",
|
|
6
6
|
"license": "MIT",
|
package/terminal-mail.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import { join } from 'node:path';
|
|
|
5
5
|
import { createHash, randomUUID } from 'node:crypto';
|
|
6
6
|
import { spawnSync } from 'node:child_process';
|
|
7
7
|
import { StringDecoder } from 'node:string_decoder';
|
|
8
|
-
import { codexUpdatePrompt } from './codex-startup.mjs';
|
|
8
|
+
import { codexUpdatePrompt, codexWindowsPastes } from './codex-startup.mjs';
|
|
9
9
|
|
|
10
10
|
// When process `pid` started (ms), from `ps -o etime=` ([[dd-]hh:]mm:ss); null when it cannot be read.
|
|
11
11
|
function startedAt(pid) {
|
|
@@ -154,8 +154,14 @@ export class TerminalMailbox {
|
|
|
154
154
|
// it never guesses that an unacknowledged paste did not happen.
|
|
155
155
|
Object.assign(batch, { phase: 'paste_started', terminal: this.session.id, pid: this.session.rootPid ?? null, pasted_at: Date.now() }); this.save();
|
|
156
156
|
// The terminal host's final newline selects one bracketed paste;
|
|
157
|
-
// submitMode:none holds the separate Enter until the paste completes.
|
|
158
|
-
|
|
157
|
+
// submitMode:none holds the separate Enter until the paste completes. A Windows Codex takes its own pastes:
|
|
158
|
+
// at most 8000 characters each, non-ASCII as key events (codex-startup.mjs codexWindowsPastes).
|
|
159
|
+
if (process.platform === 'win32' && batch.kind === 'codex') {
|
|
160
|
+
for (const paste of codexWindowsPastes(batch.text)) {
|
|
161
|
+
await this.host.sendInput(this.session.id, paste, { submitMode: 'none' });
|
|
162
|
+
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
163
|
+
}
|
|
164
|
+
} else await this.host.sendInput(this.session.id, `${batch.text}\n`, { submitMode: 'none' });
|
|
159
165
|
batch.phase = 'pasted'; this.save();
|
|
160
166
|
}
|
|
161
167
|
if (batch.phase === 'paste_started') {
|