@yolo-labs/yolobridge 0.27.0 → 0.28.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.
@@ -187,6 +187,32 @@ export async function openStream(cfg, workspaceId, attachmentId) {
187
187
  }
188
188
  return res;
189
189
  }
190
+ /**
191
+ * Tell the server where this daemon's LOCAL terminal server is listening.
192
+ *
193
+ * ⚠️ Uses the daemon's SCOPED token, and the route requires one. It is the
194
+ * mirror of the input route's rule: only a USER may type, and only the machine
195
+ * actually running the server may say where it is. A user token here would let
196
+ * anyone point a tile at an arbitrary address.
197
+ *
198
+ * Best-effort by design — returns false rather than throwing. A daemon whose
199
+ * report fails is still a perfectly good daemon; the tile simply falls back to
200
+ * the cloud relay, which is slower and works.
201
+ */
202
+ export async function reportLocalEndpoint(cfg, workspaceId, attachmentId, url, secret) {
203
+ const fetchImpl = cfg.fetchImpl ?? fetch;
204
+ try {
205
+ const res = await fetchImpl(`${base(cfg)}/v1/workspaces/${workspaceId}/yolobridge/attach/${attachmentId}/local-endpoint`, {
206
+ method: 'POST',
207
+ headers: { ...authHeaders(cfg), 'Content-Type': 'application/json' },
208
+ body: JSON.stringify({ url, secret }),
209
+ });
210
+ return res.ok;
211
+ }
212
+ catch {
213
+ return false;
214
+ }
215
+ }
190
216
  export async function postHeartbeat(cfg, workspaceId, attachmentId) {
191
217
  const body = await postEvent(cfg, workspaceId, { attachmentId, type: 'heartbeat' });
192
218
  return Boolean(body?.recorded);
@@ -900,6 +900,20 @@ export async function runAttachDaemon(deps) {
900
900
  return;
901
901
  }
902
902
  await apiClient.postHeartbeat(scopedCfg(), workspaceId, attachmentId);
903
+ // ⚠️ RE-REPORTED ON EVERY BEAT, not once at startup. The
904
+ // server holds this in memory with a TTL, so it is lost
905
+ // on a restart or a failover — and the correct recovery
906
+ // is the daemon simply saying it again a few seconds
907
+ // later, not the server persisting a secret. Best-effort:
908
+ // a failed report costs the tile its fast path, nothing
909
+ // more.
910
+ // Read LAZILY: the shell server starts inside
911
+ // `onAttached`, after these deps were built, so a
912
+ // snapshot taken at construction would always be empty.
913
+ const local = deps.localEndpoint?.();
914
+ if (local) {
915
+ await apiClient.reportLocalEndpoint(scopedCfg(), workspaceId, attachmentId, local.url, local.secret);
916
+ }
903
917
  }, (err) => {
904
918
  // The heartbeat hits the SAME boundary as the stream open
905
919
  // and gets the SAME 403, so it needs the same answer: a
@@ -912,6 +926,19 @@ export async function runAttachDaemon(deps) {
912
926
  detail: `heartbeat error: ${err instanceof Error ? err.message : String(err)}`,
913
927
  });
914
928
  }, undefined, deps.timers);
929
+ // ⚠️ REPORT THE LOCAL ENDPOINT IMMEDIATELY TOO, not only on
930
+ // the interval. Waiting ~10s means a tile opened in that
931
+ // window gets a 404 and silently downgrades to the ~200ms
932
+ // relay, while a perfectly good local server is already
933
+ // listening — and it would stay downgraded for that whole
934
+ // session. Same reasoning as the immediate heartbeat below.
935
+ // (codex P2.)
936
+ {
937
+ const local = deps.localEndpoint?.();
938
+ if (local) {
939
+ apiClient.reportLocalEndpoint(scopedCfg(), workspaceId, attachmentId, local.url, local.secret).catch(() => { });
940
+ }
941
+ }
915
942
  // Send one immediately so status isn't stale for the first ~10s.
916
943
  apiClient.postHeartbeat(scopedCfg(), workspaceId, attachmentId).catch((err) => {
917
944
  if (noteCredentialRejection(err))
package/dist/cli.js CHANGED
@@ -356,6 +356,12 @@ async function cmdAttach(args) {
356
356
  let result;
357
357
  try {
358
358
  result = await runAttachFromDisk({
359
+ // Read lazily on each heartbeat — the shell server starts in
360
+ // `onAttached`, after this object is built, so a value here would
361
+ // always be undefined.
362
+ localEndpoint: () => (shellServerHandle
363
+ ? { url: shellServerHandle.url, secret: shellServerHandle.secret }
364
+ : undefined),
359
365
  workspaceId,
360
366
  commonApiBaseUrl: apiUrl(),
361
367
  hostLabel: attachHostInfo.hostLabel,
@@ -38,16 +38,30 @@
38
38
  * why the client must treat "no answer" as a timeout and fall back, never wait
39
39
  * forever.
40
40
  *
41
- * ⚠️ KNOWN GAP RECONNECT FIDELITY FOR TUIs. The backlog is a byte TAIL, cut
42
- * at a parser-safe boundary. That is enough to resume a shell transcript, and
43
- * NOT enough to reconstruct a full-screen TUI that painted its layout once and
44
- * then emitted more than `BACKLOG_CHARS` of cursor-addressed updates: a viewer
45
- * reconnecting mid-session gets the updates without the screen they address,
46
- * and sticky modes set before the window are lost. The cloud path already
47
- * solved this properly — `local-agent.ts` keeps an `@xterm/headless` mirror and
48
- * serves a serialized screen plus a mode `prologue` and `@xterm/headless` is
49
- * already a dependency here. Doing the same for these shells is the next slice;
50
- * it is called out rather than left to be discovered. (codex P2.)
41
+ * ⚠️ THE BACKLOG DOES NOT RECONSTRUCT A TUI SCREEN AND NOTHING CURRENTLY
42
+ * NEEDS IT TO. Worth stating precisely, because the obvious next step here is
43
+ * speculative work.
44
+ *
45
+ * The backlog is a byte TAIL cut at a parser-safe boundary. It cannot rebuild a
46
+ * full-screen TUI that painted its layout once and then emitted more than
47
+ * `BACKLOG_CHARS` of cursor-addressed updates: a viewer joining mid-session
48
+ * would get the updates without the screen they address. The cloud path solved
49
+ * that properly `local-agent.ts` keeps an `@xterm/headless` mirror and serves
50
+ * a serialized screen plus a mode `prologue` and that dependency is already
51
+ * here, so doing the same looks like the natural next slice.
52
+ *
53
+ * IT IS NOT, because no client reconnects. `openLocalTerminal` establishes the
54
+ * stream exactly once; a stream that ends marks the session DEAD rather than
55
+ * retrying, and the tile never reuses a session id — every mount opens a fresh
56
+ * shell. So the replay only ever covers output produced between `/open` and
57
+ * `/stream`, a window of milliseconds inside a single connection, where a byte
58
+ * tail is exactly right.
59
+ *
60
+ * ⚠️ WHAT WOULD MAKE IT MATTER: adding session RESUMPTION — a tile that
61
+ * reattaches to its shell across a remount, a dropped stream, or a page
62
+ * refresh. That is a real feature and a reasonable one to want. The headless
63
+ * mirror is a prerequisite FOR IT, not an improvement on its own; building the
64
+ * mirror first would be solving the second half of a problem nobody has yet.
51
65
  *
52
66
  * SECURITY, deliberately narrow because this hands out shells:
53
67
  * · bound to 127.0.0.1 ONLY — never 0.0.0.0, so it is off the local network
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yolo-labs/yolobridge",
3
- "version": "0.27.0",
3
+ "version": "0.28.0",
4
4
  "description": "YoloBridge \u2014 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",