termdock 1.4.192 → 1.4.194

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)——这是不可恢复的删除。
@@ -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
+ }
@@ -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
  };
@@ -44,16 +44,59 @@ async function assertSamePane(run, pane) {
44
44
  if (identity !== `${pane.serverPid}:${pane.sessionId}:${pane.paneId}:${pane.panePid}`)
45
45
  throw new Error('TMUX_PANE_CHANGED');
46
46
  }
47
+ /** Unique-per-process buffer name so concurrent deliveries to different panes
48
+ * never share (or clobber) a buffer. */
49
+ let bufferSequence = 0;
47
50
  export async function writeCollaborationTmuxPane(run, pane, prompt) {
48
51
  await assertSamePane(run, pane);
49
52
  // A fixed pane target is independent of the current window, keyboard focus
50
- // and browser presence. One invocation preserves the bracketed submit bytes.
51
- 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.
58
+ //
59
+ // The bracketed-paste wrapper is left to tmux (-p) instead of riding in the
60
+ // payload. tmux adds the pair exactly when the application has bracketed
61
+ // paste on and adds nothing when it does not — so an agent TUI sees one
62
+ // paste while a plain shell sees clean text — and the marker bytes never
63
+ // enter the buffer, where tmux 3.7+ would run them through vis(3) and land
64
+ // a literal `^[` in the pane instead of a control byte. The same vis pass
65
+ // is why the body must carry no ESC: escape bytes are exactly what it
66
+ // rewrites, and an ESC-free body makes the pass a byte-for-byte no-op on
67
+ // every tmux version.
68
+ const buffer = `termdock-collab-${process.pid}-${bufferSequence++}`;
69
+ try {
70
+ await run(['set-buffer', '-b', buffer, '--', normalizePromptForPaste(prompt)]);
71
+ // -r keeps LF as LF rather than the separator default of CR. The
72
+ // normalizer already folded every line break to CR, so no LF is left for
73
+ // it to rewrite — the flag is insurance, not the mechanism.
74
+ await run(['paste-buffer', '-p', '-r', '-d', '-b', buffer, '-t', pane.paneId]);
75
+ // The submit key rides outside the paste block: inside it, an editor
76
+ // inserts a pasted CR as text instead of acting on it. Pasting the bare
77
+ // CR (no -p) keeps the key in the same mode-proof channel as the text.
78
+ await run(['set-buffer', '-b', buffer, '--', '\r']);
79
+ await run(['paste-buffer', '-d', '-b', buffer, '-t', pane.paneId]);
80
+ }
81
+ finally {
82
+ // -d consumed the buffer on the happy path; this sweeps up after a failed
83
+ // paste so no stray buffer is left on the server.
84
+ await run(['delete-buffer', '-b', buffer]).catch(() => undefined);
85
+ }
86
+ }
87
+ /** True while the pane is in any tmux mode (copy-mode, choose-tree, …), i.e.
88
+ * keys sent to it are consumed by the mode handler instead of the app. */
89
+ export async function paneInMode(run, pane) {
90
+ return (await run(['display-message', '-p', '-t', pane.paneId, '#{pane_in_mode}'])).trim() === '1';
52
91
  }
53
92
  /** Inject a named key into a member pane after proving the pane identity
54
- * unchanged. Named keys only — no freeform keystroke passthrough. */
93
+ * unchanged. Named keys only — no freeform keystroke passthrough. Refuses
94
+ * while the pane is in a mode: there the key would be taken by the mode
95
+ * handler (Enter in copy-mode exits the user's scroll) rather than the app. */
55
96
  export async function sendTmuxPaneKey(run, pane, key) {
56
97
  await assertSamePane(run, pane);
98
+ if (await paneInMode(run, pane))
99
+ throw new Error('TMUX_PANE_IN_MODE');
57
100
  await run(['send-keys', '-t', pane.paneId, TMUX_KEY_NAMES[key]]);
58
101
  }
59
102
  /** Current pane screen text (viewport), identity-checked. */
@@ -70,6 +113,8 @@ export async function approveCollaborationDialog(run, pane) {
70
113
  const content = await captureTmuxPaneText(run, pane);
71
114
  if (!detectApprovalDialog(content))
72
115
  return false;
116
+ // sendTmuxPaneKey refuses in-mode panes, so a dialog-looking screen behind a
117
+ // user's scroll never turns Enter into copy-mode navigation.
73
118
  await sendTmuxPaneKey(run, pane, 'enter');
74
119
  return true;
75
120
  }
@@ -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
@@ -43,7 +43,7 @@ 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
48
  import { approveCollaborationDialog, captureTmuxPaneHistory, captureTmuxPaneText, recoverStuckPaste, sendTmuxPaneKey, writeCollaborationTmuxPane } from '../agent/collaborationTmuxDelivery.js';
49
49
  import { listAllHookAgents, refreshStaleHooksAtLaunch, installHooksForSlug, uninstallHooksForSlug, } from '../agent/installers.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 () => {
@@ -5726,9 +5737,31 @@ router.post('/operations/orchestration/drive', async (req, res) => {
5726
5737
  return res.status(409).json({ error: '目标会话不是 tmux 会话,无法从 CLI 驱动(模式:' + (record.mode ?? 'unknown') + ')' });
5727
5738
  }
5728
5739
  const binding = collaborationRouting.get(target);
5729
- const pane = binding?.pane ?? null;
5730
- if (!pane)
5731
- 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
+ }
5732
5765
  try {
5733
5766
  if (action === 'approve') {
5734
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.192",
3
+ "version": "1.4.194",
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.192",
4
+ "version": "1.4.194",
5
5
  "runtimeProtocolVersion": 1,
6
6
  "minimumDesktopVersion": "1.4.46",
7
7
  "nodeMajor": 22,
8
8
  "dependencyHash": "sha256-K4KyYJuVoQDpZsBM8ZX1Rw3MddOxAzVdp/cPm0iGZz0=",
9
- "serverBundleHash": "sha256-jIRqCSStnF2/E81Q9GtAEeXAsshmnR4UUDtExjgLex0=",
9
+ "serverBundleHash": "sha256-2Y3XSbsDOzYXhEe57Szt3L16aZzGBDb1ZA/6TsYIiOQ=",
10
10
  "clientBundleHash": "sha256-wpGsT/oJup/G4MyJSKqpHBJlzc2Ha3wFIWmtp4vc/Wc=",
11
11
  "entrypoint": "dist/server/cli.js",
12
12
  "clientEntrypoint": "dist/client/index.html"