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.
@@ -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
- && attempts + 1 < (this.options.maxUnconfirmedWrites ?? 3);
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
- complete(id, message) {
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: null, checked_at: Date.now(),
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 for agent-sourced messages kept outside the fence. A fan-out
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
- if (message.fromSessionId)
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 { buildBracketedSubmitBytes } from './promptDelivery.js';
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. paste-buffer
58
- // does not bracket on its own, so the payload carries its own
59
- // `\x1b[200~ \x1b[201~` wrapper plus the submitting CR.
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
- await run(['set-buffer', '-b', buffer, '--', buildBracketedSubmitBytes(prompt)]);
63
- await run(['paste-buffer', '-d', '-b', buffer, '-t', pane.paneId]);
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
- const normalized = prompt.replace(/\r\n|\r|\n/g, '\r');
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "termdock",
3
- "version": "1.4.193",
3
+ "version": "1.4.195",
4
4
  "description": "A complete web-based terminal application with modern UI",
5
5
  "type": "module",
6
6
  "main": ".desktop-dist/main.js",
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
3
  "packageName": "termdock",
4
- "version": "1.4.193",
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-grEE01C40LqZFNdPE78QJXxG3gYtDw6ADT1aW+lm58I=",
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"