termdock 1.4.191 → 1.4.193

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.
@@ -39,8 +39,11 @@ export const COLLAB_HELP = `td collab — durable messages; no agent-specific ho
39
39
  operations are shell operations: approve dismisses an interactive approval
40
40
  dialog and refuses unless one is actually showing; named keys inject one
41
41
  key; capture reads the current screen back; run submits one line and
42
- returns the screen. Cannot target your own session. Treat every drive as
43
- strong control: the member's shell executes what you send.)
42
+ returns the screen. Works on plain shell members too (no agent needed) for
43
+ run/capture; key actions (approve/enter/…) require an agent pane, and are
44
+ refused while the user has the pane scrolled into copy-mode. Cannot target
45
+ your own session. Treat every drive as strong control: the member's shell
46
+ executes what you send.)
44
47
  cleanup <session-id>… 移除协作会话并终止其 tmux/进程(仅限与你同组的会话;
45
48
  不能清理当前会话自身,也不能通过清理解散你所在的组)
46
49
  风险操作:默认只打印清理计划并拒绝执行(exit 1)——这是不可恢复的删除。
@@ -4,11 +4,14 @@ export class CollaborationDeliveryWorker {
4
4
  states = new Map();
5
5
  failures = new Map();
6
6
  submitted = new Set();
7
- /** Sessions whose first delivery was confirmed consumed (or settled) in
8
- * this process. Only first deliveries pass the confirm gate a write to
9
- * an agent that has been interacting is consumed by definition of pty
10
- * semantics, and every delivery after the first rides that assumption. */
11
- confirmedSessions = new Set();
7
+ /** Sessions whose latest delivery was confirmed rendered in this process,
8
+ * mapped to the timestamp until which confirmations are skipped. A recent
9
+ * confirmed delivery means the agent is actively consuming input, so
10
+ * follow-up messages settle immediately; once the cooldown lapses the
11
+ * agent's state is unknown again (it may be mid-turn, or finishing one)
12
+ * and the gate re-engages. Settled-but-unconfirmed deliveries never grant
13
+ * cooldown — that would re-open the turn-handover hole the gate closes. */
14
+ confirmedUntil = new Map();
12
15
  timer = null;
13
16
  ticking = null;
14
17
  constructor(options) {
@@ -117,6 +120,28 @@ export class CollaborationDeliveryWorker {
117
120
  }
118
121
  const message = pending[0];
119
122
  const attempts = store.diagnostic(message.id)?.attempt_count ?? 0;
123
+ // Confirm gate: the route becoming ready only proves the agent process is
124
+ // up, not that its TUI is consuming — a write landing while the agent
125
+ // finishes another turn can sit unsubmitted in its input box. When the
126
+ // route can read terminal history, hold deliveries outside the session's
127
+ // confirm cooldown until our message appears in the history (the agent
128
+ // rendered it). Unconfirmed writes are re-attempted up to the configured
129
+ // bound; at-least-once transport is preserved throughout.
130
+ const confirmMs = this.options.firstDeliveryConfirmMs ?? 1_500;
131
+ const gateActive = Boolean(route.confirm) && confirmMs > 0
132
+ && Date.now() >= (this.confirmedUntil.get(id) ?? 0)
133
+ && attempts + 1 < (this.options.maxUnconfirmedWrites ?? 3);
134
+ // Differential baseline for stuck-paste recovery: captured before the
135
+ // write so a later screen diff can tell our paste apart from stale ones.
136
+ let baseline = '';
137
+ if (gateActive && route.recoverStuck && !this.submitted.has(message.id) && route.capture) {
138
+ try {
139
+ baseline = (await route.capture()) ?? '';
140
+ }
141
+ catch {
142
+ baseline = '';
143
+ }
144
+ }
120
145
  if (!this.submitted.has(message.id)) {
121
146
  store.recordTransport(message.id, {
122
147
  relay_online: null, peer_reachable: true,
@@ -143,18 +168,8 @@ export class CollaborationDeliveryWorker {
143
168
  catch { /* best-effort */ }
144
169
  }
145
170
  }
146
- // First-delivery confirm gate: the route becoming ready only proves the
147
- // agent process is up, not that its TUI is reading — a write inside the
148
- // boot window can be wiped by the startup clear while the message is
149
- // already marked delivered. When the route can read terminal history,
150
- // hold the first delivery until our message actually appears there (the
151
- // agent rendered it), dismissing any approval dialog blocking the agent
152
- // on the way. Unconfirmed writes are re-attempted up to the configured
153
- // bound; at-least-once transport is preserved throughout.
154
- const confirmMs = this.options.firstDeliveryConfirmMs ?? 1_500;
155
- if (route.confirm && confirmMs > 0 && !this.confirmedSessions.has(id)
156
- && attempts + 1 < (this.options.maxUnconfirmedWrites ?? 3)) {
157
- if (!(await this.confirmConsumed(pending, route, confirmMs))) {
171
+ if (gateActive) {
172
+ if (!(await this.confirmConsumed(pending, route, confirmMs, baseline))) {
158
173
  this.submitted.delete(message.id);
159
174
  store.recordTransport(message.id, {
160
175
  relay_online: null, peer_reachable: true,
@@ -164,17 +179,24 @@ export class CollaborationDeliveryWorker {
164
179
  this.failures.delete(id);
165
180
  return;
166
181
  }
182
+ // Confirmed rendered: refresh the exemption window so follow-ups in the
183
+ // agent's active turn settle immediately.
184
+ this.confirmedUntil.set(id, Date.now() + (this.options.confirmCooldownMs ?? 30_000));
167
185
  }
168
186
  // If persistence fails after writing, the in-process guard avoids a
169
187
  // second write on retry. Across a crash, transport is at-least-once.
170
188
  this.complete(id, message);
171
189
  }
172
190
  /** Wait out the confirm window, then look for the written messages in the
173
- * recipient's terminal history. While the agent is blocked on an approval
174
- * dialog (its first Bash call needs permission) dismiss it once per cycle
175
- * the delivery itself told the agent to run td collab, and the pane
176
- * must show an actual dialog for the key to be sent. */
177
- async confirmConsumed(messages, route, delayMs) {
191
+ * recipient's terminal history. A message found there was rendered by the
192
+ * agent and counts as consumed. While the agent is blocked on an approval
193
+ * dialog (its Bash call needs permission) dismiss it once per cycle, and
194
+ * on the first cycle submit a paste that arrived during turn handover and
195
+ * is still sitting unsubmitted (differential baseline evidence only, at
196
+ * most once per delivery). Each recovery sends Enter on evidence, never
197
+ * blind. */
198
+ async confirmConsumed(messages, route, delayMs, baseline) {
199
+ let recovered = false;
178
200
  for (let attempt = 0; attempt < 3; attempt += 1) {
179
201
  await new Promise((resolve) => setTimeout(resolve, attempt === 0 ? delayMs : Math.min(1_200, delayMs)));
180
202
  let content = null;
@@ -186,6 +208,12 @@ export class CollaborationDeliveryWorker {
186
208
  }
187
209
  if (content && messages.some((message) => content.includes(message.id)))
188
210
  return true;
211
+ if (!recovered && route.recoverStuck && baseline) {
212
+ try {
213
+ recovered = await route.recoverStuck(baseline);
214
+ }
215
+ catch { /* a failed submit must not settle delivery */ }
216
+ }
189
217
  if (content && route.approve) {
190
218
  try {
191
219
  await route.approve();
@@ -197,7 +225,6 @@ export class CollaborationDeliveryWorker {
197
225
  }
198
226
  complete(id, message) {
199
227
  const { store } = this.options;
200
- this.confirmedSessions.add(id);
201
228
  store.markDelivered([message.id]);
202
229
  this.submitted.delete(message.id);
203
230
  store.recordTransport(message.id, {
@@ -92,3 +92,19 @@ export function selectCollaborationPane(binding, panes) {
92
92
  return matches.length > 1 ? { state: 'ambiguous', reason: 'MULTIPLE_AGENT_PANES' }
93
93
  : { state: 'agent-exited', reason: 'AGENT_NOT_RUNNING_OR_IDENTITY_CHANGED' };
94
94
  }
95
+ /** Drive-side pane resolution. Message delivery needs an Agent (only a TUI
96
+ * consumes the formatted prompt, and the confirm gate searches its
97
+ * transcript), but driving is a terminal operation — `run` submits one line
98
+ * and `capture` reads the screen, both of which a plain shell answers. So an
99
+ * Agent pane wins when one is selectable, and the session's own pane is the
100
+ * fallback when there is nothing but a shell. The caller still re-asserts
101
+ * pane identity on every write. */
102
+ export function selectDrivePane(binding, panes, activePaneId) {
103
+ const agent = selectCollaborationPane(binding, panes);
104
+ if (agent.state === 'ready' && agent.pane?.agentSlug)
105
+ return agent;
106
+ const plain = panes.find((pane) => pane.paneId === activePaneId) ?? panes[0];
107
+ if (!plain)
108
+ return agent;
109
+ return { state: 'ready', pane: plain };
110
+ }
@@ -18,25 +18,69 @@ const APPROVAL_DIALOG_PATTERNS = [
18
18
  /\b1\.\s*是\b/,
19
19
  /允许.*执行|确认.*执行/i,
20
20
  ];
21
+ /** Agent TUIs collapse pasted input in the input box to a marker line like
22
+ * "[Pasted text #3 +42 lines]". While the paste sits unsubmitted the marker
23
+ * stays on screen; a submitted paste clears the box and only the transcript
24
+ * holds the content. Session-unique #N lets us diff screen captures. */
25
+ const PASTE_MARKER_PATTERN = /\[Pasted text #(\d+)/g;
21
26
  export function detectApprovalDialog(content) {
22
27
  return APPROVAL_DIALOG_PATTERNS.some((pattern) => pattern.test(content));
23
28
  }
29
+ /** Paste marker sequence numbers visible in a screen capture. */
30
+ export function extractPasteMarkerNumbers(content) {
31
+ return new Set([...content.matchAll(PASTE_MARKER_PATTERN)].map((match) => Number(match[1])));
32
+ }
33
+ /** True when `after` shows a paste marker that was not in `before`. Diffing
34
+ * prevents a recovery Enter from submitting a stale paste that predates this
35
+ * delivery (or someone else's draft): we only ever submit what we just put
36
+ * there, at most once per delivery. */
37
+ export function hasNewPasteMarker(after, before) {
38
+ const beforeNumbers = extractPasteMarkerNumbers(before);
39
+ return [...extractPasteMarkerNumbers(after)].some((number) => !beforeNumbers.has(number));
40
+ }
24
41
  async function assertSamePane(run, pane) {
25
42
  const identity = (await run(['display-message', '-p', '-t', pane.paneId,
26
43
  '#{pid}:#{session_id}:#{pane_id}:#{pane_pid}'])).trim();
27
44
  if (identity !== `${pane.serverPid}:${pane.sessionId}:${pane.paneId}:${pane.panePid}`)
28
45
  throw new Error('TMUX_PANE_CHANGED');
29
46
  }
47
+ /** Unique-per-process buffer name so concurrent deliveries to different panes
48
+ * never share (or clobber) a buffer. */
49
+ let bufferSequence = 0;
30
50
  export async function writeCollaborationTmuxPane(run, pane, prompt) {
31
51
  await assertSamePane(run, pane);
32
52
  // A fixed pane target is independent of the current window, keyboard focus
33
- // and browser presence. One invocation preserves the bracketed submit bytes.
34
- await run(['send-keys', '-t', pane.paneId, '-l', '--', buildBracketedSubmitBytes(prompt)]);
53
+ // and browser presence. Delivery goes through a named tmux buffer rather
54
+ // than `send-keys -l`: while a pane is in copy-mode, literal bytes are
55
+ // routed to the mode's key table (they scroll or do nothing) and never
56
+ // 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.
60
+ const buffer = `termdock-collab-${process.pid}-${bufferSequence++}`;
61
+ try {
62
+ await run(['set-buffer', '-b', buffer, '--', buildBracketedSubmitBytes(prompt)]);
63
+ await run(['paste-buffer', '-d', '-b', buffer, '-t', pane.paneId]);
64
+ }
65
+ finally {
66
+ // -d consumed the buffer on the happy path; this sweeps up after a failed
67
+ // paste so no stray buffer is left on the server.
68
+ await run(['delete-buffer', '-b', buffer]).catch(() => undefined);
69
+ }
70
+ }
71
+ /** True while the pane is in any tmux mode (copy-mode, choose-tree, …), i.e.
72
+ * keys sent to it are consumed by the mode handler instead of the app. */
73
+ export async function paneInMode(run, pane) {
74
+ return (await run(['display-message', '-p', '-t', pane.paneId, '#{pane_in_mode}'])).trim() === '1';
35
75
  }
36
76
  /** Inject a named key into a member pane after proving the pane identity
37
- * unchanged. Named keys only — no freeform keystroke passthrough. */
77
+ * unchanged. Named keys only — no freeform keystroke passthrough. Refuses
78
+ * while the pane is in a mode: there the key would be taken by the mode
79
+ * handler (Enter in copy-mode exits the user's scroll) rather than the app. */
38
80
  export async function sendTmuxPaneKey(run, pane, key) {
39
81
  await assertSamePane(run, pane);
82
+ if (await paneInMode(run, pane))
83
+ throw new Error('TMUX_PANE_IN_MODE');
40
84
  await run(['send-keys', '-t', pane.paneId, TMUX_KEY_NAMES[key]]);
41
85
  }
42
86
  /** Current pane screen text (viewport), identity-checked. */
@@ -53,6 +97,21 @@ export async function approveCollaborationDialog(run, pane) {
53
97
  const content = await captureTmuxPaneText(run, pane);
54
98
  if (!detectApprovalDialog(content))
55
99
  return false;
100
+ // sendTmuxPaneKey refuses in-mode panes, so a dialog-looking screen behind a
101
+ // user's scroll never turns Enter into copy-mode navigation.
102
+ await sendTmuxPaneKey(run, pane, 'enter');
103
+ return true;
104
+ }
105
+ /** Submit a paste that arrived while the agent was finishing its previous
106
+ * turn and is still sitting unsubmitted in the input box. Only acts on
107
+ * differential evidence — a paste marker that appeared after the write —
108
+ * never on pre-existing stale markers, and returns whether Enter was sent.
109
+ * Committing the whole input buffer is safe: each delivery is written once,
110
+ * so each marker's content enters the transcript exactly once. */
111
+ export async function recoverStuckPaste(run, pane, baseline) {
112
+ const viewport = await captureTmuxPaneText(run, pane);
113
+ if (!hasNewPasteMarker(viewport, baseline))
114
+ return false;
56
115
  await sendTmuxPaneKey(run, pane, 'enter');
57
116
  return true;
58
117
  }
@@ -43,9 +43,9 @@ import { COLLAB_NAME_FORBIDDEN, formatCollaborationDelivery } from '../agent/col
43
43
  import { buildCollaborationSpawnCommand, resolveCollaborationSpawnMode } from '../agent/collaborationSpawn.js';
44
44
  import { SessionSearchStore } from '../agent/sessionSearchStore.js';
45
45
  import { resolveCollaborationBackend, resolveCollaborationSessionId } from '../agent/sessionBindingRecovery.js';
46
- import { CollaborationRoutingStore, selectCollaborationPane } from '../agent/collaborationRouting.js';
46
+ import { CollaborationRoutingStore, selectCollaborationPane, selectDrivePane } from '../agent/collaborationRouting.js';
47
47
  import { CollaborationDeliveryWorker } from '../agent/collaborationDeliveryWorker.js';
48
- import { approveCollaborationDialog, captureTmuxPaneHistory, captureTmuxPaneText, sendTmuxPaneKey, writeCollaborationTmuxPane } from '../agent/collaborationTmuxDelivery.js';
48
+ import { approveCollaborationDialog, captureTmuxPaneHistory, captureTmuxPaneText, recoverStuckPaste, sendTmuxPaneKey, writeCollaborationTmuxPane } from '../agent/collaborationTmuxDelivery.js';
49
49
  import { listAllHookAgents, refreshStaleHooksAtLaunch, installHooksForSlug, uninstallHooksForSlug, } from '../agent/installers.js';
50
50
  import { loadPlugins, savePlugin, removePlugin, readPluginIcon, validateManifest, } from '../agent/plugins.js';
51
51
  import { TmuxLifecycleCoordinator } from '../utils/tmuxLifecycle.js';
@@ -1478,7 +1478,12 @@ function formatLocalCollaborationMessages(frontendSessionId, messages) {
1478
1478
  showRoutingHelp,
1479
1479
  });
1480
1480
  }
1481
- async function inspectCollaborationTmux(binding, requestedPane) {
1481
+ /** `allowPlainPane` widens the candidate set from "an Agent owns the pane" to
1482
+ * "the session's pane exists": a plain shell member has no agentSlug, so the
1483
+ * agent-keyed selector can never reach it. Delivery still needs an agent (the
1484
+ * confirm gate and prompt formatting assume a TUI); driving a terminal —
1485
+ * run/capture — is a shell operation and works on any pane. */
1486
+ async function inspectCollaborationTmux(binding, requestedPane, allowPlainPane = false) {
1482
1487
  if (!binding.tmuxSessionName)
1483
1488
  return { state: 'offline', reason: 'TMUX_BINDING_MISSING' };
1484
1489
  const name = `=${binding.tmuxSessionName}`;
@@ -1504,7 +1509,13 @@ async function inspectCollaborationTmux(binding, requestedPane) {
1504
1509
  return { serverPid, sessionId: layout.sessionId, paneId: pane.id, panePid: pane.pid,
1505
1510
  agentSlug: agent?.slug ?? '', nativeSessionId, cwd: pane.currentPath };
1506
1511
  }));
1507
- return selectCollaborationPane(binding, requestedPane ? panes.filter((pane) => pane.paneId === requestedPane) : panes);
1512
+ const candidates = requestedPane ? panes.filter((pane) => pane.paneId === requestedPane) : panes;
1513
+ if (allowPlainPane && !binding.pane) {
1514
+ // No pinned pane: prefer an Agent pane, fall back to the session's own
1515
+ // pane so a plain shell member is drivable (run/capture are shell ops).
1516
+ return selectDrivePane(binding, candidates, layout.activePaneId);
1517
+ }
1518
+ return selectCollaborationPane(binding, candidates);
1508
1519
  }
1509
1520
  async function rebindCollaborationRoute(frontendSessionId, paneId) {
1510
1521
  await collaborationDeliveryWorker.reconfigure(frontendSessionId, async () => {
@@ -1604,7 +1615,8 @@ async function resolveCollaborationRoute(frontendSessionId) {
1604
1615
  // First-delivery confirm: history proves the agent rendered our
1605
1616
  // message (boot sequences clear only the screen, never the history a
1606
1617
  // live TUI writes into). Terminal-state only — no agent hooks.
1607
- }, confirm: async () => captureTmuxPaneHistory(runTmux, pinned), approve: async () => approveCollaborationDialog(runTmux, pinned) };
1618
+ }, confirm: async () => captureTmuxPaneHistory(runTmux, pinned), approve: async () => approveCollaborationDialog(runTmux, pinned),
1619
+ recoverStuck: async (baseline) => recoverStuckPaste(runTmux, pinned, baseline) };
1608
1620
  }
1609
1621
  if (!backend || !binding.backendSessionId)
1610
1622
  return { state: 'offline', reason: 'SHELL_BACKEND_NOT_RUNNING' };
@@ -5725,9 +5737,31 @@ router.post('/operations/orchestration/drive', async (req, res) => {
5725
5737
  return res.status(409).json({ error: '目标会话不是 tmux 会话,无法从 CLI 驱动(模式:' + (record.mode ?? 'unknown') + ')' });
5726
5738
  }
5727
5739
  const binding = collaborationRouting.get(target);
5728
- const pane = binding?.pane ?? null;
5729
- if (!pane)
5730
- return res.status(409).json({ error: '目标会话还没有可驱动的已固定终端面板(agent 可能尚未启动或已离线)' });
5740
+ let pane = binding?.pane ?? null;
5741
+ if (!pane) {
5742
+ // No pinned Agent pane: resolve the session's pane directly so a plain
5743
+ // shell member is drivable. The actions below are terminal operations —
5744
+ // run and capture are exactly what a general terminal offers.
5745
+ try {
5746
+ const inspected = await inspectCollaborationTmux(binding ?? { sessionId: target, backendSessionId: null, mode: record.mode,
5747
+ tmuxSessionName: record.tmuxSessionName, agentSlug: null, nativeSessionId: null, pane: null }, null, true);
5748
+ pane = inspected.pane ?? null;
5749
+ }
5750
+ catch (error) {
5751
+ return res.status(409).json({ ok: false, code: 'DRIVE_FAILED', error: getErrorMessage(error) });
5752
+ }
5753
+ if (!pane)
5754
+ return res.status(409).json({ error: '目标会话还没有可驱动的终端面板(tmux 会话可能已退出)' });
5755
+ // Deliberately not written back to the routing store: that store drives
5756
+ // message delivery, where a plain pane has no agent to consume anything.
5757
+ // Each drive re-resolves and assertSamePane still guards the write.
5758
+ }
5759
+ // Keys are for Agents: `run` submits its own line, while a bare Enter would
5760
+ // commit whatever draft the user has typed into a plain shell. Approving a
5761
+ // dialog only makes sense where dialogs appear, too.
5762
+ if (!pane.agentSlug && (action === 'approve' || DRIVE_KEYS.has(action))) {
5763
+ return res.status(409).json({ ok: false, code: 'DRIVE_REQUIRES_AGENT', error: '目标面板是纯终端(无 agent),只支持 run/capture;按键类动作需要 agent 面板' });
5764
+ }
5731
5765
  try {
5732
5766
  if (action === 'approve') {
5733
5767
  const approved = await approveCollaborationDialog(runTmux, pane);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "termdock",
3
- "version": "1.4.191",
3
+ "version": "1.4.193",
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.191",
4
+ "version": "1.4.193",
5
5
  "runtimeProtocolVersion": 1,
6
6
  "minimumDesktopVersion": "1.4.46",
7
7
  "nodeMajor": 22,
8
8
  "dependencyHash": "sha256-K4KyYJuVoQDpZsBM8ZX1Rw3MddOxAzVdp/cPm0iGZz0=",
9
- "serverBundleHash": "sha256-36ZcNv3lgwTbxmDk01fEKtfGW25R0K5dUN3m9bbhPeU=",
9
+ "serverBundleHash": "sha256-grEE01C40LqZFNdPE78QJXxG3gYtDw6ADT1aW+lm58I=",
10
10
  "clientBundleHash": "sha256-wpGsT/oJup/G4MyJSKqpHBJlzc2Ha3wFIWmtp4vc/Wc=",
11
11
  "entrypoint": "dist/server/cli.js",
12
12
  "clientEntrypoint": "dist/client/index.html"