termdock 1.4.193 → 1.4.195
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/collaborationDeliveryWorker.js +13 -4
- package/dist/server/agent/collaborationPrompt.js +15 -3
- package/dist/server/agent/collaborationTmuxDelivery.js +43 -7
- package/dist/server/agent/promptDelivery.js +11 -3
- package/dist/server/routes/terminal.js +28 -2
- package/package.json +1 -1
- package/runtime-manifest.json +2 -2
|
@@ -128,9 +128,13 @@ export class CollaborationDeliveryWorker {
|
|
|
128
128
|
// rendered it). Unconfirmed writes are re-attempted up to the configured
|
|
129
129
|
// bound; at-least-once transport is preserved throughout.
|
|
130
130
|
const confirmMs = this.options.firstDeliveryConfirmMs ?? 1_500;
|
|
131
|
+
// A delivery that has already spent its confirm budget still has to be
|
|
132
|
+
// written — the queue must never wedge — but it settles carrying the
|
|
133
|
+
// unconfirmed reason instead of looking identical to a confirmed one.
|
|
134
|
+
const boundReachedUnconfirmed = attempts + 1 >= (this.options.maxUnconfirmedWrites ?? 3);
|
|
131
135
|
const gateActive = Boolean(route.confirm) && confirmMs > 0
|
|
132
136
|
&& Date.now() >= (this.confirmedUntil.get(id) ?? 0)
|
|
133
|
-
&&
|
|
137
|
+
&& !boundReachedUnconfirmed;
|
|
134
138
|
// Differential baseline for stuck-paste recovery: captured before the
|
|
135
139
|
// write so a later screen diff can tell our paste apart from stale ones.
|
|
136
140
|
let baseline = '';
|
|
@@ -185,7 +189,7 @@ export class CollaborationDeliveryWorker {
|
|
|
185
189
|
}
|
|
186
190
|
// If persistence fails after writing, the in-process guard avoids a
|
|
187
191
|
// second write on retry. Across a crash, transport is at-least-once.
|
|
188
|
-
this.complete(id, message);
|
|
192
|
+
this.complete(id, message, boundReachedUnconfirmed ? 'AGENT_CONSUME_UNCONFIRMED' : null);
|
|
189
193
|
}
|
|
190
194
|
/** Wait out the confirm window, then look for the written messages in the
|
|
191
195
|
* recipient's terminal history. A message found there was rendered by the
|
|
@@ -223,13 +227,18 @@ export class CollaborationDeliveryWorker {
|
|
|
223
227
|
}
|
|
224
228
|
return false;
|
|
225
229
|
}
|
|
226
|
-
|
|
230
|
+
/** Settle a delivery as delivered. `unconfirmedReason`, when set, is a
|
|
231
|
+
* diagnosis that survives the settle: the write reached the pty (there is
|
|
232
|
+
* nothing more a retry may do) but the recipient never showed it, so the
|
|
233
|
+
* sender can still see why. Never re-queues — that is what would wedge the
|
|
234
|
+
* queue and duplicate the body. */
|
|
235
|
+
complete(id, message, unconfirmedReason = null) {
|
|
227
236
|
const { store } = this.options;
|
|
228
237
|
store.markDelivered([message.id]);
|
|
229
238
|
this.submitted.delete(message.id);
|
|
230
239
|
store.recordTransport(message.id, {
|
|
231
240
|
relay_online: null, peer_reachable: true, attempt_count: store.diagnostic(message.id)?.attempt_count ?? 1,
|
|
232
|
-
next_retry_at: null, last_error:
|
|
241
|
+
next_retry_at: null, last_error: unconfirmedReason, checked_at: Date.now(),
|
|
233
242
|
});
|
|
234
243
|
this.failures.delete(id);
|
|
235
244
|
}
|
|
@@ -25,8 +25,21 @@ export function sanitizeCollaborationName(name, max = 40) {
|
|
|
25
25
|
.trim();
|
|
26
26
|
return cleaned.length > max ? `${cleaned.slice(0, max - 1)}…` : cleaned;
|
|
27
27
|
}
|
|
28
|
+
/** The one line of a delivered block that carries `message.id`, chosen by what
|
|
29
|
+
* the recipient can actually do with it: an agent-sourced message gets the
|
|
30
|
+
* reply command, a user message gets the read-back command (`td collab reply`
|
|
31
|
+
* refuses user messages — NO_REPLY_TARGET — so naming it there would be a dead
|
|
32
|
+
* command). The delivery-confirm gate searches the recipient terminal for
|
|
33
|
+
* `message.id`, so every message must carry it regardless of source: this
|
|
34
|
+
* function is the single place that decides how, and the invariant is pinned
|
|
35
|
+
* by the "carries its id" test in collaborationPrompt.test.ts. */
|
|
36
|
+
export function collaborationMessageAnchorLine(message) {
|
|
37
|
+
return message.fromSessionId
|
|
38
|
+
? `回复:td collab reply ${message.id} "回复内容" --text`
|
|
39
|
+
: `详情:td collab message get ${message.id} --json`;
|
|
40
|
+
}
|
|
28
41
|
/** A single delivered message: `来自:X · kind` over a fenced body, with the
|
|
29
|
-
* reply route
|
|
42
|
+
* anchor line (reply route / read-back route) kept outside the fence. A fan-out
|
|
30
43
|
* dispatch (message.fanOutIds present) is flagged `· 群发` and names the
|
|
31
44
|
* sibling recipients on their own line, so a broadcast is never mistaken for
|
|
32
45
|
* a one-to-one assignment — raw ids fall back to the sanitized id itself. */
|
|
@@ -37,8 +50,7 @@ function formatCollaborationMessage(message, source, fannedNames, fence) {
|
|
|
37
50
|
lines.push('', fence, message.content, fence);
|
|
38
51
|
if (message.task)
|
|
39
52
|
lines.push('', `任务上报:${JSON.stringify(message.task)}`);
|
|
40
|
-
|
|
41
|
-
lines.push('', `回复:td collab reply ${message.id} "回复内容" --text`);
|
|
53
|
+
lines.push('', collaborationMessageAnchorLine(message));
|
|
42
54
|
return lines.join('\n');
|
|
43
55
|
}
|
|
44
56
|
export function formatCollaborationDelivery(input) {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { normalizePromptForPaste } from './promptDelivery.js';
|
|
2
2
|
const TMUX_KEY_NAMES = {
|
|
3
3
|
enter: 'Enter', escape: 'Escape', space: 'Space', left: 'Left', right: 'Right', up: 'Up', down: 'Down',
|
|
4
4
|
};
|
|
@@ -47,20 +47,56 @@ async function assertSamePane(run, pane) {
|
|
|
47
47
|
/** Unique-per-process buffer name so concurrent deliveries to different panes
|
|
48
48
|
* never share (or clobber) a buffer. */
|
|
49
49
|
let bufferSequence = 0;
|
|
50
|
-
export async function writeCollaborationTmuxPane(run, pane, prompt
|
|
50
|
+
export async function writeCollaborationTmuxPane(run, pane, prompt,
|
|
51
|
+
/** Sends `input` to the tmux client's stdin. The steps below are one `\;`
|
|
52
|
+
* chain (see the comment inside), and a chain is argv, so the body must not
|
|
53
|
+
* ride in argv: tmux treats an element that is exactly `;` as a separator
|
|
54
|
+
* even after `--`, and its backslash unescaping is version-dependent
|
|
55
|
+
* (`\\` survives verbatim while `\;` collapses to `;`). Reading the body
|
|
56
|
+
* from stdin sidesteps both — every byte round-trips unchanged. Required,
|
|
57
|
+
* not optional: a run() that ignored the payload would load an empty buffer
|
|
58
|
+
* and fail the delivery rather than deliver the wrong thing. */
|
|
59
|
+
stdin) {
|
|
51
60
|
await assertSamePane(run, pane);
|
|
52
61
|
// A fixed pane target is independent of the current window, keyboard focus
|
|
53
62
|
// and browser presence. Delivery goes through a named tmux buffer rather
|
|
54
63
|
// than `send-keys -l`: while a pane is in copy-mode, literal bytes are
|
|
55
64
|
// routed to the mode's key table (they scroll or do nothing) and never
|
|
56
65
|
// reach the app, whereas paste-buffer writes into the pty regardless of
|
|
57
|
-
// mode and leaves the mode and scroll position untouched.
|
|
58
|
-
//
|
|
59
|
-
//
|
|
66
|
+
// mode and leaves the mode and scroll position untouched.
|
|
67
|
+
//
|
|
68
|
+
// The bracketed-paste wrapper is left to tmux (-p) instead of riding in the
|
|
69
|
+
// payload. tmux adds the pair exactly when the application has bracketed
|
|
70
|
+
// paste on and adds nothing when it does not — so an agent TUI sees one
|
|
71
|
+
// paste while a plain shell sees clean text — and the marker bytes never
|
|
72
|
+
// enter the buffer, where tmux 3.7+ would run them through vis(3) and land
|
|
73
|
+
// a literal `^[` in the pane instead of a control byte. The same vis pass
|
|
74
|
+
// is why the body must carry no ESC: escape bytes are exactly what it
|
|
75
|
+
// rewrites, and an ESC-free body makes the pass a byte-for-byte no-op on
|
|
76
|
+
// every tmux version.
|
|
60
77
|
const buffer = `termdock-collab-${process.pid}-${bufferSequence++}`;
|
|
61
78
|
try {
|
|
62
|
-
|
|
63
|
-
|
|
79
|
+
// The four steps ride one client invocation joined by tmux's `\;`: the
|
|
80
|
+
// server runs them back to back in a single queue item, so no other
|
|
81
|
+
// client's paste can interleave between our text and our submit CR
|
|
82
|
+
// (measured: one contiguous block, 0ms apart, versus two invocations
|
|
83
|
+
// ~14ms apart). A failing step stops the rest — verified on 3.4 and 3.7 —
|
|
84
|
+
// which is the safe direction: a failed text paste is never followed by a
|
|
85
|
+
// bare CR into whatever the input box already held.
|
|
86
|
+
await stdin([
|
|
87
|
+
// The body arrives on stdin, not argv: it is user content, and in a
|
|
88
|
+
// `\;` chain argv is a language where `;` and `\` mean something.
|
|
89
|
+
'load-buffer', '-b', buffer, '-', ';',
|
|
90
|
+
// -r keeps LF as LF rather than the separator default of CR. The
|
|
91
|
+
// normalizer already folded every line break to CR, so no LF is left
|
|
92
|
+
// for it to rewrite — the flag is insurance, not the mechanism.
|
|
93
|
+
'paste-buffer', '-p', '-r', '-d', '-b', buffer, '-t', pane.paneId, ';',
|
|
94
|
+
// The submit key rides outside the paste block: inside it, an editor
|
|
95
|
+
// inserts a pasted CR as text instead of acting on it. Pasting the bare
|
|
96
|
+
// CR (no -p) keeps the key in the same mode-proof channel as the text.
|
|
97
|
+
'set-buffer', '-b', buffer, '--', '\r', ';',
|
|
98
|
+
'paste-buffer', '-d', '-b', buffer, '-t', pane.paneId,
|
|
99
|
+
], normalizePromptForPaste(prompt));
|
|
64
100
|
}
|
|
65
101
|
finally {
|
|
66
102
|
// -d consumed the buffer on the happy path; this sweeps up after a failed
|
|
@@ -1,12 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fold a prompt into the single-channel form both tmux and agent TUIs expect:
|
|
3
|
+
* every line break becomes one CR (commits a line inside the editor, and is
|
|
4
|
+
* never a paste-end candidate), and a raw ESC becomes the visible ␛ glyph so
|
|
5
|
+
* no byte sequence in the body can be mistaken for terminal control — notably
|
|
6
|
+
* not our own bracketed-paste markers.
|
|
7
|
+
*/
|
|
8
|
+
export function normalizePromptForPaste(prompt) {
|
|
9
|
+
return prompt.replace(/\r\n|\r|\n/g, '\r').replace(/\x1b/g, '␛');
|
|
10
|
+
}
|
|
1
11
|
/**
|
|
2
12
|
* Encode a prompt as one bracketed-paste block followed by one real Enter.
|
|
3
13
|
* Agent TUIs then keep embedded newlines inside the editor instead of treating
|
|
4
14
|
* each line as a separate submission.
|
|
5
15
|
*/
|
|
6
16
|
export function buildBracketedSubmitBytes(prompt) {
|
|
7
|
-
|
|
8
|
-
const escaped = normalized.replace(/\x1b/g, '␛');
|
|
9
|
-
return `\x1b[200~${escaped}\x1b[201~\r`;
|
|
17
|
+
return `\x1b[200~${normalizePromptForPaste(prompt)}\x1b[201~\r`;
|
|
10
18
|
}
|
|
11
19
|
/**
|
|
12
20
|
* Process detection and hook events are independent Agent signals. A target is
|
|
@@ -1610,7 +1610,7 @@ async function resolveCollaborationRoute(frontendSessionId) {
|
|
|
1610
1610
|
return { state: 'ready', capture: async () => (await captureTmuxPane(pinned.paneId)).content, write: async (messages) => {
|
|
1611
1611
|
if (!globalSessionState.sessions.some((candidate) => candidate.sessionId === frontendSessionId))
|
|
1612
1612
|
throw new Error('SESSION_REMOVED');
|
|
1613
|
-
await writeCollaborationTmuxPane(runTmux, pinned, formatLocalCollaborationMessages(frontendSessionId, messages));
|
|
1613
|
+
await writeCollaborationTmuxPane(runTmux, pinned, formatLocalCollaborationMessages(frontendSessionId, messages), runTmuxStdin);
|
|
1614
1614
|
backend.lastActivity = Date.now();
|
|
1615
1615
|
// First-delivery confirm: history proves the agent rendered our
|
|
1616
1616
|
// message (boot sequences clear only the screen, never the history a
|
|
@@ -1735,6 +1735,32 @@ async function runTmux(args) {
|
|
|
1735
1735
|
});
|
|
1736
1736
|
return stdout;
|
|
1737
1737
|
}
|
|
1738
|
+
/** runTmux with a payload on the client's stdin — how delivery hands user
|
|
1739
|
+
* content to `load-buffer -`. Kept separate from runTmux so the many callers
|
|
1740
|
+
* that never pipe anything are untouched. */
|
|
1741
|
+
async function runTmuxStdin(args, input) {
|
|
1742
|
+
const child = execFile(getTmuxBinary(), args, {
|
|
1743
|
+
timeout: 5000,
|
|
1744
|
+
maxBuffer: 2 * 1024 * 1024,
|
|
1745
|
+
env: buildInteractiveColorEnvironment(process.env),
|
|
1746
|
+
});
|
|
1747
|
+
// Write then end: tmux reads stdin to EOF for `load-buffer -`.
|
|
1748
|
+
child.stdin?.end(input);
|
|
1749
|
+
const { stdout } = await new Promise((resolve, reject) => {
|
|
1750
|
+
let stdout = '';
|
|
1751
|
+
let stderr = '';
|
|
1752
|
+
child.stdout?.on('data', (chunk) => { stdout += chunk; });
|
|
1753
|
+
child.stderr?.on('data', (chunk) => { stderr += chunk; });
|
|
1754
|
+
child.on('error', reject);
|
|
1755
|
+
child.on('close', (code) => (code === 0
|
|
1756
|
+
? resolve({ stdout })
|
|
1757
|
+
// Carry tmux's own message: it names the failing step ("no buffer X",
|
|
1758
|
+
// "target pane has exited"), which is what makes a delivery failure
|
|
1759
|
+
// diagnosable from the transport diagnostic alone.
|
|
1760
|
+
: reject(new Error(`tmux exited with ${code}${stderr.trim() ? `: ${stderr.trim()}` : ''}`))));
|
|
1761
|
+
});
|
|
1762
|
+
return stdout;
|
|
1763
|
+
}
|
|
1738
1764
|
// ---- Persistent tmux control-mode connection ----
|
|
1739
1765
|
// Instead of spawning a new `tmux` process per command (execFile overhead),
|
|
1740
1766
|
// maintain a single `tmux -C attach` child process per session. Commands
|
|
@@ -5781,7 +5807,7 @@ router.post('/operations/orchestration/drive', async (req, res) => {
|
|
|
5781
5807
|
return res.status(400).json({ error: 'run 文本过长(上限 10000 字符);需要更多内容请走 td collab send 投递' });
|
|
5782
5808
|
// Same bracketed-paste write path deliveries use: one line, one submit,
|
|
5783
5809
|
// embedded newlines stay inside the recipient's editor.
|
|
5784
|
-
await writeCollaborationTmuxPane(runTmux, pane, text);
|
|
5810
|
+
await writeCollaborationTmuxPane(runTmux, pane, text, runTmuxStdin);
|
|
5785
5811
|
const snapshot = await captureTmuxPaneText(runTmux, pane);
|
|
5786
5812
|
return res.json({ ok: true, action, sessionId: target, text, snapshot });
|
|
5787
5813
|
}
|
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.195",
|
|
5
5
|
"runtimeProtocolVersion": 1,
|
|
6
6
|
"minimumDesktopVersion": "1.4.46",
|
|
7
7
|
"nodeMajor": 22,
|
|
8
8
|
"dependencyHash": "sha256-K4KyYJuVoQDpZsBM8ZX1Rw3MddOxAzVdp/cPm0iGZz0=",
|
|
9
|
-
"serverBundleHash": "sha256-
|
|
9
|
+
"serverBundleHash": "sha256-T9Yr6kyM0VWWMdS6LFYinasOYunqdstwvkzVg2zQqZE=",
|
|
10
10
|
"clientBundleHash": "sha256-wpGsT/oJup/G4MyJSKqpHBJlzc2Ha3wFIWmtp4vc/Wc=",
|
|
11
11
|
"entrypoint": "dist/server/cli.js",
|
|
12
12
|
"clientEntrypoint": "dist/client/index.html"
|