termdock 1.4.311 → 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/agentLaunchCheck.js +22 -0
- package/dist/server/agent/collaborationCli.js +13 -0
- package/dist/server/agent/collaborationTmuxDelivery.js +24 -19
- package/dist/server/agent/integrationServer.js +2 -0
- package/dist/server/agent/promptDelivery.js +6 -8
- package/dist/server/agent/terminalInput.js +36 -0
- package/dist/server/routes/terminal.js +181 -46
- package/docs/integrations.md +27 -0
- package/package.json +1 -1
- package/runtime-manifest.json +2 -2
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export function normalizeAgentLaunchCheck(input) {
|
|
2
|
+
if (!input || typeof input !== 'object')
|
|
3
|
+
return null;
|
|
4
|
+
const value = input;
|
|
5
|
+
if (typeof value.id !== 'string' || !value.id || typeof value.agentSlug !== 'string' || !value.agentSlug
|
|
6
|
+
|| !Number.isFinite(value.deadline) || !(value.nativeSessionId === null || typeof value.nativeSessionId === 'string'))
|
|
7
|
+
return null;
|
|
8
|
+
return { id: value.id, agentSlug: value.agentSlug, nativeSessionId: value.nativeSessionId, deadline: value.deadline };
|
|
9
|
+
}
|
|
10
|
+
/** Caller supplies current terminal process evidence, never screen text or cached hooks. */
|
|
11
|
+
export function agentLaunchBlockReason(check, observed, now = Date.now()) {
|
|
12
|
+
if (!check)
|
|
13
|
+
return null;
|
|
14
|
+
// A late actual observation can resolve an earlier timeout without retyping.
|
|
15
|
+
if (observed.agentSlug === check.agentSlug
|
|
16
|
+
&& (!check.nativeSessionId || observed.nativeSessionId === check.nativeSessionId))
|
|
17
|
+
return null;
|
|
18
|
+
if (observed.agentSlug && observed.agentSlug !== check.agentSlug
|
|
19
|
+
|| check.nativeSessionId && observed.nativeSessionId && observed.nativeSessionId !== check.nativeSessionId)
|
|
20
|
+
return 'AGENT_LAUNCH_IDENTITY_MISMATCH';
|
|
21
|
+
return now >= check.deadline ? 'AGENT_LAUNCH_TIMEOUT' : 'AGENT_LAUNCH_PENDING';
|
|
22
|
+
}
|
|
@@ -175,6 +175,14 @@ 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.
|
|
181
|
+
TD-started/resumed Agents: the first delivery waits for current Agent process and
|
|
182
|
+
terminal binding (exact native id on resume); cold starts do not require a native id.
|
|
183
|
+
AGENT_LAUNCH_PENDING/TIMEOUT leaves the body queued with no terminal write.
|
|
184
|
+
This one-time launch check is not application input-ready; subsequent messages
|
|
185
|
+
and ordinary terminals follow the existing path. No screen text or hooks required.
|
|
178
186
|
No paste-marker recognition, automatic extra Enter or consumption checks.
|
|
179
187
|
The service cannot see whether the recipient's agent consumed a message.
|
|
180
188
|
Use explicit replies as evidence. A screen snapshot is only an observation;
|
|
@@ -444,6 +452,11 @@ const DELIVERY_DIAGNOSTICS = {
|
|
|
444
452
|
DELIVERY_IN_PROGRESS: '投递进行中(写入前的中间标记,正常会在数秒内推进为 delivered 或带原因的重试)',
|
|
445
453
|
TERMINAL_WRITE_FAILED: '写入对方终端失败,会按重试间隔继续尝试',
|
|
446
454
|
TMUX_PANE_CHANGED: '目标面板已变化,投递已跳过,等待重新绑定',
|
|
455
|
+
AGENT_LAUNCH_PENDING: 'TD 启动的 Agent 尚未确认运行和终端绑定;消息保留队列,尚未写入',
|
|
456
|
+
AGENT_LAUNCH_TIMEOUT: '120 秒内未观察到目标 Agent;消息保留,未盲投,不证明应用故障',
|
|
457
|
+
AGENT_LAUNCH_IDENTITY_MISMATCH: '启动后的 Agent 或恢复 UUID 不匹配;消息保留,未写入',
|
|
458
|
+
AGENT_LAUNCH_CHANGED: '启动操作已变化,重新检查当前启动记录',
|
|
459
|
+
TERMINAL_INPUT_TARGET_CHANGED: '等待写入时终端承载或 TD 启动代次发生变化,旧操作未继续写入新承载;请核对回执与快照',
|
|
447
460
|
TMUX_PANE_IN_MODE: '目标面板处于 tmux 模式(如滚动查看),按键已跳过',
|
|
448
461
|
};
|
|
449
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) {
|
|
@@ -224,6 +224,8 @@ export class IntegrationRuntime {
|
|
|
224
224
|
startup_input_conditions: true, session_runtime_release: true, stable_session_identity: true, session_lifecycle_generation: true,
|
|
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
|
+
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',
|
|
227
229
|
integration_policy_update: true,
|
|
228
230
|
session_restore_diagnostics: true,
|
|
229
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,8 @@ 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';
|
|
54
|
+
import { agentLaunchBlockReason, normalizeAgentLaunchCheck } from '../agent/agentLaunchCheck.js';
|
|
53
55
|
import { prepareSupervisionEnable } from '../utils/enableSupervision.js';
|
|
54
56
|
import { buildServerHealthState, dismissServerHealth } from '../utils/serverHealth.js';
|
|
55
57
|
import { collaborationRoutes } from '../agent/collaborationRoutes.js';
|
|
@@ -965,6 +967,9 @@ function normalizePersistedClientSession(input) {
|
|
|
965
967
|
? candidate.shellTitle
|
|
966
968
|
: null,
|
|
967
969
|
agentResume: normalizePersistedAgentResumeInfo(candidate.agentResume),
|
|
970
|
+
agentLaunchCheck: normalizeAgentLaunchCheck(candidate.agentLaunchCheck),
|
|
971
|
+
agentLaunchGeneration: typeof candidate.agentLaunchGeneration === 'string' ? candidate.agentLaunchGeneration
|
|
972
|
+
: normalizeAgentLaunchCheck(candidate.agentLaunchCheck)?.id ?? null,
|
|
968
973
|
};
|
|
969
974
|
}
|
|
970
975
|
function generateTmuxSessionName() {
|
|
@@ -1447,18 +1452,95 @@ function searchMetadataForBackend(backendSessionId, session) {
|
|
|
1447
1452
|
updatedAt: session.lastActivity,
|
|
1448
1453
|
};
|
|
1449
1454
|
}
|
|
1450
|
-
function
|
|
1451
|
-
|
|
1455
|
+
async function beginAgentLaunchCheck(frontendSessionId, agentSlug, nativeSessionId = null) {
|
|
1456
|
+
const record = globalSessionState.sessions.find(candidate => candidate.sessionId === frontendSessionId);
|
|
1457
|
+
if (!record)
|
|
1458
|
+
throw new Error('SESSION_REMOVED');
|
|
1459
|
+
const id = randomUUID();
|
|
1460
|
+
upsertGlobalSessionRecord({ ...record, agentLaunchGeneration: id, agentLaunchCheck: { id, agentSlug, nativeSessionId, deadline: Date.now() + 120_000 } });
|
|
1461
|
+
await persistGlobalStateNow();
|
|
1452
1462
|
}
|
|
1453
|
-
function
|
|
1454
|
-
const
|
|
1455
|
-
if (!
|
|
1456
|
-
return;
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
return;
|
|
1463
|
+
async function observeAgentLaunchCheck(record, observed) {
|
|
1464
|
+
const check = record.agentLaunchCheck;
|
|
1465
|
+
if (!check)
|
|
1466
|
+
return null;
|
|
1467
|
+
const reason = agentLaunchBlockReason(check, observed);
|
|
1468
|
+
if (reason)
|
|
1469
|
+
return { state: 'recovering', reason };
|
|
1470
|
+
const latest = globalSessionState.sessions.find(candidate => candidate.sessionId === record.sessionId);
|
|
1471
|
+
if (!latest)
|
|
1472
|
+
return { state: 'offline', reason: 'SESSION_REMOVED' };
|
|
1473
|
+
if (latest.agentLaunchCheck?.id !== check.id)
|
|
1474
|
+
return { state: 'recovering', reason: 'AGENT_LAUNCH_CHANGED' };
|
|
1475
|
+
upsertGlobalSessionRecord({ ...latest, agentLaunchCheck: null });
|
|
1476
|
+
await persistGlobalStateNow();
|
|
1477
|
+
return null;
|
|
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);
|
|
1460
1509
|
}
|
|
1461
|
-
|
|
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) {
|
|
1523
|
+
const session = terminalSessions.get(backendSessionId);
|
|
1524
|
+
const record = globalSessionState.sessions.find(candidate => candidate.backendSessionId === backendSessionId);
|
|
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;
|
|
1540
|
+
if (blocked.reason !== 'AGENT_LAUNCH_PENDING')
|
|
1541
|
+
throw new Error(blocked.reason ?? 'AGENT_LAUNCH_PENDING');
|
|
1542
|
+
return false;
|
|
1543
|
+
} });
|
|
1462
1544
|
}
|
|
1463
1545
|
async function runAgentAutomation(automation, req) {
|
|
1464
1546
|
const run = automationStore.beginRun(automation);
|
|
@@ -1469,13 +1551,13 @@ async function runAgentAutomation(automation, req) {
|
|
|
1469
1551
|
const backend = record?.backendSessionId ? terminalSessions.get(record.backendSessionId) : null;
|
|
1470
1552
|
if (!record || !backend)
|
|
1471
1553
|
throw new Error('目标会话当前不在线');
|
|
1472
|
-
if (!canDeliverPromptToAgent(backend))
|
|
1554
|
+
if (!record.agentLaunchCheck && !canDeliverPromptToAgent(backend))
|
|
1473
1555
|
throw new Error('目标会话当前没有运行中的 Agent');
|
|
1474
1556
|
frontendSessionId = record.sessionId;
|
|
1475
1557
|
const message = automation.prompt || automation.command;
|
|
1476
1558
|
if (!message)
|
|
1477
1559
|
throw new Error('自动任务没有可发送的内容');
|
|
1478
|
-
await
|
|
1560
|
+
await deliverAutomationPromptWhenReady(record.backendSessionId, message);
|
|
1479
1561
|
}
|
|
1480
1562
|
else {
|
|
1481
1563
|
const request = req ?? {};
|
|
@@ -1489,16 +1571,20 @@ async function runAgentAutomation(automation, req) {
|
|
|
1489
1571
|
const backend = terminalSessions.get(opened.terminalSession.sessionId);
|
|
1490
1572
|
if (!backend)
|
|
1491
1573
|
throw new Error('自动任务会话创建失败');
|
|
1492
|
-
if (automation.command)
|
|
1493
|
-
|
|
1574
|
+
if (automation.command) {
|
|
1575
|
+
const agent = detectAgentFromCommand(automation.command, agentCustomCommands());
|
|
1576
|
+
if (agent)
|
|
1577
|
+
await beginAgentLaunchCheck(frontendSessionId, agent.slug);
|
|
1578
|
+
await writeAutomaticTerminalPrompt(frontendSessionId, automation.command);
|
|
1579
|
+
}
|
|
1494
1580
|
if (automation.prompt) {
|
|
1495
1581
|
if (automation.command)
|
|
1496
|
-
deliverAutomationPromptWhenReady(opened.terminalSession.sessionId, automation.prompt);
|
|
1582
|
+
await deliverAutomationPromptWhenReady(opened.terminalSession.sessionId, automation.prompt);
|
|
1497
1583
|
else
|
|
1498
|
-
await
|
|
1584
|
+
await writeAutomaticTerminalPrompt(frontendSessionId, automation.prompt);
|
|
1499
1585
|
}
|
|
1500
1586
|
}
|
|
1501
|
-
automationStore.finishRun(run.id, 'success', frontendSessionId, '
|
|
1587
|
+
automationStore.finishRun(run.id, 'success', frontendSessionId, '已粘贴到终端并发送回车');
|
|
1502
1588
|
}
|
|
1503
1589
|
catch (error) {
|
|
1504
1590
|
automationStore.finishRun(run.id, 'failed', frontendSessionId, getErrorMessage(error));
|
|
@@ -1580,7 +1666,7 @@ function formatLocalCollaborationMessages(frontendSessionId, messages) {
|
|
|
1580
1666
|
* agent-keyed selector can never reach it. Delivery still needs an agent (the
|
|
1581
1667
|
* confirm gate and prompt formatting assume a TUI); driving a terminal —
|
|
1582
1668
|
* run/capture — is a shell operation and works on any pane. */
|
|
1583
|
-
async function inspectCollaborationTmux(binding, requestedPane, allowPlainPane = false, nativeProcessIdentity = false) {
|
|
1669
|
+
async function inspectCollaborationTmux(binding, requestedPane, allowPlainPane = false, nativeProcessIdentity = false, freshProcess = false) {
|
|
1584
1670
|
if (!binding.tmuxSessionName)
|
|
1585
1671
|
return { state: 'offline', reason: 'TMUX_BINDING_MISSING' };
|
|
1586
1672
|
const name = `=${binding.tmuxSessionName}`;
|
|
@@ -1599,12 +1685,12 @@ async function inspectCollaborationTmux(binding, requestedPane, allowPlainPane =
|
|
|
1599
1685
|
const serverPid = Number((await runTmux(['display-message', '-p', '#{pid}'])).trim());
|
|
1600
1686
|
if (!Number.isInteger(serverPid) || serverPid <= 0)
|
|
1601
1687
|
throw new Error('TMUX_SERVER_IDENTITY_UNAVAILABLE');
|
|
1602
|
-
if (nativeProcessIdentity && process.platform === 'linux')
|
|
1688
|
+
if ((nativeProcessIdentity || freshProcess) && process.platform === 'linux')
|
|
1603
1689
|
await getProcessSnapshot(true);
|
|
1604
1690
|
const panes = await Promise.all(layout.windows.flatMap((window) => window.panes).map(async (pane) => {
|
|
1605
1691
|
// Native identity needs the foreground argv even for a named binary;
|
|
1606
1692
|
// pane_current_command alone cannot prove an exact resumed conversation.
|
|
1607
|
-
const program = await resolveTmuxPaneProgram(pane, true);
|
|
1693
|
+
const program = await resolveTmuxPaneProgram(pane, true, nativeProcessIdentity || freshProcess);
|
|
1608
1694
|
const agent = detectAgentFromCommand(program?.rawArgs ?? program?.command ?? '', agentCustomCommands());
|
|
1609
1695
|
const identity = nativeProcessIdentity ? await observeProgramNativeIdentity(agent, program)
|
|
1610
1696
|
: { nativeId: agent && program?.rawArgs ? inferResumeSessionId(agent, splitCommandToArgv(program.rawArgs)) : null, failure: null };
|
|
@@ -1664,8 +1750,8 @@ async function rebindCollaborationRoute(frontendSessionId, paneId) {
|
|
|
1664
1750
|
return { ...collaborationRouting.get(frontendSessionId), state: 'recovering' };
|
|
1665
1751
|
}
|
|
1666
1752
|
/**
|
|
1667
|
-
*
|
|
1668
|
-
*
|
|
1753
|
+
* A TD-initiated Agent launch waits once for current process/binding evidence.
|
|
1754
|
+
* Existing terminals receive messages whatever their agent is doing. Busy guessing (hook state,
|
|
1669
1755
|
* turn phase) is the sender's job — the send receipt carries a screen
|
|
1670
1756
|
* snapshot of the recipient after the write, and the sender judges from that.
|
|
1671
1757
|
*/
|
|
@@ -1678,19 +1764,24 @@ async function resolveCollaborationRoute(frontendSessionId) {
|
|
|
1678
1764
|
const startup = await integrationDeliveryGuard?.(frontendSessionId);
|
|
1679
1765
|
if (startup && !startup.allowed)
|
|
1680
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);
|
|
1681
1768
|
let backend = resolveOrchestrationBackend(record);
|
|
1682
1769
|
let binding = collaborationRouting.get(frontendSessionId) ?? {
|
|
1683
1770
|
sessionId: record.sessionId, backendSessionId: null, mode: record.mode, tmuxSessionName: record.tmuxSessionName,
|
|
1684
1771
|
agentSlug: record.agentResume?.slug ?? null, nativeSessionId: record.agentResume?.sessionId ?? null, pane: null,
|
|
1685
1772
|
};
|
|
1686
1773
|
if (binding.mode === 'tmux') {
|
|
1687
|
-
const inspected = await inspectCollaborationTmux(binding);
|
|
1774
|
+
const inspected = await inspectCollaborationTmux(binding, undefined, false, Boolean(record.agentLaunchCheck?.nativeSessionId), Boolean(record.agentLaunchCheck));
|
|
1775
|
+
assertGeneration();
|
|
1688
1776
|
if (inspected.state !== 'ready' || !inspected.pane)
|
|
1689
1777
|
return inspected;
|
|
1690
1778
|
record = globalSessionState.sessions.find((candidate) => candidate.sessionId === frontendSessionId);
|
|
1691
1779
|
if (!record)
|
|
1692
1780
|
return { state: 'offline', reason: 'SESSION_REMOVED_DURING_RECOVERY' };
|
|
1693
1781
|
const pane = inspected.pane;
|
|
1782
|
+
const launch = await observeAgentLaunchCheck(record, { agentSlug: pane.agentSlug || null, nativeSessionId: pane.nativeSessionId });
|
|
1783
|
+
if (launch)
|
|
1784
|
+
return launch;
|
|
1694
1785
|
// Remember the target before attachment. A failed attachment must not let
|
|
1695
1786
|
// a subsequent active-pane change silently choose another recipient.
|
|
1696
1787
|
const pinned = { serverPid: pane.serverPid, sessionId: pane.sessionId, paneId: pane.paneId, panePid: pane.panePid,
|
|
@@ -1717,17 +1808,28 @@ async function resolveCollaborationRoute(frontendSessionId) {
|
|
|
1717
1808
|
}
|
|
1718
1809
|
if (!backend)
|
|
1719
1810
|
return { state: 'detached', reason: 'TMUX_BACKEND_UNAVAILABLE' };
|
|
1811
|
+
assertGeneration();
|
|
1812
|
+
const assertCurrent = terminalInputGuard(globalSessionState.sessions.find(candidate => candidate.sessionId === frontendSessionId));
|
|
1720
1813
|
return { state: 'ready', isShell: pane.isShell, capture: async () => captureTmuxPaneText(runTmux, pinned), write: async (messages) => {
|
|
1721
1814
|
if (!globalSessionState.sessions.some((candidate) => candidate.sessionId === frontendSessionId))
|
|
1722
1815
|
throw new Error('SESSION_REMOVED');
|
|
1723
|
-
await writeCollaborationTmuxPane(runTmux, pinned, formatLocalCollaborationMessages(frontendSessionId, messages), runTmuxStdin);
|
|
1816
|
+
await writeCollaborationTmuxPane(runTmux, pinned, formatLocalCollaborationMessages(frontendSessionId, messages), runTmuxStdin, () => { assertGeneration(); assertCurrent(); });
|
|
1724
1817
|
backend.lastActivity = Date.now();
|
|
1725
1818
|
} };
|
|
1726
1819
|
}
|
|
1727
1820
|
if (!backend || !binding.backendSessionId)
|
|
1728
1821
|
return { state: 'offline', reason: 'SHELL_BACKEND_NOT_RUNNING' };
|
|
1729
|
-
await refreshCollaborationAgentIdentity(binding.backendSessionId, backend);
|
|
1822
|
+
await refreshCollaborationAgentIdentity(binding.backendSessionId, backend, Boolean(record.agentLaunchCheck));
|
|
1823
|
+
assertGeneration();
|
|
1730
1824
|
const target = backend;
|
|
1825
|
+
const assertCurrent = terminalInputGuard(record);
|
|
1826
|
+
if (record.agentLaunchCheck) {
|
|
1827
|
+
const agent = detectAgentFromCommand(target.activeProgram?.rawArgs ?? target.activeProgram?.command ?? '', agentCustomCommands());
|
|
1828
|
+
const nativeSessionId = agent && target.activeProgram?.rawArgs ? inferResumeSessionId(agent, splitCommandToArgv(target.activeProgram.rawArgs)) : null;
|
|
1829
|
+
const launch = await observeAgentLaunchCheck(record, { agentSlug: agent?.slug ?? null, nativeSessionId });
|
|
1830
|
+
if (launch)
|
|
1831
|
+
return launch;
|
|
1832
|
+
}
|
|
1731
1833
|
return { state: 'ready',
|
|
1732
1834
|
isShell: backend.activeProgram?.source !== 'unknown' && shellNamesBackend.has(backend.activeProgram?.command?.toLowerCase() ?? ''),
|
|
1733
1835
|
capture: async () => target.autoTitleContext,
|
|
@@ -1736,19 +1838,26 @@ async function resolveCollaborationRoute(frontendSessionId) {
|
|
|
1736
1838
|
throw new Error('SESSION_REMOVED');
|
|
1737
1839
|
if (terminalSessions.get(binding.backendSessionId) !== target)
|
|
1738
1840
|
throw new Error('SHELL_BACKEND_CHANGED');
|
|
1739
|
-
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
|
+
});
|
|
1740
1847
|
target.lastActivity = Date.now();
|
|
1741
1848
|
} };
|
|
1742
1849
|
}
|
|
1743
|
-
async function refreshCollaborationAgentIdentity(backendSessionId, session) {
|
|
1850
|
+
async function refreshCollaborationAgentIdentity(backendSessionId, session, freshProcess = false) {
|
|
1744
1851
|
let activeProgram = null;
|
|
1745
1852
|
if (session.mode === 'shell') {
|
|
1746
|
-
activeProgram = await getCachedShellActiveProgram(backendSessionId, session);
|
|
1853
|
+
activeProgram = freshProcess ? await detectShellActiveProgram(session, true) : await getCachedShellActiveProgram(backendSessionId, session);
|
|
1747
1854
|
}
|
|
1748
1855
|
else if (session.tmuxSessionName) {
|
|
1749
|
-
const layout = await getCachedTmuxLayout(session.tmuxSessionName);
|
|
1856
|
+
const layout = freshProcess ? await getTmuxLayout(session.tmuxSessionName) : await getCachedTmuxLayout(session.tmuxSessionName);
|
|
1857
|
+
if (freshProcess && process.platform === 'linux')
|
|
1858
|
+
await getProcessSnapshot(true);
|
|
1750
1859
|
const activePane = getActivePaneFromLayout(layout);
|
|
1751
|
-
const resolved = activePane ? await resolveTmuxPaneProgram(activePane) : null;
|
|
1860
|
+
const resolved = activePane ? await resolveTmuxPaneProgram(activePane, freshProcess, freshProcess) : null;
|
|
1752
1861
|
activeProgram = resolved
|
|
1753
1862
|
? { ...resolved, updatedAt: Date.now() }
|
|
1754
1863
|
: getActiveProgramFromTmuxLayout(layout);
|
|
@@ -1792,6 +1901,7 @@ async function spawnCollaborationAgentSession(req, group, sourceSessionId, input
|
|
|
1792
1901
|
const backend = terminalSessions.get(opened.terminalSession.sessionId);
|
|
1793
1902
|
if (!backend)
|
|
1794
1903
|
throw new HttpStatusError(500, 'Agent Session 创建失败', 'COLLAB_AGENT_SESSION_FAILED');
|
|
1904
|
+
await beginAgentLaunchCheck(frontendSessionId, launcher.slug);
|
|
1795
1905
|
const latestGroup = collaborationStore.getGroup(group.id);
|
|
1796
1906
|
if (!latestGroup || latestGroup.deleted) {
|
|
1797
1907
|
const created = globalSessionState.sessions.find(record => record.sessionId === frontendSessionId);
|
|
@@ -1814,7 +1924,7 @@ async function spawnCollaborationAgentSession(req, group, sourceSessionId, input
|
|
|
1814
1924
|
collaborationStore.setRole({ groupId: group.id, sessionId: frontendSessionId, role: bootstrapRole });
|
|
1815
1925
|
if (profile)
|
|
1816
1926
|
collaborationStore.setMemberLaunchProfile(group.id, frontendSessionId, profile);
|
|
1817
|
-
|
|
1927
|
+
await writeAutomaticTerminalPrompt(frontendSessionId, buildCollaborationSpawnCommand({ slug: launcher.slug, command: profile?.command ?? launcher.command }));
|
|
1818
1928
|
const requestedTask = typeof input.task === 'string' ? input.task.trim().slice(0, 20_000) : '';
|
|
1819
1929
|
collaborationStore.send({
|
|
1820
1930
|
groupId: updatedGroup.id,
|
|
@@ -2096,8 +2206,8 @@ async function launchIntegrationTerminal(record, restoring, prepared) {
|
|
|
2096
2206
|
backend.agentSession = null;
|
|
2097
2207
|
backend.agent = null;
|
|
2098
2208
|
backend.agentResumeRecovered = false;
|
|
2099
|
-
await
|
|
2100
|
-
await
|
|
2209
|
+
await beginAgentLaunchCheck(record.session_id, record.agent_slug, restoring ? record.agent_native_session_id : null);
|
|
2210
|
+
await writeAutomaticTerminalPrompt(record.session_id, integrationLaunchCommand(record, restoring));
|
|
2101
2211
|
broadcastClientState();
|
|
2102
2212
|
});
|
|
2103
2213
|
}
|
|
@@ -2144,11 +2254,12 @@ export function prepareCollaborationTaskWorker(task, template, dependencies, ser
|
|
|
2144
2254
|
throw error;
|
|
2145
2255
|
}
|
|
2146
2256
|
if (!currentAgent && (!launch || launch.backendId !== opened.terminalSession.sessionId)) {
|
|
2257
|
+
await beginAgentLaunchCheck(record.sessionId, launcher.slug);
|
|
2147
2258
|
// Persist before typing: a retry must never type the launcher into a newly booted Agent's prompt.
|
|
2148
2259
|
const temporary = `${launchFile}.${process.pid}.tmp`;
|
|
2149
2260
|
await fs.promises.writeFile(temporary, JSON.stringify({ sessionId: record.sessionId, backendId: opened.terminalSession.sessionId }), { mode: 0o600 });
|
|
2150
2261
|
await fs.promises.rename(temporary, launchFile);
|
|
2151
|
-
|
|
2262
|
+
await writeAutomaticTerminalPrompt(opened.session.sessionId, buildCollaborationSpawnCommand({ slug: launcher.slug, command: inheritedProfile?.command ?? launcher.command }));
|
|
2152
2263
|
}
|
|
2153
2264
|
const group = collaborationStore.getGroup(task.groupId);
|
|
2154
2265
|
if (!group || group.deleted)
|
|
@@ -4247,7 +4358,7 @@ async function sampleTerminalSession(sessionId, session) {
|
|
|
4247
4358
|
events.push({ type: 'tmux-layout', layout });
|
|
4248
4359
|
return events;
|
|
4249
4360
|
}
|
|
4250
|
-
async function detectShellActiveProgram(session) {
|
|
4361
|
+
async function detectShellActiveProgram(session, includeArguments = false) {
|
|
4251
4362
|
const pid = getPtyProcessPid(session.ptyProcess);
|
|
4252
4363
|
if (pid === null) {
|
|
4253
4364
|
return null;
|
|
@@ -4286,10 +4397,15 @@ async function detectShellActiveProgram(session) {
|
|
|
4286
4397
|
const foregroundRows = rows.filter((row) => row.command && row.tpgid > 0 && row.pgid === row.tpgid && !row.stat.startsWith('Z'));
|
|
4287
4398
|
const preferredForeground = foregroundRows.find((row) => row.pid !== pid) ?? foregroundRows[foregroundRows.length - 1];
|
|
4288
4399
|
if (preferredForeground?.command) {
|
|
4400
|
+
// Startup checks need current argv, including an exact resume UUID;
|
|
4401
|
+
// ordinary terminal sampling retains its existing inexpensive path.
|
|
4402
|
+
const rawArgs = includeArguments
|
|
4403
|
+
? (await execFileAsync('ps', ['-p', String(preferredForeground.pid), '-o', 'args='], { timeout: 3000, maxBuffer: 512 * 1024 })).stdout.trim() || null
|
|
4404
|
+
: null;
|
|
4289
4405
|
return {
|
|
4290
4406
|
command: preferredForeground.command,
|
|
4291
4407
|
source: 'shell-tty',
|
|
4292
|
-
rawArgs
|
|
4408
|
+
rawArgs,
|
|
4293
4409
|
updatedAt: Date.now(),
|
|
4294
4410
|
};
|
|
4295
4411
|
}
|
|
@@ -5799,16 +5915,21 @@ router.post('/tmux/recovery/restore-all', async (_req, res) => {
|
|
|
5799
5915
|
continue;
|
|
5800
5916
|
}
|
|
5801
5917
|
try {
|
|
5802
|
-
await tmuxLifecycle.run(`restore:${record.tmuxSessionName}`, async () => {
|
|
5918
|
+
await collaborationDeliveryWorker.reconfigure(frontendSessionId, () => tmuxLifecycle.run(`restore:${record.tmuxSessionName}`, async () => {
|
|
5803
5919
|
await prepareManagedTmuxSessionUnlocked(record.tmuxSessionName, record.cwd);
|
|
5804
5920
|
const metadata = await resolveLiveTmuxMetadata(record.tmuxSessionName);
|
|
5805
5921
|
const foreground = metadata?.program?.toLowerCase() ?? null;
|
|
5806
5922
|
if (foreground && !shellNamesBackend.has(foreground)) {
|
|
5807
5923
|
throw new Error(`pane is busy with ${foreground}`);
|
|
5808
5924
|
}
|
|
5809
|
-
|
|
5810
|
-
|
|
5811
|
-
|
|
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
|
+
}));
|
|
5812
5933
|
outcomes.push({ sessionId: frontendSessionId, restored: true });
|
|
5813
5934
|
}
|
|
5814
5935
|
catch (error) {
|
|
@@ -6424,7 +6545,7 @@ router.post('/operations/orchestration/cleanup', async (req, res) => {
|
|
|
6424
6545
|
// caller, tmux-local only, and `approve` refuses unless an approval dialog is
|
|
6425
6546
|
// actually showing (Enter on the highlighted option). The dialogs may be
|
|
6426
6547
|
// approving any command the member agent asked for; the caller is presumed to
|
|
6427
|
-
// own that decision
|
|
6548
|
+
// own that decision. Ordinary delivery never invokes approval automatically.
|
|
6428
6549
|
const DRIVE_KEYS = new Set(['enter', 'escape', 'space', 'left', 'right', 'up', 'down']);
|
|
6429
6550
|
router.post('/operations/orchestration/drive', async (req, res) => {
|
|
6430
6551
|
const sourceSessionId = resolveFrontendSessionId(req.body ?? {});
|
|
@@ -6447,6 +6568,7 @@ router.post('/operations/orchestration/drive', async (req, res) => {
|
|
|
6447
6568
|
return res.status(409).json({ error: '目标会话不是 tmux 会话,无法从 CLI 驱动(模式:' + (record.mode ?? 'unknown') + ')' });
|
|
6448
6569
|
}
|
|
6449
6570
|
const binding = collaborationRouting.get(target);
|
|
6571
|
+
const assertCurrent = terminalInputGuard(record);
|
|
6450
6572
|
let pane = binding?.pane ?? null;
|
|
6451
6573
|
if (!pane) {
|
|
6452
6574
|
// No pinned Agent pane: resolve the session's pane directly so a plain
|
|
@@ -6474,7 +6596,7 @@ router.post('/operations/orchestration/drive', async (req, res) => {
|
|
|
6474
6596
|
}
|
|
6475
6597
|
try {
|
|
6476
6598
|
if (action === 'approve') {
|
|
6477
|
-
const approved = await approveCollaborationDialog(runTmux, pane);
|
|
6599
|
+
const approved = await approveCollaborationDialog(runTmux, pane, assertCurrent);
|
|
6478
6600
|
if (!approved)
|
|
6479
6601
|
return res.status(409).json({ ok: false, code: 'NO_APPROVAL_DIALOG', error: '目标面板当前没有显示审批对话框;未发送任何按键' });
|
|
6480
6602
|
return res.json({ ok: true, action, sessionId: target, approved: true });
|
|
@@ -6495,14 +6617,14 @@ router.post('/operations/orchestration/drive', async (req, res) => {
|
|
|
6495
6617
|
return res.status(400).json({ error: 'run 文本过长(上限 10000 字符);需要更多内容请走 td collab send 投递' });
|
|
6496
6618
|
// Same bracketed-paste write path deliveries use: one line, one submit,
|
|
6497
6619
|
// embedded newlines stay inside the recipient's editor.
|
|
6498
|
-
await writeCollaborationTmuxPane(runTmux, pane, text, runTmuxStdin);
|
|
6620
|
+
await writeCollaborationTmuxPane(runTmux, pane, text, runTmuxStdin, assertCurrent);
|
|
6499
6621
|
const snapshot = await captureTmuxPaneText(runTmux, pane);
|
|
6500
6622
|
return res.json({ ok: true, action, sessionId: target, text, snapshot });
|
|
6501
6623
|
}
|
|
6502
6624
|
if (!DRIVE_KEYS.has(action)) {
|
|
6503
6625
|
return res.status(400).json({ error: `未知动作 ${action};可用:approve|enter|escape|space|left|right|up|down|capture|run` });
|
|
6504
6626
|
}
|
|
6505
|
-
await sendTmuxPaneKey(runTmux, pane, action);
|
|
6627
|
+
await sendTmuxPaneKey(runTmux, pane, action, assertCurrent);
|
|
6506
6628
|
res.json({ ok: true, action, sessionId: target });
|
|
6507
6629
|
}
|
|
6508
6630
|
catch (error) {
|
|
@@ -6752,7 +6874,8 @@ router.post('/operations/execution-archives/:sessionId/restore', executionArchiv
|
|
|
6752
6874
|
broadcastClientState();
|
|
6753
6875
|
throw error;
|
|
6754
6876
|
}
|
|
6755
|
-
|
|
6877
|
+
await beginAgentLaunchCheck(record.sessionId, target.slug, target.nativeSessionId);
|
|
6878
|
+
await writeAutomaticTerminalPrompt(opened.session.sessionId, command);
|
|
6756
6879
|
restoreArchivedMembership(entry, record.sessionId);
|
|
6757
6880
|
await persistGlobalStateNow();
|
|
6758
6881
|
broadcastClientState();
|
|
@@ -7990,7 +8113,19 @@ router.post('/:sessionId/agent-resume', async (req, res) => {
|
|
|
7990
8113
|
});
|
|
7991
8114
|
}
|
|
7992
8115
|
try {
|
|
7993
|
-
|
|
8116
|
+
const frontend = globalSessionState.sessions.find(candidate => candidate.backendSessionId === sessionId);
|
|
8117
|
+
const launch = async () => {
|
|
8118
|
+
if (frontend)
|
|
8119
|
+
await beginAgentLaunchCheck(frontend.sessionId, target.slug, target.nativeSessionId);
|
|
8120
|
+
if (frontend)
|
|
8121
|
+
await writeAutomaticTerminalPrompt(frontend.sessionId, target.command);
|
|
8122
|
+
else
|
|
8123
|
+
await writeBracketedPrompt(session.ptyProcess, target.command, captureTerminalInputGuard(() => terminalSessions.get(sessionId), session));
|
|
8124
|
+
};
|
|
8125
|
+
if (frontend)
|
|
8126
|
+
await collaborationDeliveryWorker.reconfigure(frontend.sessionId, launch);
|
|
8127
|
+
else
|
|
8128
|
+
await launch();
|
|
7994
8129
|
session.lastActivity = Date.now();
|
|
7995
8130
|
res.json({ success: true, command: target.command });
|
|
7996
8131
|
}
|
package/docs/integrations.md
CHANGED
|
@@ -504,6 +504,33 @@ session 事件;get 也会现场核验。120 秒内未形成绑定返回 `SESSI
|
|
|
504
504
|
不自动补回车、不根据终端文字确认消费。`delivered`/`pty_written` 是现有回执字段,
|
|
505
505
|
仅记录终端写入调用完成,不保证对方收到、提交、消费或执行。快照只作观测,明确回复单独关联。
|
|
506
506
|
旧回执中的 `AGENT_CONSUME_UNCONFIRMED` 保留历史,不触发补键或重投。
|
|
507
|
+
|
|
508
|
+
1.4.312 起,TD 实际启动/恢复 Agent 时持久记录一次进程检查意图。仅该次启动的
|
|
509
|
+
首次投递在原终端观察到目标 Agent 后放行;恢复还须匹配原 native UUID,冷启动
|
|
510
|
+
不要求首次输入前尚未生成的 UUID。已有会话和普通 Terminal 不新增检查;运行中
|
|
511
|
+
no-op restore 不重新开启检查。检查是运行/绑定事实,不表示 TUI 已完成输入初始化。
|
|
512
|
+
`startup_input_disabled` 仅清除画面条件,独立于该进程检查。capabilities 为
|
|
513
|
+
`agent_launch_process_check=true`,最低运行服务 1.4.312;建议 CLI 同升以取得新诊断文案。
|
|
514
|
+
|
|
515
|
+
未确认时普通消息保持 pending、实际写入次数不增加,诊断 `AGENT_LAUNCH_PENDING`;
|
|
516
|
+
120 秒后 `AGENT_LAUNCH_TIMEOUT`,不同 Agent/原 UUID 为 `AGENT_LAUNCH_IDENTITY_MISMATCH`。
|
|
517
|
+
不因超时盲投或重新启动,后续如果现场确实观察到正确进程可解除检查。检查随活动
|
|
518
|
+
终端登记持久化,服务重启保留;新的实际启动/恢复重新生成检查 ID,旧观察不能清除
|
|
519
|
+
新检查。一旦当前检查解除,后续普通消息沿原终端路由,不重复判断应用就绪/忙闲。
|
|
520
|
+
已正常 release 的承载登记被移除,下一次 restore 在新承载上重新记录检查。
|
|
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
|
+
|
|
507
534
|
任务完成继续以负责人明确 `report complete` 为准。
|
|
508
535
|
|
|
509
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"
|