very-happy-cli 0.2.97 → 0.2.99

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.
Files changed (31) hide show
  1. package/dist/{AcpBackend-CTUM4dvG.mjs → AcpBackend-C9LtzYSx.mjs} +1 -1
  2. package/dist/{AcpBackend-DrwdVSeJ.cjs → AcpBackend-DkvV2nhl.cjs} +1 -1
  3. package/dist/{AcpSessionManager-BoBuHee3.mjs → AcpSessionManager-B5rJcE82.mjs} +1 -1
  4. package/dist/{AcpSessionManager-BieE187D.cjs → AcpSessionManager-gsp_-PGy.cjs} +1 -1
  5. package/dist/{config-DCH33LOM.mjs → config-BcCVR-Wx.mjs} +2 -2
  6. package/dist/{config-COq6X7U7.cjs → config-Bidi0Yeq.cjs} +2 -2
  7. package/dist/{index-Bcgquogx.mjs → index-B87z2DOo.mjs} +12 -15
  8. package/dist/{index-Dn0iOBFd.cjs → index-CxdGnaov.cjs} +4 -4
  9. package/dist/{index-BD2fHJ8o.cjs → index-Dp1EYEyF.cjs} +18 -21
  10. package/dist/{index-DkhMvofH.mjs → index-Wfds4scC.mjs} +5 -5
  11. package/dist/index.cjs +2 -2
  12. package/dist/index.mjs +2 -2
  13. package/dist/{installTerminalHooks-DqNtW6a2.mjs → installTerminalHooks-DkOh088o.mjs} +2 -2
  14. package/dist/{installTerminalHooks-H3kpFEZS.cjs → installTerminalHooks-jaCruGfR.cjs} +2 -2
  15. package/dist/lib.cjs +1 -1
  16. package/dist/lib.d.cts +11 -0
  17. package/dist/lib.d.mts +11 -0
  18. package/dist/lib.mjs +1 -1
  19. package/dist/{mcp-BKAuzaLX.mjs → mcp-DhftF_XE.mjs} +2 -2
  20. package/dist/{mcp-BwNc5eRs.cjs → mcp-ZQbu8tL1.cjs} +2 -2
  21. package/dist/{runGemini-Brp0UmLx.mjs → runGemini-3-xWWBRA.mjs} +4 -4
  22. package/dist/{runGemini-w35qiFBB.cjs → runGemini-Ce7Maf-D.cjs} +4 -4
  23. package/dist/{runOpenClaw-BvPRFx-L.mjs → runOpenClaw-DZhGhihh.mjs} +3 -3
  24. package/dist/{runOpenClaw-CkHtTCpI.cjs → runOpenClaw-q3inEsDr.cjs} +3 -3
  25. package/dist/{send-c-wEmh5U.mjs → send-B3NOqi9-.mjs} +2 -2
  26. package/dist/{send-tyKG13xt.cjs → send-BdEJeVye.cjs} +2 -2
  27. package/dist/{spawn-yS6yY8Xi.mjs → spawn-CCYAeBfN.mjs} +2 -2
  28. package/dist/{spawn-xdCsG2RT.cjs → spawn-DEVUh4iW.cjs} +2 -2
  29. package/dist/{types-xZOYW0_G.mjs → types-CrJj-daH.mjs} +302 -73
  30. package/dist/{types-DoGaKVfK.cjs → types-DhxxJFD0.cjs} +303 -73
  31. package/package.json +7 -7
@@ -51,7 +51,7 @@ var z__namespace = /*#__PURE__*/_interopNamespaceDefault(z);
51
51
  var pty__namespace = /*#__PURE__*/_interopNamespaceDefault(pty);
52
52
 
53
53
  var name = "very-happy-cli";
54
- var version = "0.2.97";
54
+ var version = "0.2.99";
55
55
  var description = "Remote multi-agent workspace CLI with deep Claude integration, native terminal streaming, and self-hosting.";
56
56
  var author = "Kirill Dubovitskiy";
57
57
  var contributors = [
@@ -164,12 +164,12 @@ var dependencies = {
164
164
  zod: "^4.0.0"
165
165
  };
166
166
  var optionalDependencies = {
167
- "very-happy-tools-arm64-darwin": "0.2.97",
168
- "very-happy-tools-x64-darwin": "0.2.97",
169
- "very-happy-tools-arm64-linux": "0.2.97",
170
- "very-happy-tools-x64-linux": "0.2.97",
171
- "very-happy-tools-arm64-win32": "0.2.97",
172
- "very-happy-tools-x64-win32": "0.2.97"
167
+ "very-happy-tools-arm64-darwin": "0.2.99",
168
+ "very-happy-tools-x64-darwin": "0.2.99",
169
+ "very-happy-tools-arm64-linux": "0.2.99",
170
+ "very-happy-tools-x64-linux": "0.2.99",
171
+ "very-happy-tools-arm64-win32": "0.2.99",
172
+ "very-happy-tools-x64-win32": "0.2.99"
173
173
  };
174
174
  var devDependencies = {
175
175
  "@slopus/happy-wire": "workspace:*",
@@ -1041,7 +1041,9 @@ const WebTerminalListItemSchema = z.z.object({
1041
1041
  * conversation carried over, the processes did not. Cleared once opened. */
1042
1042
  restoredAt: z.z.number().optional(),
1043
1043
  /** B-265: @vh_title_manual is set (user renamed it). */
1044
- manual: z.z.boolean().optional()
1044
+ manual: z.z.boolean().optional(),
1045
+ /** B-273: opened attached to the user's tmux session of this name. */
1046
+ attachTmux: z.z.string().optional()
1045
1047
  });
1046
1048
  const ClosedTerminalRecordSchema = z.z.object({
1047
1049
  id: z.z.string(),
@@ -1058,6 +1060,8 @@ const ClosedTerminalRecordSchema = z.z.object({
1058
1060
  /** B-265: tags + manual-rename flag at close time (restored verbatim). */
1059
1061
  tags: z.z.array(z.z.string()).optional(),
1060
1062
  manual: z.z.boolean().optional(),
1063
+ /** B-273: attached user tmux session name — restore re-attaches. */
1064
+ attachTmux: z.z.string().optional(),
1061
1065
  closedAt: z.z.number()
1062
1066
  });
1063
1067
  z.z.object({
@@ -1111,6 +1115,18 @@ z.z.object({
1111
1115
  rpcAvailable: z.z.boolean(),
1112
1116
  detectedAt: z.z.number()
1113
1117
  }).optional(),
1118
+ /**
1119
+ * B-273 capability flag: this daemon answers `list-tmux-sessions` and
1120
+ * honours `open-terminal.attachTmux`. Same trust rule (`detectedAt >=
1121
+ * startedAt`). Stamped unconditionally — on a machine without tmux the
1122
+ * RPC just answers an empty list.
1123
+ */
1124
+ tmuxSessions: z.z.object({
1125
+ rpcAvailable: z.z.boolean(),
1126
+ detectedAt: z.z.number(),
1127
+ /** B-282: `kill-terminal` honours `alsoAttached`. */
1128
+ killAttached: z.z.boolean().optional()
1129
+ }).optional(),
1114
1130
  /** Relay-owned CLI compatibility/update policy last checked by this daemon. */
1115
1131
  cliUpdate: CliUpdateStateSchema.optional(),
1116
1132
  /**
@@ -1569,7 +1585,7 @@ class RpcHandlerManager {
1569
1585
  }
1570
1586
  }
1571
1587
 
1572
- const __dirname$1 = path$1.dirname(url.fileURLToPath((typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('types-DoGaKVfK.cjs', document.baseURI).href))));
1588
+ const __dirname$1 = path$1.dirname(url.fileURLToPath((typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('types-DhxxJFD0.cjs', document.baseURI).href))));
1573
1589
  function projectPath() {
1574
1590
  const path = path$1.resolve(__dirname$1, "..");
1575
1591
  return path;
@@ -4812,7 +4828,7 @@ function sanitizeLiveSnapshot(raw, now, ttlMs = LIVE_SNAPSHOT_TTL_MS, max = LIVE
4812
4828
  const rows = [];
4813
4829
  for (const [id, value] of Object.entries(raw)) {
4814
4830
  if (!id || !value || typeof value !== "object") continue;
4815
- const { title, cwd, seenAt, tags, manual } = value;
4831
+ const { title, cwd, seenAt, tags, manual, attachTmux } = value;
4816
4832
  if (typeof seenAt !== "number" || !Number.isFinite(seenAt)) continue;
4817
4833
  if (now - seenAt >= ttlMs) continue;
4818
4834
  const tagList = sanitizeTagList(tags);
@@ -4821,6 +4837,7 @@ function sanitizeLiveSnapshot(raw, now, ttlMs = LIVE_SNAPSHOT_TTL_MS, max = LIVE
4821
4837
  cwd: typeof cwd === "string" && cwd ? cwd : void 0,
4822
4838
  ...tagList !== void 0 ? { tags: tagList } : {},
4823
4839
  ...manual === true ? { manual: true } : {},
4840
+ ...typeof attachTmux === "string" && attachTmux ? { attachTmux } : {},
4824
4841
  seenAt
4825
4842
  }]);
4826
4843
  }
@@ -4841,6 +4858,7 @@ function liveSnapshotChanged(prev, next) {
4841
4858
  if (!before) return true;
4842
4859
  if (before.title !== info.title || before.cwd !== info.cwd) return true;
4843
4860
  if (!!before.manual !== !!info.manual) return true;
4861
+ if ((before.attachTmux ?? "") !== (info.attachTmux ?? "")) return true;
4844
4862
  if ((before.tags ?? []).join("\0") !== (info.tags ?? []).join("\0")) return true;
4845
4863
  }
4846
4864
  return false;
@@ -4866,9 +4884,89 @@ function pickMirrorForTerminal(sessions, terminalId) {
4866
4884
  return best ? { sessionId: best.id, claudeSessionId: best.claude } : null;
4867
4885
  }
4868
4886
 
4887
+ function shellescape(s) {
4888
+ return "'" + s.replace(/'/g, "'\\''") + "'";
4889
+ }
4890
+
4891
+ const USER_SESSION_FIELD_SEP = "<~|~>";
4892
+ const USER_SESSIONS_FORMAT = [
4893
+ "#{session_id}",
4894
+ "#{session_name}",
4895
+ "#{session_windows}",
4896
+ "#{session_attached}",
4897
+ "#{session_activity}",
4898
+ "#{session_created}",
4899
+ "#{pane_current_command}",
4900
+ "#{pane_current_path}"
4901
+ ].join(USER_SESSION_FIELD_SEP);
4902
+ const TMUX_SESSION_ID_RE = /^\$\d{1,9}$/;
4903
+ const TMUX_SESSION_NAME_MAX = 128;
4904
+ function isSafeTmuxSessionName(name) {
4905
+ if (typeof name !== "string") return false;
4906
+ if (name.length === 0 || name.length > TMUX_SESSION_NAME_MAX) return false;
4907
+ return !/[\x00-\x1f\x7f]/.test(name);
4908
+ }
4909
+ function isVhSessionName(name) {
4910
+ return name.startsWith("vh-");
4911
+ }
4912
+ function parseUserSessionLine(line) {
4913
+ if (!line) return void 0;
4914
+ const parts = line.split(USER_SESSION_FIELD_SEP);
4915
+ if (parts.length < 8) return void 0;
4916
+ const [id, name, windows, attached, activity, created, command] = parts;
4917
+ if (!TMUX_SESSION_ID_RE.test(id) || !isSafeTmuxSessionName(name)) return void 0;
4918
+ const cwd = parts.slice(7).join(USER_SESSION_FIELD_SEP);
4919
+ const num = (v) => {
4920
+ const n = Number(v);
4921
+ return Number.isFinite(n) ? n : void 0;
4922
+ };
4923
+ return {
4924
+ id,
4925
+ name,
4926
+ windows: Math.max(0, Math.floor(num(windows) ?? 0)),
4927
+ attached: (num(attached) ?? 0) > 0,
4928
+ activityAt: activity ? (num(activity) ?? 0) * 1e3 || void 0 : void 0,
4929
+ createdAt: created ? (num(created) ?? 0) * 1e3 || void 0 : void 0,
4930
+ command: command.trim() || void 0,
4931
+ cwd: cwd || void 0
4932
+ };
4933
+ }
4934
+ const USER_SESSIONS_MAX = 50;
4935
+ function parseUserSessions(stdout, max = USER_SESSIONS_MAX) {
4936
+ const out = [];
4937
+ for (const line of stdout.split("\n")) {
4938
+ const s = parseUserSessionLine(line);
4939
+ if (!s || isVhSessionName(s.name)) continue;
4940
+ out.push(s);
4941
+ }
4942
+ out.sort((a, b) => (b.activityAt ?? 0) - (a.activityAt ?? 0));
4943
+ return out.slice(0, max);
4944
+ }
4945
+ function attachStartupCommand(sessionId, socket) {
4946
+ if (!TMUX_SESSION_ID_RE.test(sessionId)) throw new Error(`not a tmux session id: ${sessionId}`);
4947
+ const sock = socket ? ` -S ${shellescape(socket)}` : "";
4948
+ return ` TMUX= tmux${sock} attach-session -t ${shellescape(sessionId)}`;
4949
+ }
4950
+
4869
4951
  const TERMINAL_ID_RE$1 = /^[a-zA-Z0-9_-]{1,64}$/;
4870
4952
  function planTerminalRestore(record, facts) {
4871
4953
  if (facts.tmuxAlive) return { kind: "already-live" };
4954
+ if (record.attachTmux) {
4955
+ const matches = (facts.userSessions ?? []).filter((s) => s.name === record.attachTmux);
4956
+ if (matches.length !== 1) return { kind: "error", reason: "tmux-session-gone" };
4957
+ const cwd = record.cwd && facts.cwdExists(record.cwd) ? record.cwd : facts.homeDir;
4958
+ if (!cwd) return { kind: "error", reason: "missing-cwd" };
4959
+ return {
4960
+ kind: "create",
4961
+ terminalId: record.id,
4962
+ cwd,
4963
+ title: record.title ?? record.attachTmux,
4964
+ manual: true,
4965
+ tags: record.tags,
4966
+ command: attachStartupCommand(matches[0].id, facts.attachSocket),
4967
+ attachTmux: record.attachTmux
4968
+ };
4969
+ }
4872
4970
  if (!record.cwd || !facts.cwdExists(record.cwd)) return { kind: "error", reason: "missing-cwd" };
4873
4971
  const resumable = isClaudeSessionId(record.claudeSessionId) && facts.conversationExists(record.cwd, record.claudeSessionId);
4874
4972
  return {
@@ -4882,6 +4980,20 @@ function planTerminalRestore(record, facts) {
4882
4980
  };
4883
4981
  }
4884
4982
 
4983
+ const VH_TMUX_SOCKET_ENV = "VH_TMUX_SOCKET";
4984
+ function tmuxSocketFlags(env = process.env) {
4985
+ const socket = env[VH_TMUX_SOCKET_ENV];
4986
+ return socket ? ["-S", socket] : [];
4987
+ }
4988
+ function tmuxArgs(args, env = process.env) {
4989
+ return [...tmuxSocketFlags(env), ...args];
4990
+ }
4991
+ function scrubTmuxClientEnv(env) {
4992
+ delete env.TMUX;
4993
+ delete env.TMUX_PANE;
4994
+ return env;
4995
+ }
4996
+
4885
4997
  function getProjectPath(workingDirectory) {
4886
4998
  const projectId = path.resolve(workingDirectory).replace(/[^a-zA-Z0-9-]/g, "-");
4887
4999
  const claudeConfigDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude");
@@ -9215,20 +9327,6 @@ function previewAscii(line) {
9215
9327
  return line.length > 80 ? `${out}\u2026` : out;
9216
9328
  }
9217
9329
 
9218
- const VH_TMUX_SOCKET_ENV = "VH_TMUX_SOCKET";
9219
- function tmuxSocketFlags(env = process.env) {
9220
- const socket = env[VH_TMUX_SOCKET_ENV];
9221
- return socket ? ["-S", socket] : [];
9222
- }
9223
- function tmuxArgs(args, env = process.env) {
9224
- return [...tmuxSocketFlags(env), ...args];
9225
- }
9226
- function scrubTmuxClientEnv(env) {
9227
- delete env.TMUX;
9228
- delete env.TMUX_PANE;
9229
- return env;
9230
- }
9231
-
9232
9330
  const CONTROL_CLIENT_KILL_GRACE_MS = 2e3;
9233
9331
  const CONTROL_COMMAND_TIMEOUT_MS = 1e4;
9234
9332
  function isSafeControlCommand(command) {
@@ -10046,7 +10144,7 @@ function sanitizeClosedTerminals(raw, max = CLOSED_TERMINALS_MAX) {
10046
10144
  const out = [];
10047
10145
  for (const item of raw) {
10048
10146
  if (!item || typeof item !== "object") continue;
10049
- const { id, title, cwd, mirrorSessionId, claudeSessionId, reason, closedAt, tags, manual } = item;
10147
+ const { id, title, cwd, mirrorSessionId, claudeSessionId, reason, closedAt, tags, manual, attachTmux } = item;
10050
10148
  if (typeof id !== "string" || id.length === 0 || typeof closedAt !== "number") continue;
10051
10149
  if (seen.has(id)) continue;
10052
10150
  seen.add(id);
@@ -10060,6 +10158,7 @@ function sanitizeClosedTerminals(raw, max = CLOSED_TERMINALS_MAX) {
10060
10158
  reason: reason === "daemon-gap" || reason === "closed" ? reason : void 0,
10061
10159
  ...tagList !== void 0 ? { tags: tagList } : {},
10062
10160
  ...manual === true ? { manual: true } : {},
10161
+ ...typeof attachTmux === "string" && attachTmux ? { attachTmux } : {},
10063
10162
  closedAt
10064
10163
  });
10065
10164
  }
@@ -10184,7 +10283,7 @@ function startupInjectionArgs(tmuxSession, command) {
10184
10283
  ];
10185
10284
  }
10186
10285
  function seenInfoOf(t) {
10187
- return { title: t.title, cwd: t.cwd, tags: t.tags, manual: t.manual };
10286
+ return { title: t.title, cwd: t.cwd, tags: t.tags, manual: t.manual, attachTmux: t.attachTmux };
10188
10287
  }
10189
10288
  function terminalListSignature(items) {
10190
10289
  const canon = [...items].sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0).map((t) => [
@@ -10197,7 +10296,8 @@ function terminalListSignature(items) {
10197
10296
  t.agentState ?? "",
10198
10297
  // B-105: a mirror binding appearing/disappearing MUST push the
10199
10298
  // list, or the web never learns the toggle became available.
10200
- t.mirrorSessionId ?? ""
10299
+ t.mirrorSessionId ?? "",
10300
+ t.attachTmux ?? ""
10201
10301
  ]);
10202
10302
  return JSON.stringify(canon);
10203
10303
  }
@@ -10216,7 +10316,7 @@ function deriveAutoTitle(paneTitle, hostname) {
10216
10316
  const chars = Array.from(t);
10217
10317
  return chars.length > TITLE_MAX_CHARS ? chars.slice(0, TITLE_MAX_CHARS).join("") : t;
10218
10318
  }
10219
- const LIST_FIELD_SEP = "";
10319
+ const LIST_FIELD_SEP = "<~|~>";
10220
10320
  const LIST_SESSIONS_FORMAT = [
10221
10321
  "#{session_name}",
10222
10322
  "#{session_created}",
@@ -10225,6 +10325,9 @@ const LIST_SESSIONS_FORMAT = [
10225
10325
  "#{@vh_title}",
10226
10326
  "#{@vh_title_manual}",
10227
10327
  "#{@vh_tags}",
10328
+ // B-273: the user tmux session this terminal was opened to attach (name),
10329
+ // carried into close records so a restore can re-attach.
10330
+ "#{@vh_attach}",
10228
10331
  // B-121: the control-mode client has no `pty.process`, so the agent-state
10229
10332
  // fast path lost its live `#{pane_current_command}` equivalent. Carry it in
10230
10333
  // the ONE list-sessions call the tracker already makes — the value goes
@@ -10239,8 +10342,8 @@ const LIST_SESSIONS_FORMAT = [
10239
10342
  function parseSessionListLine(line) {
10240
10343
  if (!line) return void 0;
10241
10344
  const parts = line.split(LIST_FIELD_SEP);
10242
- if (parts.length < 9) return void 0;
10243
- const [name, created, activity, cwd, vhTitle, manual, vhTags, paneCommand] = parts;
10345
+ if (parts.length < 10) return void 0;
10346
+ const [name, created, activity, cwd, vhTitle, manual, vhTags, vhAttach, paneCommand] = parts;
10244
10347
  if (!name) return void 0;
10245
10348
  return {
10246
10349
  name,
@@ -10251,9 +10354,12 @@ function parseSessionListLine(line) {
10251
10354
  manual: manual.trim().length > 0,
10252
10355
  tags: parseTerminalTags(vhTags),
10253
10356
  paneCurrentCommand: paneCommand.trim() || void 0,
10254
- // pane_title is last, so anything after field 8 is title content that
10357
+ // Verbatim (no trim): tmux allows edge spaces in a session name and the
10358
+ // restore lookup / attach echo compare it exactly.
10359
+ attachTmux: isSafeTmuxSessionName(vhAttach) ? vhAttach : void 0,
10360
+ // pane_title is last, so anything after field 9 is title content that
10255
10361
  // contained the separator — rejoin it rather than dropping it.
10256
- paneTitle: parts.slice(8).join(LIST_FIELD_SEP) || void 0
10362
+ paneTitle: parts.slice(9).join(LIST_FIELD_SEP) || void 0
10257
10363
  };
10258
10364
  }
10259
10365
  const TERMINAL_TAG_MAX_COUNT = 64;
@@ -10310,12 +10416,12 @@ function isClaudeConfident(tail) {
10310
10416
  const last15 = lines.slice(-15).join("\n");
10311
10417
  return last15.includes("Do you want") || last15.includes("Would you like to proceed") || /^[\s│]*[❯>]\s*1\.\s/m.test(last15) || text.includes("esc to interrupt") || text.includes("? for shortcuts") || text.includes("bypass permissions on") || text.includes("\u23F5\u23F5");
10312
10418
  }
10313
- function planScrollAction(paneInMode, alternateOn, paneWantsMouse, lines, claudeLike = false, paneRows = 24) {
10419
+ function planScrollAction(paneInMode, alternateOn, paneWantsMouse, lines, claudeLike = false, paneRows = 24, nestedTmux = false) {
10314
10420
  const count = Math.min(Math.abs(Math.trunc(lines)), 200);
10315
10421
  if (count === 0) return { kind: "none" };
10316
10422
  const up = lines > 0;
10317
10423
  if (paneInMode) return { kind: "copy-scroll", dir: up ? "up" : "down", count };
10318
- if (alternateOn && paneWantsMouse) return { kind: "mouse-wheel", dir: up ? "up" : "down", count };
10424
+ if (alternateOn && (paneWantsMouse || nestedTmux)) return { kind: "mouse-wheel", dir: up ? "up" : "down", count };
10319
10425
  if (alternateOn && claudeLike) {
10320
10426
  const halfPage = Math.max(1, Math.floor(paneRows / 2));
10321
10427
  const pages = Math.max(1, Math.round(count / halfPage));
@@ -10430,6 +10536,9 @@ class TerminalSession {
10430
10536
  id;
10431
10537
  tmuxSession;
10432
10538
  transport;
10539
+ /** B-273: the user tmux session this terminal was created to attach —
10540
+ * echoed to any later open of the same live session. */
10541
+ attachedTmux;
10433
10542
  /**
10434
10543
  * The pane this terminal follows, latched from the open capture's
10435
10544
  * `list-panes` (first pane of the window). `%output` from any OTHER pane is
@@ -10940,6 +11049,7 @@ class WebTerminalManager {
10940
11049
  cwd: info.cwd,
10941
11050
  tags: info.tags,
10942
11051
  manual: info.manual,
11052
+ ...info.attachTmux ? { attachTmux: info.attachTmux } : {},
10943
11053
  // Live binding first (authoritative while it exists), persisted
10944
11054
  // metadata as the fallback once the mirror has been torn down.
10945
11055
  mirrorSessionId: this.mirrorResolver?.(id) ?? mirror?.sessionId,
@@ -10992,6 +11102,7 @@ class WebTerminalManager {
10992
11102
  cwd: info.cwd,
10993
11103
  tags: info.tags,
10994
11104
  manual: info.manual,
11105
+ ...info.attachTmux ? { attachTmux: info.attachTmux } : {},
10995
11106
  mirrorSessionId: mirror?.sessionId,
10996
11107
  claudeSessionId: mirror?.claudeSessionId,
10997
11108
  reason: "daemon-gap",
@@ -11111,12 +11222,17 @@ class WebTerminalManager {
11111
11222
  return false;
11112
11223
  }
11113
11224
  },
11114
- conversationExists: (cwd, claudeSessionId) => node_fs.existsSync(path.join(getProjectPath(cwd), `${claudeSessionId}.jsonl`))
11225
+ conversationExists: (cwd, claudeSessionId) => node_fs.existsSync(path.join(getProjectPath(cwd), `${claudeSessionId}.jsonl`)),
11226
+ // B-273: only consulted for records that were attach terminals.
11227
+ // Unbounded: the panel's 50-row cap must not hide a restore target.
11228
+ userSessions: record.attachTmux ? this.listUserTmuxSessions(Infinity) : [],
11229
+ attachSocket: process.env[VH_TMUX_SOCKET_ENV],
11230
+ homeDir: os.homedir()
11115
11231
  });
11116
11232
  if (plan.kind === "already-live") return { type: "success", terminalId };
11117
11233
  if (plan.kind === "error") return { type: "error", reason: plan.reason };
11118
11234
  if (!this.createDetachedTerminal(plan)) return { type: "error", reason: "create-failed" };
11119
- logger.info(`[WEB TERMINAL] restored closed terminal ${terminalId} in ${plan.cwd}${plan.command ? " (conversation resumed)" : ""}`);
11235
+ logger.info(`[WEB TERMINAL] restored closed terminal ${terminalId} in ${plan.cwd}${plan.attachTmux ? ` (attached tmux session ${plan.attachTmux})` : plan.command ? " (conversation resumed)" : ""}`);
11120
11236
  this.kickListRefresh();
11121
11237
  return { type: "success", terminalId };
11122
11238
  }
@@ -11176,6 +11292,13 @@ class WebTerminalManager {
11176
11292
  { stdio: "ignore", timeout: TMUX_PROBE_TIMEOUT_MS, env }
11177
11293
  );
11178
11294
  }
11295
+ if (plan.attachTmux) {
11296
+ node_child_process.spawnSync(
11297
+ "tmux",
11298
+ tmuxArgs(["set-option", "-t", `=${name}:`, "@vh_attach", plan.attachTmux]),
11299
+ { stdio: "ignore", timeout: TMUX_PROBE_TIMEOUT_MS, env }
11300
+ );
11301
+ }
11179
11302
  if (plan.command) {
11180
11303
  for (const argv of startupInjectionArgs(name, plan.command)) {
11181
11304
  node_child_process.spawnSync("tmux", tmuxArgs(argv), { stdio: "ignore", timeout: TMUX_PROBE_TIMEOUT_MS, env });
@@ -11188,7 +11311,7 @@ class WebTerminalManager {
11188
11311
  * would care about changed (see liveSnapshotChanged). */
11189
11312
  persistLiveSnapshot(live, now) {
11190
11313
  const candidate = /* @__PURE__ */ new Map();
11191
- for (const [id, info] of live) candidate.set(id, { title: info.title, cwd: info.cwd, tags: info.tags, manual: info.manual, seenAt: now });
11314
+ for (const [id, info] of live) candidate.set(id, { title: info.title, cwd: info.cwd, tags: info.tags, manual: info.manual, ...info.attachTmux ? { attachTmux: info.attachTmux } : {}, seenAt: now });
11192
11315
  if (!liveSnapshotChanged(this.lastPersistedSnapshot, candidate)) return;
11193
11316
  this.lastPersistedSnapshot = candidate;
11194
11317
  saveLiveSnapshot(candidate);
@@ -11286,6 +11409,7 @@ class WebTerminalManager {
11286
11409
  const lines = opts.streamMode === "lines";
11287
11410
  const existing = this.terminals.get(id);
11288
11411
  if (existing) {
11412
+ const echo2 = opts.attachTmux && !(opts.attachOnly || opts.resub) && existing.attachedTmux?.name === opts.attachTmux.name ? { attachedTmux: existing.attachedTmux } : {};
11289
11413
  if (!opts.resub || existing.subscribers === 0) existing.subscribers += 1;
11290
11414
  existing.lastTouch = Date.now();
11291
11415
  this.applyResize(existing, cols, rows);
@@ -11298,15 +11422,16 @@ class WebTerminalManager {
11298
11422
  seq: existing.seq,
11299
11423
  mode: "replay",
11300
11424
  chunks,
11301
- ...lines ? { streamMode: "lines" } : {}
11425
+ ...lines ? { streamMode: "lines" } : {},
11426
+ ...echo2
11302
11427
  };
11303
11428
  }
11304
11429
  if (lines && existing.transport.kind === "control") {
11305
- return await this.linesSnapshotResponse(id, existing);
11430
+ return { ...await this.linesSnapshotResponse(id, existing), ...echo2 };
11306
11431
  }
11307
11432
  const state = existing.subscribeState(opts.fromSeq);
11308
11433
  logger.debug(`[WEB TERMINAL] re-subscribed ${id} (subs=${existing.subscribers}, mode=${state.mode}, seq=${state.seq})`);
11309
- return { terminalId: id, tmuxSession: existing.tmuxSession, ...state };
11434
+ return { terminalId: id, tmuxSession: existing.tmuxSession, ...state, ...echo2 };
11310
11435
  }
11311
11436
  if (opts.attachOnly || opts.resub) {
11312
11437
  const alive = isTmuxAvailable() && node_child_process.spawnSync(
@@ -11331,6 +11456,10 @@ class WebTerminalManager {
11331
11456
  let file = defaultShell();
11332
11457
  let args = [];
11333
11458
  let tmuxSession;
11459
+ let attachedTmux;
11460
+ if (opts.attachTmux && !(opts.attachOnly || opts.resub) && !isTmuxAvailable()) {
11461
+ throw new Error("tmux-unavailable");
11462
+ }
11334
11463
  if (isTmuxAvailable()) {
11335
11464
  tmuxSession = `vh-${id}`;
11336
11465
  let createdNew = false;
@@ -11343,6 +11472,7 @@ class WebTerminalManager {
11343
11472
  `VH_HAPPY_HOME_DIR=${configuration.happyHomeDir}`
11344
11473
  ] : [];
11345
11474
  const attachOnly = !!(opts.attachOnly || opts.resub);
11475
+ const attachTarget = !attachOnly && opts.attachTmux ? this.resolveAttachTarget(opts.attachTmux) : void 0;
11346
11476
  if (!attachOnly) {
11347
11477
  try {
11348
11478
  const created = node_child_process.spawnSync(
@@ -11384,16 +11514,15 @@ class WebTerminalManager {
11384
11514
  }
11385
11515
  }
11386
11516
  if (createdNew) {
11387
- const startup = normalizeStartupCommand(opts.startupCommand);
11388
- if (startup) {
11389
- try {
11390
- for (const a of startupInjectionArgs(tmuxSession, startup)) {
11391
- node_child_process.spawnSync("tmux", tmuxArgs(a), { stdio: "ignore", timeout: TMUX_PROBE_TIMEOUT_MS, env });
11392
- }
11393
- logger.debug(`[WEB TERMINAL] injected startup command into new session ${tmuxSession}`);
11394
- } catch {
11395
- }
11396
- }
11517
+ attachedTmux = this.injectIntoCreated(tmuxSession, attachTarget, opts.startupCommand, env);
11518
+ } else if (opts.attachTmux && !attachOnly) {
11519
+ const stored = node_child_process.spawnSync(
11520
+ "tmux",
11521
+ tmuxArgs(["show-options", "-qv", "-t", `=${tmuxSession}:`, "@vh_attach"]),
11522
+ { encoding: "utf8", timeout: TMUX_PROBE_TIMEOUT_MS, env }
11523
+ );
11524
+ const name = stored.status === 0 && typeof stored.stdout === "string" ? stored.stdout.replace(/\r?\n$/, "") : "";
11525
+ if (name && name === opts.attachTmux.name) attachedTmux = { id: opts.attachTmux.id, name };
11397
11526
  }
11398
11527
  if (!attachOnly && !createdNew) {
11399
11528
  const alive = node_child_process.spawnSync(
@@ -11408,17 +11537,7 @@ class WebTerminalManager {
11408
11537
  { stdio: "ignore", timeout: TMUX_CREATE_TIMEOUT_MS, env }
11409
11538
  );
11410
11539
  createdNew = retry.status === 0;
11411
- if (createdNew) {
11412
- const startup = normalizeStartupCommand(opts.startupCommand);
11413
- if (startup) {
11414
- for (const a of startupInjectionArgs(tmuxSession, startup)) {
11415
- try {
11416
- node_child_process.spawnSync("tmux", tmuxArgs(a), { stdio: "ignore", timeout: TMUX_PROBE_TIMEOUT_MS, env });
11417
- } catch {
11418
- }
11419
- }
11420
- }
11421
- }
11540
+ if (createdNew) attachedTmux = this.injectIntoCreated(tmuxSession, attachTarget, opts.startupCommand, env);
11422
11541
  }
11423
11542
  }
11424
11543
  if (!attachOnly && !createdNew) {
@@ -11428,6 +11547,7 @@ class WebTerminalManager {
11428
11547
  { stdio: "ignore", timeout: TMUX_PROBE_TIMEOUT_MS, env }
11429
11548
  ).status === 0;
11430
11549
  if (!alive) {
11550
+ if (attachTarget) throw new Error("tmux-unavailable");
11431
11551
  logger.info(`[WEB TERMINAL] tmux session creation unavailable; using direct PTY for ${id}`);
11432
11552
  tmuxSession = void 0;
11433
11553
  }
@@ -11510,20 +11630,110 @@ class WebTerminalManager {
11510
11630
  session = created;
11511
11631
  }
11512
11632
  session.subscribers = 1;
11633
+ if (attachedTmux) session.attachedTmux = attachedTmux;
11513
11634
  this.terminals.set(id, session);
11514
11635
  session.onTitleChange(() => this.kickListRefresh());
11515
11636
  logger.debug(`[WEB TERMINAL] opened ${id} (${tmuxSession ? "control" : "pty"}) ${cols}x${rows} cwd=${cwd}`);
11516
11637
  this.kickListRefresh();
11638
+ const echo = attachedTmux ? { attachedTmux } : {};
11517
11639
  if (session.transport.kind !== "control") {
11518
11640
  const state = session.subscribeState(void 0);
11519
- return { terminalId: id, tmuxSession, ...state, ...lines ? { streamMode: "lines" } : {} };
11641
+ return { terminalId: id, tmuxSession, ...state, ...lines ? { streamMode: "lines" } : {}, ...echo };
11520
11642
  }
11521
11643
  const restored = await this.captureRestore(session, true);
11522
11644
  if (!lines) {
11523
11645
  const state = session.subscribeState(void 0);
11524
- return { terminalId: id, tmuxSession, ...state };
11646
+ return { terminalId: id, tmuxSession, ...state, ...echo };
11647
+ }
11648
+ return { ...this.linesResponse(id, session, restored), ...echo };
11649
+ }
11650
+ /**
11651
+ * Post-create injection shared by BOTH creation sites in open(): an attach
11652
+ * request (B-273) pins the title to the user session's name (the inner
11653
+ * tmux's pane title is not reliable), stores `@vh_attach` for close
11654
+ * records / restore and types the attach line; otherwise the ordinary
11655
+ * startup command runs. Returns what to echo as `attachedTmux`.
11656
+ */
11657
+ injectIntoCreated(tmuxSession, attachTarget, startupCommand, env) {
11658
+ if (attachTarget) {
11659
+ for (const a of [
11660
+ ["set-option", "-t", `=${tmuxSession}:`, "@vh_title", attachTarget.name],
11661
+ ["set-option", "-t", `=${tmuxSession}:`, "@vh_title_manual", "1"],
11662
+ ["set-option", "-t", `=${tmuxSession}:`, "@vh_attach", attachTarget.name],
11663
+ ...startupInjectionArgs(tmuxSession, attachStartupCommand(attachTarget.id, process.env[VH_TMUX_SOCKET_ENV]))
11664
+ ]) {
11665
+ try {
11666
+ node_child_process.spawnSync("tmux", tmuxArgs(a), { stdio: "ignore", timeout: TMUX_PROBE_TIMEOUT_MS, env });
11667
+ } catch {
11668
+ }
11669
+ }
11670
+ logger.debug(`[WEB TERMINAL] ${tmuxSession} attaches user tmux session ${attachTarget.id} (${attachTarget.name})`);
11671
+ return { id: attachTarget.id, name: attachTarget.name };
11672
+ }
11673
+ const startup = normalizeStartupCommand(startupCommand);
11674
+ if (startup) {
11675
+ try {
11676
+ for (const a of startupInjectionArgs(tmuxSession, startup)) {
11677
+ node_child_process.spawnSync("tmux", tmuxArgs(a), { stdio: "ignore", timeout: TMUX_PROBE_TIMEOUT_MS, env });
11678
+ }
11679
+ logger.debug(`[WEB TERMINAL] injected startup command into new session ${tmuxSession}`);
11680
+ } catch {
11681
+ }
11682
+ }
11683
+ return void 0;
11684
+ }
11685
+ /**
11686
+ * B-273: check an attach request against the live tmux server. The id
11687
+ * must be a real `$N` (session ids only reset when the tmux server
11688
+ * restarts, so a stale id from an older list simply does not exist)
11689
+ * whose CURRENT name equals the name the web saw — the name cross-check
11690
+ * is what refuses a target that was killed and re-created in between —
11691
+ * and must not be a vh-* web terminal. Throws the 'tmux-session-gone'
11692
+ * contract string. Note `display-message -t '$N'` exits 0 with an EMPTY
11693
+ * line for an unknown id (its target lookup may fail silently), which is
11694
+ * why existence is probed with `has-session` first.
11695
+ */
11696
+ resolveAttachTarget(req) {
11697
+ const id = typeof req?.id === "string" ? req.id : "";
11698
+ const name = typeof req?.name === "string" ? req.name : "";
11699
+ if (!TMUX_SESSION_ID_RE.test(id) || !isSafeTmuxSessionName(name) || isVhSessionName(name)) throw new Error("tmux-session-gone");
11700
+ const env = ptyEnv();
11701
+ const exists = node_child_process.spawnSync("tmux", tmuxArgs(["has-session", "-t", id]), { stdio: "ignore", timeout: TMUX_PROBE_TIMEOUT_MS, env }).status === 0;
11702
+ if (!exists) throw new Error("tmux-session-gone");
11703
+ const r = node_child_process.spawnSync(
11704
+ "tmux",
11705
+ tmuxArgs(["display-message", "-p", "-t", id, "#{session_name}"]),
11706
+ { encoding: "utf8", timeout: TMUX_PROBE_TIMEOUT_MS, env }
11707
+ );
11708
+ const current = r.status === 0 && typeof r.stdout === "string" ? r.stdout.replace(/\r?\n$/, "") : void 0;
11709
+ if (current !== name) throw new Error("tmux-session-gone");
11710
+ return { id, name };
11711
+ }
11712
+ /** B-273: the user's own tmux sessions (never vh-*), newest activity
11713
+ * first. Empty when tmux is missing or no server runs. */
11714
+ /** Last raw outcome of the list-tmux-sessions spawn (diagnostics only). */
11715
+ lastUserSessionsProbe;
11716
+ listUserTmuxSessions(max) {
11717
+ if (!isTmuxAvailable()) {
11718
+ this.lastUserSessionsProbe = { status: null, error: "tmux-unavailable", stdoutBytes: 0 };
11719
+ return [];
11720
+ }
11721
+ try {
11722
+ const r = node_child_process.spawnSync(
11723
+ "tmux",
11724
+ tmuxArgs(["list-sessions", "-F", USER_SESSIONS_FORMAT]),
11725
+ { encoding: "utf8", timeout: TMUX_CREATE_TIMEOUT_MS, env: ptyEnv() }
11726
+ );
11727
+ this.lastUserSessionsProbe = { status: r.status, error: r.error?.message, stderr: (r.stderr || "").toString().trim() || void 0, stdoutBytes: typeof r.stdout === "string" ? r.stdout.length : 0 };
11728
+ if (r.status !== 0 || typeof r.stdout !== "string") {
11729
+ logger.debug(`[WEB TERMINAL] list-tmux-sessions failed: ${JSON.stringify(this.lastUserSessionsProbe)}`);
11730
+ return [];
11731
+ }
11732
+ return parseUserSessions(r.stdout, max);
11733
+ } catch (e) {
11734
+ this.lastUserSessionsProbe = { status: null, error: e instanceof Error ? e.message : String(e), stdoutBytes: 0 };
11735
+ return [];
11525
11736
  }
11526
- return this.linesResponse(id, session, restored);
11527
11737
  }
11528
11738
  /**
11529
11739
  * Run the opening/catch-up capture for a control session and prime the
@@ -11748,7 +11958,7 @@ class WebTerminalManager {
11748
11958
  /** Permanently destroy the terminal: detach the pty AND kill the tmux
11749
11959
  * session (so a local `tmux attach` won't find it either). Used when the
11750
11960
  * user deletes the terminal from the sidebar. */
11751
- killSession(terminalId) {
11961
+ killSession(terminalId, opts) {
11752
11962
  const info = this.lastSeenInfo.get(terminalId) ?? this.listSessions().find((t) => t.id === terminalId);
11753
11963
  const target = `=vh-${terminalId}:`;
11754
11964
  const killed = node_child_process.spawnSync("tmux", tmuxArgs(["kill-session", "-t", target]), { stdio: "ignore", env: ptyEnv() });
@@ -11757,6 +11967,15 @@ class WebTerminalManager {
11757
11967
  logger.debug(`[WEB TERMINAL] failed to verify tmux session removal ${target}`);
11758
11968
  return false;
11759
11969
  }
11970
+ if (opts?.alsoAttached && info?.attachTmux) {
11971
+ const matches = this.listUserTmuxSessions(Infinity).filter((s) => s.name === info.attachTmux);
11972
+ if (matches.length === 1) {
11973
+ node_child_process.spawnSync("tmux", tmuxArgs(["kill-session", "-t", matches[0].id]), { stdio: "ignore", timeout: TMUX_PROBE_TIMEOUT_MS, env: ptyEnv() });
11974
+ logger.info(`[WEB TERMINAL] killed attached user tmux session ${matches[0].id} (${info.attachTmux}) with ${terminalId}`);
11975
+ } else {
11976
+ logger.debug(`[WEB TERMINAL] alsoAttached: no unique session named ${info.attachTmux} (${matches.length} matches)`);
11977
+ }
11978
+ }
11760
11979
  if (info) {
11761
11980
  const mirror = pickMirrorForTerminal(readPersistedSessions(), terminalId);
11762
11981
  this.recordClosed({
@@ -11765,6 +11984,7 @@ class WebTerminalManager {
11765
11984
  cwd: info.cwd,
11766
11985
  tags: info.tags,
11767
11986
  manual: info.manual,
11987
+ ...info.attachTmux ? { attachTmux: info.attachTmux } : {},
11768
11988
  mirrorSessionId: this.mirrorResolver?.(terminalId) ?? mirror?.sessionId,
11769
11989
  claudeSessionId: mirror?.claudeSessionId,
11770
11990
  reason: "closed",
@@ -11853,7 +12073,8 @@ class WebTerminalManager {
11853
12073
  // Daemon-internal (not pushed to web / not in the signature):
11854
12074
  // the mirror reconciler's stricter "claude is really here" gate.
11855
12075
  claudeConfident: probe.claudeConfident,
11856
- manual: s.manual
12076
+ manual: s.manual,
12077
+ ...s.attachTmux ? { attachTmux: s.attachTmux } : {}
11857
12078
  });
11858
12079
  }
11859
12080
  return out;
@@ -11920,18 +12141,19 @@ class WebTerminalManager {
11920
12141
  try {
11921
12142
  const probe = node_child_process.spawnSync(
11922
12143
  "tmux",
11923
- tmuxArgs(["display-message", "-p", "-t", name, "#{pane_in_mode} #{alternate_on} #{mouse_any_flag} #{pane_width} #{pane_height} #{pane_current_command}"]),
12144
+ tmuxArgs(["display-message", "-p", "-t", name, ["#{pane_in_mode}", "#{alternate_on}", "#{mouse_any_flag}", "#{pane_width}", "#{pane_height}", "#{pane_current_command}"].join(LIST_FIELD_SEP)]),
11924
12145
  { encoding: "utf8", timeout: TMUX_PROBE_TIMEOUT_MS, env: ptyEnv() }
11925
12146
  );
11926
12147
  if (probe.status !== 0 || typeof probe.stdout !== "string") return;
11927
- const [inMode, altOn, wantsMouse, paneW, paneH, paneCmd] = probe.stdout.trim().split(" ");
12148
+ const [inMode, altOn, wantsMouse, paneW, paneH, paneCmd] = probe.stdout.trim().split(LIST_FIELD_SEP);
11928
12149
  const action = planScrollAction(
11929
12150
  inMode === "1",
11930
12151
  altOn === "1",
11931
12152
  wantsMouse === "1",
11932
12153
  lines,
11933
12154
  looksLikeClaudeCommand(paneCmd || ""),
11934
- Number(paneH) || 24
12155
+ Number(paneH) || 24,
12156
+ (paneCmd || "").trim() === "tmux"
11935
12157
  );
11936
12158
  if (action.kind === "none") return;
11937
12159
  if (action.kind === "mouse-wheel") {
@@ -13943,7 +14165,8 @@ class ApiMachineClient {
13943
14165
  this.syncResumeSessionRpcRegistration();
13944
14166
  this.syncRestartSessionRpcRegistration();
13945
14167
  this.rpcHandlerManager.registerHandler("open-terminal", async (params) => {
13946
- const { terminalId, cols, rows, cwd, fromSeq, encStream, startupCommand, resub, attachOnly, streamMode } = params || {};
14168
+ const { terminalId, cols, rows, cwd, fromSeq, encStream, startupCommand, resub, attachOnly, streamMode, attachTmux } = params || {};
14169
+ const attach = attachTmux && typeof attachTmux === "object" && typeof attachTmux.id === "string" && typeof attachTmux.name === "string" ? { id: attachTmux.id, name: attachTmux.name } : void 0;
13947
14170
  const result = await this.webTerminal.open({
13948
14171
  terminalId,
13949
14172
  cols,
@@ -13953,7 +14176,8 @@ class ApiMachineClient {
13953
14176
  startupCommand,
13954
14177
  resub,
13955
14178
  attachOnly,
13956
- streamMode: streamMode === "lines" ? "lines" : void 0
14179
+ streamMode: streamMode === "lines" ? "lines" : void 0,
14180
+ ...attach ? { attachTmux: attach } : {}
13957
14181
  });
13958
14182
  if (encStream) {
13959
14183
  this.encTerminals.add(result.terminalId);
@@ -14007,11 +14231,11 @@ class ApiMachineClient {
14007
14231
  return { type: "success" };
14008
14232
  });
14009
14233
  this.rpcHandlerManager.registerHandler("kill-terminal", async (params) => {
14010
- const { terminalId } = params || {};
14234
+ const { terminalId, alsoAttached } = params || {};
14011
14235
  if (typeof terminalId !== "string" || !terminalId) {
14012
14236
  return { type: "error", errorMessage: "Terminal ID is required" };
14013
14237
  }
14014
- if (!this.webTerminal.killSession(terminalId)) {
14238
+ if (!this.webTerminal.killSession(terminalId, { alsoAttached: alsoAttached === true })) {
14015
14239
  return { type: "error", errorMessage: "tmux session is still running" };
14016
14240
  }
14017
14241
  this.encTerminals.delete(terminalId);
@@ -14028,6 +14252,9 @@ class ApiMachineClient {
14028
14252
  this.rpcHandlerManager.registerHandler("list-terminals", async () => {
14029
14253
  return { type: "success", terminals: this.webTerminal.buildTerminalList() };
14030
14254
  });
14255
+ this.rpcHandlerManager.registerHandler("list-tmux-sessions", async () => {
14256
+ return { type: "success", sessions: this.webTerminal.listUserTmuxSessions() };
14257
+ });
14031
14258
  this.rpcHandlerManager.registerHandler("set-terminal-title", async (params) => {
14032
14259
  const { terminalId, title, ifAbsent } = params || {};
14033
14260
  if (!terminalId || typeof title !== "string") {
@@ -14421,6 +14648,8 @@ class ApiMachineClient {
14421
14648
  // B-265 capability flag; restamped every connect so the
14422
14649
  // web's `detectedAt >= startedAt` trust rule holds.
14423
14650
  terminalRestore: { rpcAvailable: true, detectedAt: now },
14651
+ // B-273 capability flag (same restamp discipline).
14652
+ tmuxSessions: { rpcAvailable: true, detectedAt: now, killAttached: true },
14424
14653
  // B-084: closed records survive daemon restarts (persisted
14425
14654
  // in closed-terminals.json), so the connect snapshot ships
14426
14655
  // them too — not just the incremental pushes.
@@ -15830,6 +16059,7 @@ exports.releaseDaemonLock = releaseDaemonLock;
15830
16059
  exports.resolveCliUpdateCheckInterval = resolveCliUpdateCheckInterval;
15831
16060
  exports.scrubTmuxClientEnv = scrubTmuxClientEnv;
15832
16061
  exports.sendToVhTerminal = sendToVhTerminal;
16062
+ exports.shellescape = shellescape;
15833
16063
  exports.startOfflineReconnection = startOfflineReconnection;
15834
16064
  exports.summarizeSpawnSessionForLog = summarizeSpawnSessionForLog;
15835
16065
  exports.tmuxArgs = tmuxArgs;