termdock 1.4.312 → 1.4.313
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/dist/server/agent/collaborationCli.js +4 -0
- package/dist/server/agent/collaborationTmuxDelivery.js +24 -19
- package/dist/server/agent/integrationServer.js +1 -0
- package/dist/server/agent/promptDelivery.js +6 -8
- package/dist/server/agent/terminalInput.js +36 -0
- package/dist/server/routes/terminal.js +105 -47
- package/docs/integrations.md +12 -0
- package/package.json +1 -1
- package/runtime-manifest.json +2 -2
|
@@ -175,6 +175,9 @@ Idempotency retention: 7 days. Terminal delivery, ACK and result never imply eac
|
|
|
175
175
|
Delivery semantics: delivered records completion of terminal write calls;
|
|
176
176
|
it does not guarantee receipt, submission, consumption or task completion; a timeout stops waiting, it does not cancel delivery.
|
|
177
177
|
Terminal input: paste once, wait 50ms, send one unbracketed CR (Enter).
|
|
178
|
+
TD automatic prompts, launch commands and drive keys share each transport's input queue.
|
|
179
|
+
Waiting input is bound to its original runtime; TERMINAL_INPUT_TARGET_CHANGED
|
|
180
|
+
means the terminal or TD launch changed, not that an Agent rejected the message.
|
|
178
181
|
TD-started/resumed Agents: the first delivery waits for current Agent process and
|
|
179
182
|
terminal binding (exact native id on resume); cold starts do not require a native id.
|
|
180
183
|
AGENT_LAUNCH_PENDING/TIMEOUT leaves the body queued with no terminal write.
|
|
@@ -453,6 +456,7 @@ const DELIVERY_DIAGNOSTICS = {
|
|
|
453
456
|
AGENT_LAUNCH_TIMEOUT: '120 秒内未观察到目标 Agent;消息保留,未盲投,不证明应用故障',
|
|
454
457
|
AGENT_LAUNCH_IDENTITY_MISMATCH: '启动后的 Agent 或恢复 UUID 不匹配;消息保留,未写入',
|
|
455
458
|
AGENT_LAUNCH_CHANGED: '启动操作已变化,重新检查当前启动记录',
|
|
459
|
+
TERMINAL_INPUT_TARGET_CHANGED: '等待写入时终端承载或 TD 启动代次发生变化,旧操作未继续写入新承载;请核对回执与快照',
|
|
456
460
|
TMUX_PANE_IN_MODE: '目标面板处于 tmux 模式(如滚动查看),按键已跳过',
|
|
457
461
|
};
|
|
458
462
|
export function deliveryDiagnosticText(lastError) {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { plainCollaborationSnapshot } from './collaborationText.js';
|
|
2
2
|
import { normalizePromptForPaste, waitForPromptSubmit } from './promptDelivery.js';
|
|
3
|
+
import { serializeTerminalInput } from './terminalInput.js';
|
|
3
4
|
const TMUX_KEY_NAMES = {
|
|
4
5
|
enter: 'Enter', escape: 'Escape', space: 'Space', left: 'Left', right: 'Right', up: 'Up', down: 'Down',
|
|
5
6
|
};
|
|
@@ -31,21 +32,18 @@ async function assertSamePane(run, pane) {
|
|
|
31
32
|
/** Unique-per-process buffer name so concurrent deliveries to different panes
|
|
32
33
|
* never share (or clobber) a buffer. */
|
|
33
34
|
let bufferSequence = 0;
|
|
34
|
-
|
|
35
|
+
function paneInputKey(pane) {
|
|
36
|
+
return `${pane.serverPid}:${pane.sessionId}:${pane.paneId}:${pane.panePid}`;
|
|
37
|
+
}
|
|
35
38
|
/** Serialize TD paste/submit pairs for this exact runtime pane. */
|
|
36
|
-
export function writeCollaborationTmuxPane(run, pane, prompt, stdin) {
|
|
37
|
-
|
|
38
|
-
const previous = paneWrites.get(key) ?? Promise.resolve();
|
|
39
|
-
const operation = previous.catch(() => undefined).then(() => writeTmuxPrompt(run, pane, prompt, stdin))
|
|
40
|
-
.finally(() => { if (paneWrites.get(key) === operation)
|
|
41
|
-
paneWrites.delete(key); });
|
|
42
|
-
paneWrites.set(key, operation);
|
|
43
|
-
return operation;
|
|
39
|
+
export function writeCollaborationTmuxPane(run, pane, prompt, stdin, assertCurrent) {
|
|
40
|
+
return serializeTerminalInput(paneInputKey(pane), () => writeTmuxPrompt(run, pane, prompt, stdin, assertCurrent));
|
|
44
41
|
}
|
|
45
42
|
async function writeTmuxPrompt(run, pane, prompt,
|
|
46
43
|
/** Body travels on stdin so tmux cannot parse its semicolons/backslashes. */
|
|
47
|
-
stdin) {
|
|
44
|
+
stdin, assertCurrent) {
|
|
48
45
|
await assertSamePane(run, pane);
|
|
46
|
+
assertCurrent?.();
|
|
49
47
|
// A fixed pane target is independent of the current window, keyboard focus
|
|
50
48
|
// and browser presence. Delivery goes through a named tmux buffer rather
|
|
51
49
|
// than `send-keys -l`: while a pane is in copy-mode, literal bytes are
|
|
@@ -77,6 +75,7 @@ stdin) {
|
|
|
77
75
|
], normalizePromptForPaste(prompt));
|
|
78
76
|
await waitForPromptSubmit();
|
|
79
77
|
await assertSamePane(run, pane);
|
|
78
|
+
assertCurrent?.();
|
|
80
79
|
await run([
|
|
81
80
|
// The submit key rides outside the paste block: inside it, an editor
|
|
82
81
|
// inserts a pasted CR as text instead of acting on it. Pasting the bare
|
|
@@ -100,10 +99,14 @@ export async function paneInMode(run, pane) {
|
|
|
100
99
|
* unchanged. Named keys only — no freeform keystroke passthrough. Refuses
|
|
101
100
|
* while the pane is in a mode: there the key would be taken by the mode
|
|
102
101
|
* handler (Enter in copy-mode exits the user's scroll) rather than the app. */
|
|
103
|
-
export
|
|
102
|
+
export function sendTmuxPaneKey(run, pane, key, assertCurrent) {
|
|
103
|
+
return serializeTerminalInput(paneInputKey(pane), () => sendTmuxPaneKeyUnlocked(run, pane, key, assertCurrent));
|
|
104
|
+
}
|
|
105
|
+
async function sendTmuxPaneKeyUnlocked(run, pane, key, assertCurrent) {
|
|
104
106
|
await assertSamePane(run, pane);
|
|
105
107
|
if (await paneInMode(run, pane))
|
|
106
108
|
throw new Error('TMUX_PANE_IN_MODE');
|
|
109
|
+
assertCurrent?.();
|
|
107
110
|
await run(['send-keys', '-t', pane.paneId, TMUX_KEY_NAMES[key]]);
|
|
108
111
|
}
|
|
109
112
|
/** Current pane screen text (viewport), identity-checked. */
|
|
@@ -116,14 +119,16 @@ export async function captureTmuxPaneText(run, pane, rawOutput = false) {
|
|
|
116
119
|
* Enter (the highlighted option). Refuses unless the screen actually shows
|
|
117
120
|
* an approval prompt: driving a dialog that isn't there would only inject
|
|
118
121
|
* a stray keystroke into the agent's work. Returns false when no dialog. */
|
|
119
|
-
export
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
122
|
+
export function approveCollaborationDialog(run, pane, assertCurrent) {
|
|
123
|
+
return serializeTerminalInput(paneInputKey(pane), async () => {
|
|
124
|
+
assertCurrent?.();
|
|
125
|
+
const content = await captureTmuxPaneText(run, pane);
|
|
126
|
+
if (!detectApprovalDialog(content))
|
|
127
|
+
return false;
|
|
128
|
+
// The same input queue covers both the observation and requested key.
|
|
129
|
+
await sendTmuxPaneKeyUnlocked(run, pane, 'enter', assertCurrent);
|
|
130
|
+
return true;
|
|
131
|
+
});
|
|
127
132
|
}
|
|
128
133
|
/** History-inclusive terminal observation, without inferring consumption. */
|
|
129
134
|
export async function captureTmuxPaneHistory(run, pane, lines = 800, rawOutput = false) {
|
|
@@ -225,6 +225,7 @@ export class IntegrationRuntime {
|
|
|
225
225
|
session_resume_configuration: true, session_lifecycle_min_version: '1.4.309',
|
|
226
226
|
session_startup_input_clear: true, session_startup_input_clear_min_version: '1.4.310',
|
|
227
227
|
agent_launch_process_check: true, agent_launch_process_check_min_version: '1.4.312',
|
|
228
|
+
terminal_input_runtime_fence: true, terminal_input_runtime_fence_min_version: '1.4.313',
|
|
228
229
|
integration_policy_update: true,
|
|
229
230
|
session_restore_diagnostics: true,
|
|
230
231
|
native_identity_linux_flock_owner: process.platform === 'linux',
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { serializeTerminalInput } from './terminalInput.js';
|
|
1
2
|
/**
|
|
2
3
|
* Fold a prompt into the single-channel form both tmux and agent TUIs expect:
|
|
3
4
|
* every line break becomes one CR (commits a line inside the editor, and is
|
|
@@ -16,18 +17,15 @@ export function waitForPromptSubmit() {
|
|
|
16
17
|
export function buildBracketedPasteBytes(prompt) {
|
|
17
18
|
return `\x1b[200~${normalizePromptForPaste(prompt)}\x1b[201~`;
|
|
18
19
|
}
|
|
19
|
-
const promptWrites = new WeakMap();
|
|
20
20
|
/** Serialize TD prompt pairs per PTY. Success only records terminal writes. */
|
|
21
|
-
export function writeBracketedPrompt(target, prompt) {
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
export function writeBracketedPrompt(target, prompt, assertCurrent) {
|
|
22
|
+
return serializeTerminalInput(target, async () => {
|
|
23
|
+
assertCurrent?.();
|
|
24
24
|
target.write(buildBracketedPasteBytes(prompt));
|
|
25
25
|
await waitForPromptSubmit();
|
|
26
|
+
assertCurrent?.();
|
|
26
27
|
target.write('\r');
|
|
27
|
-
})
|
|
28
|
-
promptWrites.delete(target); });
|
|
29
|
-
promptWrites.set(target, operation);
|
|
30
|
-
return operation;
|
|
28
|
+
});
|
|
31
29
|
}
|
|
32
30
|
/**
|
|
33
31
|
* Process detection and hook events are independent Agent signals. A target is
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/** One TD input operation per actual transport, independent of application state. */
|
|
2
|
+
const objectInputs = new WeakMap();
|
|
3
|
+
const namedInputs = new Map();
|
|
4
|
+
export function serializeTerminalInput(target, action) {
|
|
5
|
+
return typeof target === 'string' ? serializeInQueue(namedInputs, target, action)
|
|
6
|
+
: serializeInQueue(objectInputs, target, action);
|
|
7
|
+
}
|
|
8
|
+
function serializeInQueue(store, target, action) {
|
|
9
|
+
const previous = store.get(target) ?? Promise.resolve();
|
|
10
|
+
const operation = previous.catch(() => undefined).then(action).finally(() => {
|
|
11
|
+
if (store.get(target) === operation)
|
|
12
|
+
store.delete(target);
|
|
13
|
+
});
|
|
14
|
+
store.set(target, operation);
|
|
15
|
+
return operation;
|
|
16
|
+
}
|
|
17
|
+
/** Clearing a one-time launch check does not change the runtime generation. */
|
|
18
|
+
export function captureTerminalInputGuard(current, expected = current()) {
|
|
19
|
+
return () => {
|
|
20
|
+
if (current() !== expected)
|
|
21
|
+
throw new Error('TERMINAL_INPUT_TARGET_CHANGED');
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
/** Await the actual write instead of leaving detached timers after reporting success. */
|
|
25
|
+
export async function waitAndWriteTerminalInput(input) {
|
|
26
|
+
for (let attempt = 0;; attempt++) {
|
|
27
|
+
input.assertCurrent();
|
|
28
|
+
const ready = await input.ready(attempt);
|
|
29
|
+
input.assertCurrent();
|
|
30
|
+
if (ready) {
|
|
31
|
+
await input.write();
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
await new Promise(resolve => setTimeout(resolve, 250));
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -50,6 +50,7 @@ import { archiveTaskWorkspace, restoreTaskWorkspace, snapshotTaskWorkspace } fro
|
|
|
50
50
|
import { isTmuxRecoveryCandidate } from '../utils/tmuxRecoveryCandidate.js';
|
|
51
51
|
import { AutomationStore, normalizeAutomationSchedule } from '../agent/automationStore.js';
|
|
52
52
|
import { writeBracketedPrompt, canDeliverPromptToAgent } from '../agent/promptDelivery.js';
|
|
53
|
+
import { captureTerminalInputGuard, waitAndWriteTerminalInput } from '../agent/terminalInput.js';
|
|
53
54
|
import { agentLaunchBlockReason, normalizeAgentLaunchCheck } from '../agent/agentLaunchCheck.js';
|
|
54
55
|
import { prepareSupervisionEnable } from '../utils/enableSupervision.js';
|
|
55
56
|
import { buildServerHealthState, dismissServerHealth } from '../utils/serverHealth.js';
|
|
@@ -967,6 +968,8 @@ function normalizePersistedClientSession(input) {
|
|
|
967
968
|
: null,
|
|
968
969
|
agentResume: normalizePersistedAgentResumeInfo(candidate.agentResume),
|
|
969
970
|
agentLaunchCheck: normalizeAgentLaunchCheck(candidate.agentLaunchCheck),
|
|
971
|
+
agentLaunchGeneration: typeof candidate.agentLaunchGeneration === 'string' ? candidate.agentLaunchGeneration
|
|
972
|
+
: normalizeAgentLaunchCheck(candidate.agentLaunchCheck)?.id ?? null,
|
|
970
973
|
};
|
|
971
974
|
}
|
|
972
975
|
function generateTmuxSessionName() {
|
|
@@ -1449,14 +1452,12 @@ function searchMetadataForBackend(backendSessionId, session) {
|
|
|
1449
1452
|
updatedAt: session.lastActivity,
|
|
1450
1453
|
};
|
|
1451
1454
|
}
|
|
1452
|
-
function writeTerminalInput(session, value) {
|
|
1453
|
-
session.ptyProcess.write(value.replace(/\r?\n/g, '\r'));
|
|
1454
|
-
}
|
|
1455
1455
|
async function beginAgentLaunchCheck(frontendSessionId, agentSlug, nativeSessionId = null) {
|
|
1456
1456
|
const record = globalSessionState.sessions.find(candidate => candidate.sessionId === frontendSessionId);
|
|
1457
1457
|
if (!record)
|
|
1458
1458
|
throw new Error('SESSION_REMOVED');
|
|
1459
|
-
|
|
1459
|
+
const id = randomUUID();
|
|
1460
|
+
upsertGlobalSessionRecord({ ...record, agentLaunchGeneration: id, agentLaunchCheck: { id, agentSlug, nativeSessionId, deadline: Date.now() + 120_000 } });
|
|
1460
1461
|
await persistGlobalStateNow();
|
|
1461
1462
|
}
|
|
1462
1463
|
async function observeAgentLaunchCheck(record, observed) {
|
|
@@ -1475,30 +1476,71 @@ async function observeAgentLaunchCheck(record, observed) {
|
|
|
1475
1476
|
await persistGlobalStateNow();
|
|
1476
1477
|
return null;
|
|
1477
1478
|
}
|
|
1478
|
-
|
|
1479
|
+
function terminalInputGuard(record) {
|
|
1480
|
+
const frontendSessionId = record.sessionId;
|
|
1481
|
+
const backendSessionId = record.backendSessionId;
|
|
1482
|
+
const generation = captureTerminalInputGuard(() => globalSessionState.sessions
|
|
1483
|
+
.find(candidate => candidate.sessionId === frontendSessionId)?.agentLaunchGeneration ?? null, record.agentLaunchGeneration ?? null);
|
|
1484
|
+
const backend = backendSessionId ? terminalSessions.get(backendSessionId) : undefined;
|
|
1485
|
+
return () => {
|
|
1486
|
+
const latest = globalSessionState.sessions.find(candidate => candidate.sessionId === frontendSessionId);
|
|
1487
|
+
if (!latest)
|
|
1488
|
+
throw new Error('SESSION_REMOVED');
|
|
1489
|
+
generation();
|
|
1490
|
+
if (latest.backendSessionId !== backendSessionId
|
|
1491
|
+
|| backendSessionId && terminalSessions.get(backendSessionId) !== backend)
|
|
1492
|
+
throw new Error('TERMINAL_INPUT_TARGET_CHANGED');
|
|
1493
|
+
};
|
|
1494
|
+
}
|
|
1495
|
+
/** tmux input always uses the pane queue, including automation and launch commands. */
|
|
1496
|
+
async function automaticTerminalInput(record, assertCurrent = terminalInputGuard(record)) {
|
|
1497
|
+
assertCurrent();
|
|
1498
|
+
if (record.mode === 'tmux') {
|
|
1499
|
+
const binding = collaborationRouting.get(record.sessionId) ?? {
|
|
1500
|
+
sessionId: record.sessionId, backendSessionId: record.backendSessionId, mode: record.mode,
|
|
1501
|
+
tmuxSessionName: record.tmuxSessionName, agentSlug: null, nativeSessionId: null, pane: null,
|
|
1502
|
+
};
|
|
1503
|
+
const inspected = await inspectCollaborationTmux(binding, null, true);
|
|
1504
|
+
assertCurrent();
|
|
1505
|
+
if (inspected.state !== 'ready' || !inspected.pane)
|
|
1506
|
+
throw new Error(inspected.reason ?? 'TMUX_PANE_UNAVAILABLE');
|
|
1507
|
+
const pane = inspected.pane;
|
|
1508
|
+
return (prompt) => writeCollaborationTmuxPane(runTmux, pane, prompt, runTmuxStdin, assertCurrent);
|
|
1509
|
+
}
|
|
1510
|
+
const backend = record.backendSessionId ? terminalSessions.get(record.backendSessionId) : null;
|
|
1511
|
+
if (!backend)
|
|
1512
|
+
throw new Error('SHELL_BACKEND_NOT_RUNNING');
|
|
1513
|
+
return (prompt) => writeBracketedPrompt(backend.ptyProcess, prompt, assertCurrent);
|
|
1514
|
+
}
|
|
1515
|
+
async function writeAutomaticTerminalPrompt(frontendSessionId, prompt) {
|
|
1516
|
+
const record = globalSessionState.sessions.find(candidate => candidate.sessionId === frontendSessionId);
|
|
1517
|
+
if (!record)
|
|
1518
|
+
throw new Error('SESSION_REMOVED');
|
|
1519
|
+
const write = await automaticTerminalInput(record);
|
|
1520
|
+
await write(prompt);
|
|
1521
|
+
}
|
|
1522
|
+
async function deliverAutomationPromptWhenReady(backendSessionId, prompt) {
|
|
1479
1523
|
const session = terminalSessions.get(backendSessionId);
|
|
1480
|
-
if (!session)
|
|
1481
|
-
return;
|
|
1482
1524
|
const record = globalSessionState.sessions.find(candidate => candidate.backendSessionId === backendSessionId);
|
|
1483
|
-
if (record
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1525
|
+
if (!session || !record)
|
|
1526
|
+
throw new Error('SHELL_BACKEND_NOT_RUNNING');
|
|
1527
|
+
const assertCurrent = terminalInputGuard(record);
|
|
1528
|
+
const write = await automaticTerminalInput(record, assertCurrent);
|
|
1529
|
+
await waitAndWriteTerminalInput({ assertCurrent, write: () => write(prompt), ready: async (attempt) => {
|
|
1530
|
+
const latest = globalSessionState.sessions.find(candidate => candidate.sessionId === record.sessionId);
|
|
1531
|
+
if (!latest.agentLaunchCheck)
|
|
1532
|
+
return canDeliverPromptToAgent(session) || attempt >= 60;
|
|
1533
|
+
await refreshCollaborationAgentIdentity(backendSessionId, session, true);
|
|
1534
|
+
assertCurrent();
|
|
1535
|
+
const agent = detectAgentFromCommand(session.activeProgram?.rawArgs ?? session.activeProgram?.command ?? '', agentCustomCommands());
|
|
1536
|
+
const nativeSessionId = agent && session.activeProgram?.rawArgs ? inferResumeSessionId(agent, splitCommandToArgv(session.activeProgram.rawArgs)) : null;
|
|
1537
|
+
const blocked = await observeAgentLaunchCheck(latest, { agentSlug: agent?.slug ?? null, nativeSessionId });
|
|
1538
|
+
if (!blocked)
|
|
1539
|
+
return true;
|
|
1489
1540
|
if (blocked.reason !== 'AGENT_LAUNCH_PENDING')
|
|
1490
1541
|
throw new Error(blocked.reason ?? 'AGENT_LAUNCH_PENDING');
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
}
|
|
1494
|
-
await writeBracketedPrompt(session.ptyProcess, prompt);
|
|
1495
|
-
return;
|
|
1496
|
-
}
|
|
1497
|
-
if (canDeliverPromptToAgent(session) || attempt >= 60) {
|
|
1498
|
-
void writeBracketedPrompt(session.ptyProcess, prompt).catch((error) => console.error('[automation] terminal write failed:', getErrorMessage(error)));
|
|
1499
|
-
return;
|
|
1500
|
-
}
|
|
1501
|
-
setTimeout(() => { void deliverAutomationPromptWhenReady(backendSessionId, prompt, attempt + 1).catch(error => console.error('[automation] terminal write failed:', getErrorMessage(error))); }, 250);
|
|
1542
|
+
return false;
|
|
1543
|
+
} });
|
|
1502
1544
|
}
|
|
1503
1545
|
async function runAgentAutomation(automation, req) {
|
|
1504
1546
|
const run = automationStore.beginRun(automation);
|
|
@@ -1515,10 +1557,7 @@ async function runAgentAutomation(automation, req) {
|
|
|
1515
1557
|
const message = automation.prompt || automation.command;
|
|
1516
1558
|
if (!message)
|
|
1517
1559
|
throw new Error('自动任务没有可发送的内容');
|
|
1518
|
-
|
|
1519
|
-
await deliverAutomationPromptWhenReady(record.backendSessionId, message);
|
|
1520
|
-
else
|
|
1521
|
-
await writeBracketedPrompt(backend.ptyProcess, message);
|
|
1560
|
+
await deliverAutomationPromptWhenReady(record.backendSessionId, message);
|
|
1522
1561
|
}
|
|
1523
1562
|
else {
|
|
1524
1563
|
const request = req ?? {};
|
|
@@ -1536,16 +1575,16 @@ async function runAgentAutomation(automation, req) {
|
|
|
1536
1575
|
const agent = detectAgentFromCommand(automation.command, agentCustomCommands());
|
|
1537
1576
|
if (agent)
|
|
1538
1577
|
await beginAgentLaunchCheck(frontendSessionId, agent.slug);
|
|
1539
|
-
|
|
1578
|
+
await writeAutomaticTerminalPrompt(frontendSessionId, automation.command);
|
|
1540
1579
|
}
|
|
1541
1580
|
if (automation.prompt) {
|
|
1542
1581
|
if (automation.command)
|
|
1543
1582
|
await deliverAutomationPromptWhenReady(opened.terminalSession.sessionId, automation.prompt);
|
|
1544
1583
|
else
|
|
1545
|
-
await
|
|
1584
|
+
await writeAutomaticTerminalPrompt(frontendSessionId, automation.prompt);
|
|
1546
1585
|
}
|
|
1547
1586
|
}
|
|
1548
|
-
automationStore.finishRun(run.id, 'success', frontendSessionId, '
|
|
1587
|
+
automationStore.finishRun(run.id, 'success', frontendSessionId, '已粘贴到终端并发送回车');
|
|
1549
1588
|
}
|
|
1550
1589
|
catch (error) {
|
|
1551
1590
|
automationStore.finishRun(run.id, 'failed', frontendSessionId, getErrorMessage(error));
|
|
@@ -1725,6 +1764,7 @@ async function resolveCollaborationRoute(frontendSessionId) {
|
|
|
1725
1764
|
const startup = await integrationDeliveryGuard?.(frontendSessionId);
|
|
1726
1765
|
if (startup && !startup.allowed)
|
|
1727
1766
|
return { state: 'recovering', reason: startup.reason ?? 'SESSION_STARTUP_INPUT_PENDING' };
|
|
1767
|
+
const assertGeneration = captureTerminalInputGuard(() => globalSessionState.sessions.find(candidate => candidate.sessionId === frontendSessionId)?.agentLaunchGeneration ?? null, record.agentLaunchGeneration ?? null);
|
|
1728
1768
|
let backend = resolveOrchestrationBackend(record);
|
|
1729
1769
|
let binding = collaborationRouting.get(frontendSessionId) ?? {
|
|
1730
1770
|
sessionId: record.sessionId, backendSessionId: null, mode: record.mode, tmuxSessionName: record.tmuxSessionName,
|
|
@@ -1732,6 +1772,7 @@ async function resolveCollaborationRoute(frontendSessionId) {
|
|
|
1732
1772
|
};
|
|
1733
1773
|
if (binding.mode === 'tmux') {
|
|
1734
1774
|
const inspected = await inspectCollaborationTmux(binding, undefined, false, Boolean(record.agentLaunchCheck?.nativeSessionId), Boolean(record.agentLaunchCheck));
|
|
1775
|
+
assertGeneration();
|
|
1735
1776
|
if (inspected.state !== 'ready' || !inspected.pane)
|
|
1736
1777
|
return inspected;
|
|
1737
1778
|
record = globalSessionState.sessions.find((candidate) => candidate.sessionId === frontendSessionId);
|
|
@@ -1767,17 +1808,21 @@ async function resolveCollaborationRoute(frontendSessionId) {
|
|
|
1767
1808
|
}
|
|
1768
1809
|
if (!backend)
|
|
1769
1810
|
return { state: 'detached', reason: 'TMUX_BACKEND_UNAVAILABLE' };
|
|
1811
|
+
assertGeneration();
|
|
1812
|
+
const assertCurrent = terminalInputGuard(globalSessionState.sessions.find(candidate => candidate.sessionId === frontendSessionId));
|
|
1770
1813
|
return { state: 'ready', isShell: pane.isShell, capture: async () => captureTmuxPaneText(runTmux, pinned), write: async (messages) => {
|
|
1771
1814
|
if (!globalSessionState.sessions.some((candidate) => candidate.sessionId === frontendSessionId))
|
|
1772
1815
|
throw new Error('SESSION_REMOVED');
|
|
1773
|
-
await writeCollaborationTmuxPane(runTmux, pinned, formatLocalCollaborationMessages(frontendSessionId, messages), runTmuxStdin);
|
|
1816
|
+
await writeCollaborationTmuxPane(runTmux, pinned, formatLocalCollaborationMessages(frontendSessionId, messages), runTmuxStdin, () => { assertGeneration(); assertCurrent(); });
|
|
1774
1817
|
backend.lastActivity = Date.now();
|
|
1775
1818
|
} };
|
|
1776
1819
|
}
|
|
1777
1820
|
if (!backend || !binding.backendSessionId)
|
|
1778
1821
|
return { state: 'offline', reason: 'SHELL_BACKEND_NOT_RUNNING' };
|
|
1779
1822
|
await refreshCollaborationAgentIdentity(binding.backendSessionId, backend, Boolean(record.agentLaunchCheck));
|
|
1823
|
+
assertGeneration();
|
|
1780
1824
|
const target = backend;
|
|
1825
|
+
const assertCurrent = terminalInputGuard(record);
|
|
1781
1826
|
if (record.agentLaunchCheck) {
|
|
1782
1827
|
const agent = detectAgentFromCommand(target.activeProgram?.rawArgs ?? target.activeProgram?.command ?? '', agentCustomCommands());
|
|
1783
1828
|
const nativeSessionId = agent && target.activeProgram?.rawArgs ? inferResumeSessionId(agent, splitCommandToArgv(target.activeProgram.rawArgs)) : null;
|
|
@@ -1793,7 +1838,12 @@ async function resolveCollaborationRoute(frontendSessionId) {
|
|
|
1793
1838
|
throw new Error('SESSION_REMOVED');
|
|
1794
1839
|
if (terminalSessions.get(binding.backendSessionId) !== target)
|
|
1795
1840
|
throw new Error('SHELL_BACKEND_CHANGED');
|
|
1796
|
-
await writeBracketedPrompt(target.ptyProcess, formatLocalCollaborationMessages(frontendSessionId, messages))
|
|
1841
|
+
await writeBracketedPrompt(target.ptyProcess, formatLocalCollaborationMessages(frontendSessionId, messages), () => {
|
|
1842
|
+
assertGeneration();
|
|
1843
|
+
assertCurrent();
|
|
1844
|
+
if (terminalSessions.get(binding.backendSessionId) !== target)
|
|
1845
|
+
throw new Error('TERMINAL_INPUT_TARGET_CHANGED');
|
|
1846
|
+
});
|
|
1797
1847
|
target.lastActivity = Date.now();
|
|
1798
1848
|
} };
|
|
1799
1849
|
}
|
|
@@ -1874,7 +1924,7 @@ async function spawnCollaborationAgentSession(req, group, sourceSessionId, input
|
|
|
1874
1924
|
collaborationStore.setRole({ groupId: group.id, sessionId: frontendSessionId, role: bootstrapRole });
|
|
1875
1925
|
if (profile)
|
|
1876
1926
|
collaborationStore.setMemberLaunchProfile(group.id, frontendSessionId, profile);
|
|
1877
|
-
|
|
1927
|
+
await writeAutomaticTerminalPrompt(frontendSessionId, buildCollaborationSpawnCommand({ slug: launcher.slug, command: profile?.command ?? launcher.command }));
|
|
1878
1928
|
const requestedTask = typeof input.task === 'string' ? input.task.trim().slice(0, 20_000) : '';
|
|
1879
1929
|
collaborationStore.send({
|
|
1880
1930
|
groupId: updatedGroup.id,
|
|
@@ -2157,8 +2207,7 @@ async function launchIntegrationTerminal(record, restoring, prepared) {
|
|
|
2157
2207
|
backend.agent = null;
|
|
2158
2208
|
backend.agentResumeRecovered = false;
|
|
2159
2209
|
await beginAgentLaunchCheck(record.session_id, record.agent_slug, restoring ? record.agent_native_session_id : null);
|
|
2160
|
-
await
|
|
2161
|
-
await runTmux(['send-keys', '-t', pinned.paneId, 'Enter']);
|
|
2210
|
+
await writeAutomaticTerminalPrompt(record.session_id, integrationLaunchCommand(record, restoring));
|
|
2162
2211
|
broadcastClientState();
|
|
2163
2212
|
});
|
|
2164
2213
|
}
|
|
@@ -2210,7 +2259,7 @@ export function prepareCollaborationTaskWorker(task, template, dependencies, ser
|
|
|
2210
2259
|
const temporary = `${launchFile}.${process.pid}.tmp`;
|
|
2211
2260
|
await fs.promises.writeFile(temporary, JSON.stringify({ sessionId: record.sessionId, backendId: opened.terminalSession.sessionId }), { mode: 0o600 });
|
|
2212
2261
|
await fs.promises.rename(temporary, launchFile);
|
|
2213
|
-
|
|
2262
|
+
await writeAutomaticTerminalPrompt(opened.session.sessionId, buildCollaborationSpawnCommand({ slug: launcher.slug, command: inheritedProfile?.command ?? launcher.command }));
|
|
2214
2263
|
}
|
|
2215
2264
|
const group = collaborationStore.getGroup(task.groupId);
|
|
2216
2265
|
if (!group || group.deleted)
|
|
@@ -5866,16 +5915,21 @@ router.post('/tmux/recovery/restore-all', async (_req, res) => {
|
|
|
5866
5915
|
continue;
|
|
5867
5916
|
}
|
|
5868
5917
|
try {
|
|
5869
|
-
await tmuxLifecycle.run(`restore:${record.tmuxSessionName}`, async () => {
|
|
5918
|
+
await collaborationDeliveryWorker.reconfigure(frontendSessionId, () => tmuxLifecycle.run(`restore:${record.tmuxSessionName}`, async () => {
|
|
5870
5919
|
await prepareManagedTmuxSessionUnlocked(record.tmuxSessionName, record.cwd);
|
|
5871
5920
|
const metadata = await resolveLiveTmuxMetadata(record.tmuxSessionName);
|
|
5872
5921
|
const foreground = metadata?.program?.toLowerCase() ?? null;
|
|
5873
5922
|
if (foreground && !shellNamesBackend.has(foreground)) {
|
|
5874
5923
|
throw new Error(`pane is busy with ${foreground}`);
|
|
5875
5924
|
}
|
|
5876
|
-
|
|
5877
|
-
|
|
5878
|
-
|
|
5925
|
+
// Explicit recovery may replace the vanished tmux pane. A pending
|
|
5926
|
+
// message alone must never replace that binding.
|
|
5927
|
+
const previous = collaborationRouting.get(frontendSessionId);
|
|
5928
|
+
if (previous)
|
|
5929
|
+
collaborationRouting.bind({ ...previous, pane: null });
|
|
5930
|
+
await beginAgentLaunchCheck(frontendSessionId, agent.slug, target.nativeSessionId);
|
|
5931
|
+
await writeAutomaticTerminalPrompt(frontendSessionId, command);
|
|
5932
|
+
}));
|
|
5879
5933
|
outcomes.push({ sessionId: frontendSessionId, restored: true });
|
|
5880
5934
|
}
|
|
5881
5935
|
catch (error) {
|
|
@@ -6491,7 +6545,7 @@ router.post('/operations/orchestration/cleanup', async (req, res) => {
|
|
|
6491
6545
|
// caller, tmux-local only, and `approve` refuses unless an approval dialog is
|
|
6492
6546
|
// actually showing (Enter on the highlighted option). The dialogs may be
|
|
6493
6547
|
// approving any command the member agent asked for; the caller is presumed to
|
|
6494
|
-
// own that decision
|
|
6548
|
+
// own that decision. Ordinary delivery never invokes approval automatically.
|
|
6495
6549
|
const DRIVE_KEYS = new Set(['enter', 'escape', 'space', 'left', 'right', 'up', 'down']);
|
|
6496
6550
|
router.post('/operations/orchestration/drive', async (req, res) => {
|
|
6497
6551
|
const sourceSessionId = resolveFrontendSessionId(req.body ?? {});
|
|
@@ -6514,6 +6568,7 @@ router.post('/operations/orchestration/drive', async (req, res) => {
|
|
|
6514
6568
|
return res.status(409).json({ error: '目标会话不是 tmux 会话,无法从 CLI 驱动(模式:' + (record.mode ?? 'unknown') + ')' });
|
|
6515
6569
|
}
|
|
6516
6570
|
const binding = collaborationRouting.get(target);
|
|
6571
|
+
const assertCurrent = terminalInputGuard(record);
|
|
6517
6572
|
let pane = binding?.pane ?? null;
|
|
6518
6573
|
if (!pane) {
|
|
6519
6574
|
// No pinned Agent pane: resolve the session's pane directly so a plain
|
|
@@ -6541,7 +6596,7 @@ router.post('/operations/orchestration/drive', async (req, res) => {
|
|
|
6541
6596
|
}
|
|
6542
6597
|
try {
|
|
6543
6598
|
if (action === 'approve') {
|
|
6544
|
-
const approved = await approveCollaborationDialog(runTmux, pane);
|
|
6599
|
+
const approved = await approveCollaborationDialog(runTmux, pane, assertCurrent);
|
|
6545
6600
|
if (!approved)
|
|
6546
6601
|
return res.status(409).json({ ok: false, code: 'NO_APPROVAL_DIALOG', error: '目标面板当前没有显示审批对话框;未发送任何按键' });
|
|
6547
6602
|
return res.json({ ok: true, action, sessionId: target, approved: true });
|
|
@@ -6562,14 +6617,14 @@ router.post('/operations/orchestration/drive', async (req, res) => {
|
|
|
6562
6617
|
return res.status(400).json({ error: 'run 文本过长(上限 10000 字符);需要更多内容请走 td collab send 投递' });
|
|
6563
6618
|
// Same bracketed-paste write path deliveries use: one line, one submit,
|
|
6564
6619
|
// embedded newlines stay inside the recipient's editor.
|
|
6565
|
-
await writeCollaborationTmuxPane(runTmux, pane, text, runTmuxStdin);
|
|
6620
|
+
await writeCollaborationTmuxPane(runTmux, pane, text, runTmuxStdin, assertCurrent);
|
|
6566
6621
|
const snapshot = await captureTmuxPaneText(runTmux, pane);
|
|
6567
6622
|
return res.json({ ok: true, action, sessionId: target, text, snapshot });
|
|
6568
6623
|
}
|
|
6569
6624
|
if (!DRIVE_KEYS.has(action)) {
|
|
6570
6625
|
return res.status(400).json({ error: `未知动作 ${action};可用:approve|enter|escape|space|left|right|up|down|capture|run` });
|
|
6571
6626
|
}
|
|
6572
|
-
await sendTmuxPaneKey(runTmux, pane, action);
|
|
6627
|
+
await sendTmuxPaneKey(runTmux, pane, action, assertCurrent);
|
|
6573
6628
|
res.json({ ok: true, action, sessionId: target });
|
|
6574
6629
|
}
|
|
6575
6630
|
catch (error) {
|
|
@@ -6820,7 +6875,7 @@ router.post('/operations/execution-archives/:sessionId/restore', executionArchiv
|
|
|
6820
6875
|
throw error;
|
|
6821
6876
|
}
|
|
6822
6877
|
await beginAgentLaunchCheck(record.sessionId, target.slug, target.nativeSessionId);
|
|
6823
|
-
|
|
6878
|
+
await writeAutomaticTerminalPrompt(opened.session.sessionId, command);
|
|
6824
6879
|
restoreArchivedMembership(entry, record.sessionId);
|
|
6825
6880
|
await persistGlobalStateNow();
|
|
6826
6881
|
broadcastClientState();
|
|
@@ -8062,7 +8117,10 @@ router.post('/:sessionId/agent-resume', async (req, res) => {
|
|
|
8062
8117
|
const launch = async () => {
|
|
8063
8118
|
if (frontend)
|
|
8064
8119
|
await beginAgentLaunchCheck(frontend.sessionId, target.slug, target.nativeSessionId);
|
|
8065
|
-
|
|
8120
|
+
if (frontend)
|
|
8121
|
+
await writeAutomaticTerminalPrompt(frontend.sessionId, target.command);
|
|
8122
|
+
else
|
|
8123
|
+
await writeBracketedPrompt(session.ptyProcess, target.command, captureTerminalInputGuard(() => terminalSessions.get(sessionId), session));
|
|
8066
8124
|
};
|
|
8067
8125
|
if (frontend)
|
|
8068
8126
|
await collaborationDeliveryWorker.reconfigure(frontend.sessionId, launch);
|
package/docs/integrations.md
CHANGED
|
@@ -519,6 +519,18 @@ no-op restore 不重新开启检查。检查是运行/绑定事实,不表示 T
|
|
|
519
519
|
新检查。一旦当前检查解除,后续普通消息沿原终端路由,不重复判断应用就绪/忙闲。
|
|
520
520
|
已正常 release 的承载登记被移除,下一次 restore 在新承载上重新记录检查。
|
|
521
521
|
|
|
522
|
+
1.4.313 起,定时发送、Agent 启动/恢复命令和普通消息复用实际终端的串行输入队列;
|
|
523
|
+
tmux 的显式 `drive keys/approve` 也在该队列中执行,不插进正文和回车之间。
|
|
524
|
+
定时发送等待实际写入调用结束后才记录成功。等待或排队中的输入绑定原承载和
|
|
525
|
+
TD 启动代次,代次检查解除后仍保留;承载替换、移除或再次启动时,旧操作报
|
|
526
|
+
`TERMINAL_INPUT_TARGET_CHANGED`(登记删除为 `SESSION_REMOVED`),不会跟随逻辑 ID
|
|
527
|
+
写入新的承载。该检查在正文写入前及发送回车前执行;如果承载在两步之间变化,
|
|
528
|
+
正文可能已写入旧终端,回车不会发给新承载。此错误不证明应用是否收到了正文,
|
|
529
|
+
也不自动补键或重发正文。持久消息 worker 仍沿已有回执/路由重试规则处理失败,
|
|
530
|
+
可能写入过的消息须由调用者核对。直接键盘输入不进入自动输入队列。
|
|
531
|
+
能力探测为 `terminal_input_runtime_fence=true`,最低运行服务 1.4.313;CLI 同升
|
|
532
|
+
可读取完整帮助和 `TERMINAL_INPUT_TARGET_CHANGED` 的诊断文案。
|
|
533
|
+
|
|
522
534
|
任务完成继续以负责人明确 `report complete` 为准。
|
|
523
535
|
|
|
524
536
|
恢复保留原 TD ID、原生 ID、argv/model/cwd/包装器配置。原会话已经精确运行时直接
|
package/package.json
CHANGED
package/runtime-manifest.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
3
|
"packageName": "termdock",
|
|
4
|
-
"version": "1.4.
|
|
4
|
+
"version": "1.4.313",
|
|
5
5
|
"runtimeProtocolVersion": 1,
|
|
6
6
|
"minimumDesktopVersion": "1.4.46",
|
|
7
7
|
"nodeMajor": 22,
|
|
8
8
|
"dependencyHash": "sha256-oaGYi/P5ob8sd6VnNBOwshKozjhdV+hYiCmLAsn5x00=",
|
|
9
|
-
"serverBundleHash": "sha256-
|
|
9
|
+
"serverBundleHash": "sha256-LRpuy83bfoSabTOSe76Yykebdslf8gyq1O1oiR4+Z0c=",
|
|
10
10
|
"clientBundleHash": "sha256-rSq/8EDPH8i6WLa9SUHpZCeR4b5vZUPiTUusapoxjBs=",
|
|
11
11
|
"entrypoint": "dist/server/cli.js",
|
|
12
12
|
"clientEntrypoint": "dist/client/index.html"
|