@yolo-labs/yolobridge 0.21.0 → 0.22.0

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/cli.js CHANGED
@@ -443,6 +443,10 @@ async function cmdAttach(args) {
443
443
  // real `onExit` handler below: stop + detach immediately rather than
444
444
  // let the daemon loop ride out the full heartbeat-staleness window.
445
445
  try {
446
+ // Say where Ctrl+C goes BEFORE the agent takes over the screen.
447
+ // Without this the operator presses it expecting to quit, nothing
448
+ // happens, and there is no way to discover why.
449
+ process.stdout.write('yolo-bridge: Ctrl+C goes to the agent · Ctrl-P Ctrl-Q to detach\n');
446
450
  startLocalAgent({
447
451
  agentBin,
448
452
  // Empty unless the MCP block above successfully resolved argv
@@ -451,6 +455,15 @@ async function cmdAttach(args) {
451
455
  // never a launch carrying a flag the binary would reject.
452
456
  agentArgs: agentMcpArgs,
453
457
  cwd: spawnCwd,
458
+ // The daemon's only reachable stop key. `process.on('SIGINT')`
459
+ // above cannot fire from the keyboard: stdin is in raw mode so the
460
+ // tty never turns Ctrl+C into a signal, and Ctrl+C is deliberately
461
+ // forwarded to the AGENT instead (interrupting a runaway agent is
462
+ // worth more than quitting the daemon). Same teardown either way.
463
+ onDetachRequested: () => {
464
+ process.stdout.write('\nyolo-bridge: detaching...\n');
465
+ onSignal();
466
+ },
454
467
  onExit: ({ exitCode, signal }) => {
455
468
  localAgentExited = true;
456
469
  stopRequested = true;
@@ -0,0 +1,80 @@
1
+ /**
2
+ * The detach escape sequence for `yolo-bridge attach`.
3
+ *
4
+ * WHY THIS EXISTS. `cli.ts` registers `process.on('SIGINT', …)` as the daemon's
5
+ * stop path, and `local-agent.ts` puts stdin into RAW MODE so every byte can be
6
+ * forwarded to the agent's PTY. Raw mode is precisely the mode in which the
7
+ * tty stops translating `\x03` into SIGINT — so that handler is not merely at
8
+ * risk of being missed, it is UNREACHABLE from the keyboard for as long as an
9
+ * agent is attached. The operator presses Ctrl+C expecting to quit, gets
10
+ * silence, and the teardown never runs.
11
+ *
12
+ * ⚠️ THE FIX IS NOT TO GIVE Ctrl+C BACK TO THE DAEMON. Forwarding it to the
13
+ * agent is the more valuable behaviour by a wide margin — interrupting a
14
+ * runaway agent is the thing an operator actually needs mid-session, and a
15
+ * daemon that quit instead would take the agent down with it. So Ctrl+C keeps
16
+ * going to the agent and the daemon gets its own key.
17
+ *
18
+ * `Ctrl-P Ctrl-Q`, following `docker attach`. Chosen because it is vanishingly
19
+ * rare in agent TUIs: `Ctrl-C`, `Ctrl-D`, `Ctrl-Z` and a lone `Ctrl-Q` are all
20
+ * in active use by the CLIs this daemon spawns, and stealing any of them would
21
+ * trade one broken key for another.
22
+ */
23
+ /** `Ctrl-P` — the prefix. Held back until the next byte decides its meaning. */
24
+ export const DETACH_PREFIX_BYTE = 0x10;
25
+ /** `Ctrl-Q` — only a detach when it IMMEDIATELY follows the prefix. */
26
+ export const DETACH_SUFFIX_BYTE = 0x11;
27
+ /**
28
+ * Splits a stdin stream into "detach" and "everything else".
29
+ *
30
+ * Byte-oriented and chunk-agnostic on purpose: in raw mode each keypress
31
+ * usually arrives as its own chunk, but nothing guarantees it, so the two
32
+ * bytes of the sequence may land together or apart and must behave identically
33
+ * either way.
34
+ *
35
+ * ⚠️ A PREFIX FOLLOWED BY ANYTHING ELSE FORWARDS BOTH BYTES. Swallowing the
36
+ * `Ctrl-P` would silently break it for agents that use it, which is the same
37
+ * class of bug this whole change exists to fix.
38
+ */
39
+ export function createDetachSequenceFilter(opts) {
40
+ let prefixPending = false;
41
+ let detached = false;
42
+ return {
43
+ push(data) {
44
+ // Once detached, further keystrokes belong to a session that is going
45
+ // away; forwarding them would race the teardown.
46
+ if (detached)
47
+ return;
48
+ let out = '';
49
+ for (let i = 0; i < data.length; i++) {
50
+ const code = data.charCodeAt(i);
51
+ if (prefixPending) {
52
+ prefixPending = false;
53
+ if (code === DETACH_SUFFIX_BYTE) {
54
+ // Emit whatever preceded the sequence, then stop. The prefix and
55
+ // suffix are consumed and never reach the agent.
56
+ if (out)
57
+ opts.emit(out);
58
+ detached = true;
59
+ opts.onDetach();
60
+ return;
61
+ }
62
+ // Not the suffix: the prefix was an ordinary keystroke after all.
63
+ out += String.fromCharCode(DETACH_PREFIX_BYTE);
64
+ // Fall through so THIS byte is handled normally — including the case
65
+ // where it is itself another prefix.
66
+ }
67
+ if (code === DETACH_PREFIX_BYTE) {
68
+ prefixPending = true;
69
+ continue;
70
+ }
71
+ out += String.fromCharCode(code);
72
+ }
73
+ if (out)
74
+ opts.emit(out);
75
+ },
76
+ dispose() {
77
+ prefixPending = false;
78
+ },
79
+ };
80
+ }
@@ -33,6 +33,7 @@
33
33
  */
34
34
  import { createRequire } from 'node:module';
35
35
  import { randomUUID } from 'node:crypto';
36
+ import { createDetachSequenceFilter } from './detach-sequence.js';
36
37
  import * as pty from 'node-pty';
37
38
  import { splitByUtf8Bytes } from './output-stream.js';
38
39
  import { AnsiScanner, TerminalModeTracker, buildModePrologue, resolveGroundStart, } from './ansi-replay-state.js';
@@ -737,8 +738,16 @@ export function startLocalAgent(opts = {}) {
737
738
  fanOutRawData(data, pushRaw(data));
738
739
  });
739
740
  if (inStream && typeof inStream.on === 'function') {
741
+ // Every byte passes through the detach filter on its way to the PTY. It
742
+ // forwards everything except the `Ctrl-P Ctrl-Q` sequence — including a
743
+ // lone `Ctrl-P`, which agents use for history and which must not be eaten.
744
+ const detachFilter = createDetachSequenceFilter({
745
+ emit: (chunk) => { ptyProcess.write(chunk); },
746
+ onDetach: () => { opts.onDetachRequested?.(); },
747
+ });
748
+ state.detachFilter = detachFilter;
740
749
  const stdinListener = (data) => {
741
- ptyProcess.write(typeof data === 'string' ? data : data.toString('utf-8'));
750
+ detachFilter.push(typeof data === 'string' ? data : data.toString('utf-8'));
742
751
  };
743
752
  if (inStream.isTTY && typeof inStream.setRawMode === 'function') {
744
753
  inStream.setRawMode(true);
@@ -818,6 +827,7 @@ function handleLocalResize(state) {
818
827
  }
819
828
  }
820
829
  function teardownStdio(state) {
830
+ state.detachFilter?.dispose();
821
831
  if (state.resizeSource && state.resizeListener && typeof state.resizeSource.removeListener === 'function') {
822
832
  state.resizeSource.removeListener('resize', state.resizeListener);
823
833
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yolo-labs/yolobridge",
3
- "version": "0.21.0",
3
+ "version": "0.22.0",
4
4
  "description": "YoloBridge — local coding-agent daemon that attaches a user's own Claude Code/Codex session to a YOLO Studio workspace as a first-class tile (docs/YOLOBRIDGE_PLAN.md, build-order Phase 5).",
5
5
  "license": "MIT",
6
6
  "type": "module",