very-happy-cli 0.2.97 → 0.2.98

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-BXiGiFC7.mjs} +1 -1
  2. package/dist/{AcpBackend-DrwdVSeJ.cjs → AcpBackend-DhR8ml8L.cjs} +1 -1
  3. package/dist/{AcpSessionManager-BoBuHee3.mjs → AcpSessionManager-CDnrxAfQ.mjs} +1 -1
  4. package/dist/{AcpSessionManager-BieE187D.cjs → AcpSessionManager-Ckc0NFmf.cjs} +1 -1
  5. package/dist/{config-DCH33LOM.mjs → config-B3pA9YsN.mjs} +2 -2
  6. package/dist/{config-COq6X7U7.cjs → config-GJQc_kpD.cjs} +2 -2
  7. package/dist/{index-Dn0iOBFd.cjs → index-BBm2b34F.cjs} +4 -4
  8. package/dist/{index-DkhMvofH.mjs → index-Bh5Rcy0s.mjs} +5 -5
  9. package/dist/{index-BD2fHJ8o.cjs → index-CO1gNtuP.cjs} +18 -21
  10. package/dist/{index-Bcgquogx.mjs → index-CuUVSZnK.mjs} +12 -15
  11. package/dist/index.cjs +2 -2
  12. package/dist/index.mjs +2 -2
  13. package/dist/{installTerminalHooks-H3kpFEZS.cjs → installTerminalHooks-Cp9JVxQ-.cjs} +2 -2
  14. package/dist/{installTerminalHooks-DqNtW6a2.mjs → installTerminalHooks-Ru0dn_zF.mjs} +2 -2
  15. package/dist/lib.cjs +1 -1
  16. package/dist/lib.d.cts +10 -0
  17. package/dist/lib.d.mts +10 -0
  18. package/dist/lib.mjs +1 -1
  19. package/dist/{mcp-BwNc5eRs.cjs → mcp-B5HMF24K.cjs} +2 -2
  20. package/dist/{mcp-BKAuzaLX.mjs → mcp-CQoVN0u2.mjs} +2 -2
  21. package/dist/{runGemini-w35qiFBB.cjs → runGemini-BBzPVzuc.cjs} +4 -4
  22. package/dist/{runGemini-Brp0UmLx.mjs → runGemini-CwZXQsgu.mjs} +4 -4
  23. package/dist/{runOpenClaw-CkHtTCpI.cjs → runOpenClaw-B4nC6XGL.cjs} +3 -3
  24. package/dist/{runOpenClaw-BvPRFx-L.mjs → runOpenClaw-C-jz4rWr.mjs} +3 -3
  25. package/dist/{send-tyKG13xt.cjs → send-CWgaINHC.cjs} +2 -2
  26. package/dist/{send-c-wEmh5U.mjs → send-DN-nVBEI.mjs} +2 -2
  27. package/dist/{spawn-xdCsG2RT.cjs → spawn-C0NSP84Y.cjs} +2 -2
  28. package/dist/{spawn-yS6yY8Xi.mjs → spawn-CqcfbOnk.mjs} +2 -2
  29. package/dist/{types-DoGaKVfK.cjs → types-BP-4phS_.cjs} +289 -70
  30. package/dist/{types-xZOYW0_G.mjs → types-Cl4vWuDY.mjs} +288 -70
  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.98";
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.98",
168
+ "very-happy-tools-x64-darwin": "0.2.98",
169
+ "very-happy-tools-arm64-linux": "0.2.98",
170
+ "very-happy-tools-x64-linux": "0.2.98",
171
+ "very-happy-tools-arm64-win32": "0.2.98",
172
+ "very-happy-tools-x64-win32": "0.2.98"
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,16 @@ 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
+ }).optional(),
1114
1128
  /** Relay-owned CLI compatibility/update policy last checked by this daemon. */
1115
1129
  cliUpdate: CliUpdateStateSchema.optional(),
1116
1130
  /**
@@ -1569,7 +1583,7 @@ class RpcHandlerManager {
1569
1583
  }
1570
1584
  }
1571
1585
 
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))));
1586
+ 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-BP-4phS_.cjs', document.baseURI).href))));
1573
1587
  function projectPath() {
1574
1588
  const path = path$1.resolve(__dirname$1, "..");
1575
1589
  return path;
@@ -4812,7 +4826,7 @@ function sanitizeLiveSnapshot(raw, now, ttlMs = LIVE_SNAPSHOT_TTL_MS, max = LIVE
4812
4826
  const rows = [];
4813
4827
  for (const [id, value] of Object.entries(raw)) {
4814
4828
  if (!id || !value || typeof value !== "object") continue;
4815
- const { title, cwd, seenAt, tags, manual } = value;
4829
+ const { title, cwd, seenAt, tags, manual, attachTmux } = value;
4816
4830
  if (typeof seenAt !== "number" || !Number.isFinite(seenAt)) continue;
4817
4831
  if (now - seenAt >= ttlMs) continue;
4818
4832
  const tagList = sanitizeTagList(tags);
@@ -4821,6 +4835,7 @@ function sanitizeLiveSnapshot(raw, now, ttlMs = LIVE_SNAPSHOT_TTL_MS, max = LIVE
4821
4835
  cwd: typeof cwd === "string" && cwd ? cwd : void 0,
4822
4836
  ...tagList !== void 0 ? { tags: tagList } : {},
4823
4837
  ...manual === true ? { manual: true } : {},
4838
+ ...typeof attachTmux === "string" && attachTmux ? { attachTmux } : {},
4824
4839
  seenAt
4825
4840
  }]);
4826
4841
  }
@@ -4841,6 +4856,7 @@ function liveSnapshotChanged(prev, next) {
4841
4856
  if (!before) return true;
4842
4857
  if (before.title !== info.title || before.cwd !== info.cwd) return true;
4843
4858
  if (!!before.manual !== !!info.manual) return true;
4859
+ if ((before.attachTmux ?? "") !== (info.attachTmux ?? "")) return true;
4844
4860
  if ((before.tags ?? []).join("\0") !== (info.tags ?? []).join("\0")) return true;
4845
4861
  }
4846
4862
  return false;
@@ -4866,9 +4882,89 @@ function pickMirrorForTerminal(sessions, terminalId) {
4866
4882
  return best ? { sessionId: best.id, claudeSessionId: best.claude } : null;
4867
4883
  }
4868
4884
 
4885
+ function shellescape(s) {
4886
+ return "'" + s.replace(/'/g, "'\\''") + "'";
4887
+ }
4888
+
4889
+ const USER_SESSION_FIELD_SEP = "<~|~>";
4890
+ const USER_SESSIONS_FORMAT = [
4891
+ "#{session_id}",
4892
+ "#{session_name}",
4893
+ "#{session_windows}",
4894
+ "#{session_attached}",
4895
+ "#{session_activity}",
4896
+ "#{session_created}",
4897
+ "#{pane_current_command}",
4898
+ "#{pane_current_path}"
4899
+ ].join(USER_SESSION_FIELD_SEP);
4900
+ const TMUX_SESSION_ID_RE = /^\$\d{1,9}$/;
4901
+ const TMUX_SESSION_NAME_MAX = 128;
4902
+ function isSafeTmuxSessionName(name) {
4903
+ if (typeof name !== "string") return false;
4904
+ if (name.length === 0 || name.length > TMUX_SESSION_NAME_MAX) return false;
4905
+ return !/[\x00-\x1f\x7f]/.test(name);
4906
+ }
4907
+ function isVhSessionName(name) {
4908
+ return name.startsWith("vh-");
4909
+ }
4910
+ function parseUserSessionLine(line) {
4911
+ if (!line) return void 0;
4912
+ const parts = line.split(USER_SESSION_FIELD_SEP);
4913
+ if (parts.length < 8) return void 0;
4914
+ const [id, name, windows, attached, activity, created, command] = parts;
4915
+ if (!TMUX_SESSION_ID_RE.test(id) || !isSafeTmuxSessionName(name)) return void 0;
4916
+ const cwd = parts.slice(7).join(USER_SESSION_FIELD_SEP);
4917
+ const num = (v) => {
4918
+ const n = Number(v);
4919
+ return Number.isFinite(n) ? n : void 0;
4920
+ };
4921
+ return {
4922
+ id,
4923
+ name,
4924
+ windows: Math.max(0, Math.floor(num(windows) ?? 0)),
4925
+ attached: (num(attached) ?? 0) > 0,
4926
+ activityAt: activity ? (num(activity) ?? 0) * 1e3 || void 0 : void 0,
4927
+ createdAt: created ? (num(created) ?? 0) * 1e3 || void 0 : void 0,
4928
+ command: command.trim() || void 0,
4929
+ cwd: cwd || void 0
4930
+ };
4931
+ }
4932
+ const USER_SESSIONS_MAX = 50;
4933
+ function parseUserSessions(stdout, max = USER_SESSIONS_MAX) {
4934
+ const out = [];
4935
+ for (const line of stdout.split("\n")) {
4936
+ const s = parseUserSessionLine(line);
4937
+ if (!s || isVhSessionName(s.name)) continue;
4938
+ out.push(s);
4939
+ }
4940
+ out.sort((a, b) => (b.activityAt ?? 0) - (a.activityAt ?? 0));
4941
+ return out.slice(0, max);
4942
+ }
4943
+ function attachStartupCommand(sessionId, socket) {
4944
+ if (!TMUX_SESSION_ID_RE.test(sessionId)) throw new Error(`not a tmux session id: ${sessionId}`);
4945
+ const sock = socket ? ` -S ${shellescape(socket)}` : "";
4946
+ return ` TMUX= tmux${sock} attach-session -t ${shellescape(sessionId)}`;
4947
+ }
4948
+
4869
4949
  const TERMINAL_ID_RE$1 = /^[a-zA-Z0-9_-]{1,64}$/;
4870
4950
  function planTerminalRestore(record, facts) {
4871
4951
  if (facts.tmuxAlive) return { kind: "already-live" };
4952
+ if (record.attachTmux) {
4953
+ const matches = (facts.userSessions ?? []).filter((s) => s.name === record.attachTmux);
4954
+ if (matches.length !== 1) return { kind: "error", reason: "tmux-session-gone" };
4955
+ const cwd = record.cwd && facts.cwdExists(record.cwd) ? record.cwd : facts.homeDir;
4956
+ if (!cwd) return { kind: "error", reason: "missing-cwd" };
4957
+ return {
4958
+ kind: "create",
4959
+ terminalId: record.id,
4960
+ cwd,
4961
+ title: record.title ?? record.attachTmux,
4962
+ manual: true,
4963
+ tags: record.tags,
4964
+ command: attachStartupCommand(matches[0].id, facts.attachSocket),
4965
+ attachTmux: record.attachTmux
4966
+ };
4967
+ }
4872
4968
  if (!record.cwd || !facts.cwdExists(record.cwd)) return { kind: "error", reason: "missing-cwd" };
4873
4969
  const resumable = isClaudeSessionId(record.claudeSessionId) && facts.conversationExists(record.cwd, record.claudeSessionId);
4874
4970
  return {
@@ -4882,6 +4978,20 @@ function planTerminalRestore(record, facts) {
4882
4978
  };
4883
4979
  }
4884
4980
 
4981
+ const VH_TMUX_SOCKET_ENV = "VH_TMUX_SOCKET";
4982
+ function tmuxSocketFlags(env = process.env) {
4983
+ const socket = env[VH_TMUX_SOCKET_ENV];
4984
+ return socket ? ["-S", socket] : [];
4985
+ }
4986
+ function tmuxArgs(args, env = process.env) {
4987
+ return [...tmuxSocketFlags(env), ...args];
4988
+ }
4989
+ function scrubTmuxClientEnv(env) {
4990
+ delete env.TMUX;
4991
+ delete env.TMUX_PANE;
4992
+ return env;
4993
+ }
4994
+
4885
4995
  function getProjectPath(workingDirectory) {
4886
4996
  const projectId = path.resolve(workingDirectory).replace(/[^a-zA-Z0-9-]/g, "-");
4887
4997
  const claudeConfigDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude");
@@ -9215,20 +9325,6 @@ function previewAscii(line) {
9215
9325
  return line.length > 80 ? `${out}\u2026` : out;
9216
9326
  }
9217
9327
 
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
9328
  const CONTROL_CLIENT_KILL_GRACE_MS = 2e3;
9233
9329
  const CONTROL_COMMAND_TIMEOUT_MS = 1e4;
9234
9330
  function isSafeControlCommand(command) {
@@ -10046,7 +10142,7 @@ function sanitizeClosedTerminals(raw, max = CLOSED_TERMINALS_MAX) {
10046
10142
  const out = [];
10047
10143
  for (const item of raw) {
10048
10144
  if (!item || typeof item !== "object") continue;
10049
- const { id, title, cwd, mirrorSessionId, claudeSessionId, reason, closedAt, tags, manual } = item;
10145
+ const { id, title, cwd, mirrorSessionId, claudeSessionId, reason, closedAt, tags, manual, attachTmux } = item;
10050
10146
  if (typeof id !== "string" || id.length === 0 || typeof closedAt !== "number") continue;
10051
10147
  if (seen.has(id)) continue;
10052
10148
  seen.add(id);
@@ -10060,6 +10156,7 @@ function sanitizeClosedTerminals(raw, max = CLOSED_TERMINALS_MAX) {
10060
10156
  reason: reason === "daemon-gap" || reason === "closed" ? reason : void 0,
10061
10157
  ...tagList !== void 0 ? { tags: tagList } : {},
10062
10158
  ...manual === true ? { manual: true } : {},
10159
+ ...typeof attachTmux === "string" && attachTmux ? { attachTmux } : {},
10063
10160
  closedAt
10064
10161
  });
10065
10162
  }
@@ -10184,7 +10281,7 @@ function startupInjectionArgs(tmuxSession, command) {
10184
10281
  ];
10185
10282
  }
10186
10283
  function seenInfoOf(t) {
10187
- return { title: t.title, cwd: t.cwd, tags: t.tags, manual: t.manual };
10284
+ return { title: t.title, cwd: t.cwd, tags: t.tags, manual: t.manual, attachTmux: t.attachTmux };
10188
10285
  }
10189
10286
  function terminalListSignature(items) {
10190
10287
  const canon = [...items].sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0).map((t) => [
@@ -10197,7 +10294,8 @@ function terminalListSignature(items) {
10197
10294
  t.agentState ?? "",
10198
10295
  // B-105: a mirror binding appearing/disappearing MUST push the
10199
10296
  // list, or the web never learns the toggle became available.
10200
- t.mirrorSessionId ?? ""
10297
+ t.mirrorSessionId ?? "",
10298
+ t.attachTmux ?? ""
10201
10299
  ]);
10202
10300
  return JSON.stringify(canon);
10203
10301
  }
@@ -10216,7 +10314,7 @@ function deriveAutoTitle(paneTitle, hostname) {
10216
10314
  const chars = Array.from(t);
10217
10315
  return chars.length > TITLE_MAX_CHARS ? chars.slice(0, TITLE_MAX_CHARS).join("") : t;
10218
10316
  }
10219
- const LIST_FIELD_SEP = "";
10317
+ const LIST_FIELD_SEP = "<~|~>";
10220
10318
  const LIST_SESSIONS_FORMAT = [
10221
10319
  "#{session_name}",
10222
10320
  "#{session_created}",
@@ -10225,6 +10323,9 @@ const LIST_SESSIONS_FORMAT = [
10225
10323
  "#{@vh_title}",
10226
10324
  "#{@vh_title_manual}",
10227
10325
  "#{@vh_tags}",
10326
+ // B-273: the user tmux session this terminal was opened to attach (name),
10327
+ // carried into close records so a restore can re-attach.
10328
+ "#{@vh_attach}",
10228
10329
  // B-121: the control-mode client has no `pty.process`, so the agent-state
10229
10330
  // fast path lost its live `#{pane_current_command}` equivalent. Carry it in
10230
10331
  // the ONE list-sessions call the tracker already makes — the value goes
@@ -10239,8 +10340,8 @@ const LIST_SESSIONS_FORMAT = [
10239
10340
  function parseSessionListLine(line) {
10240
10341
  if (!line) return void 0;
10241
10342
  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;
10343
+ if (parts.length < 10) return void 0;
10344
+ const [name, created, activity, cwd, vhTitle, manual, vhTags, vhAttach, paneCommand] = parts;
10244
10345
  if (!name) return void 0;
10245
10346
  return {
10246
10347
  name,
@@ -10251,9 +10352,12 @@ function parseSessionListLine(line) {
10251
10352
  manual: manual.trim().length > 0,
10252
10353
  tags: parseTerminalTags(vhTags),
10253
10354
  paneCurrentCommand: paneCommand.trim() || void 0,
10254
- // pane_title is last, so anything after field 8 is title content that
10355
+ // Verbatim (no trim): tmux allows edge spaces in a session name and the
10356
+ // restore lookup / attach echo compare it exactly.
10357
+ attachTmux: isSafeTmuxSessionName(vhAttach) ? vhAttach : void 0,
10358
+ // pane_title is last, so anything after field 9 is title content that
10255
10359
  // contained the separator — rejoin it rather than dropping it.
10256
- paneTitle: parts.slice(8).join(LIST_FIELD_SEP) || void 0
10360
+ paneTitle: parts.slice(9).join(LIST_FIELD_SEP) || void 0
10257
10361
  };
10258
10362
  }
10259
10363
  const TERMINAL_TAG_MAX_COUNT = 64;
@@ -10310,12 +10414,12 @@ function isClaudeConfident(tail) {
10310
10414
  const last15 = lines.slice(-15).join("\n");
10311
10415
  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
10416
  }
10313
- function planScrollAction(paneInMode, alternateOn, paneWantsMouse, lines, claudeLike = false, paneRows = 24) {
10417
+ function planScrollAction(paneInMode, alternateOn, paneWantsMouse, lines, claudeLike = false, paneRows = 24, nestedTmux = false) {
10314
10418
  const count = Math.min(Math.abs(Math.trunc(lines)), 200);
10315
10419
  if (count === 0) return { kind: "none" };
10316
10420
  const up = lines > 0;
10317
10421
  if (paneInMode) return { kind: "copy-scroll", dir: up ? "up" : "down", count };
10318
- if (alternateOn && paneWantsMouse) return { kind: "mouse-wheel", dir: up ? "up" : "down", count };
10422
+ if (alternateOn && (paneWantsMouse || nestedTmux)) return { kind: "mouse-wheel", dir: up ? "up" : "down", count };
10319
10423
  if (alternateOn && claudeLike) {
10320
10424
  const halfPage = Math.max(1, Math.floor(paneRows / 2));
10321
10425
  const pages = Math.max(1, Math.round(count / halfPage));
@@ -10430,6 +10534,9 @@ class TerminalSession {
10430
10534
  id;
10431
10535
  tmuxSession;
10432
10536
  transport;
10537
+ /** B-273: the user tmux session this terminal was created to attach —
10538
+ * echoed to any later open of the same live session. */
10539
+ attachedTmux;
10433
10540
  /**
10434
10541
  * The pane this terminal follows, latched from the open capture's
10435
10542
  * `list-panes` (first pane of the window). `%output` from any OTHER pane is
@@ -10940,6 +11047,7 @@ class WebTerminalManager {
10940
11047
  cwd: info.cwd,
10941
11048
  tags: info.tags,
10942
11049
  manual: info.manual,
11050
+ ...info.attachTmux ? { attachTmux: info.attachTmux } : {},
10943
11051
  // Live binding first (authoritative while it exists), persisted
10944
11052
  // metadata as the fallback once the mirror has been torn down.
10945
11053
  mirrorSessionId: this.mirrorResolver?.(id) ?? mirror?.sessionId,
@@ -10992,6 +11100,7 @@ class WebTerminalManager {
10992
11100
  cwd: info.cwd,
10993
11101
  tags: info.tags,
10994
11102
  manual: info.manual,
11103
+ ...info.attachTmux ? { attachTmux: info.attachTmux } : {},
10995
11104
  mirrorSessionId: mirror?.sessionId,
10996
11105
  claudeSessionId: mirror?.claudeSessionId,
10997
11106
  reason: "daemon-gap",
@@ -11111,12 +11220,17 @@ class WebTerminalManager {
11111
11220
  return false;
11112
11221
  }
11113
11222
  },
11114
- conversationExists: (cwd, claudeSessionId) => node_fs.existsSync(path.join(getProjectPath(cwd), `${claudeSessionId}.jsonl`))
11223
+ conversationExists: (cwd, claudeSessionId) => node_fs.existsSync(path.join(getProjectPath(cwd), `${claudeSessionId}.jsonl`)),
11224
+ // B-273: only consulted for records that were attach terminals.
11225
+ // Unbounded: the panel's 50-row cap must not hide a restore target.
11226
+ userSessions: record.attachTmux ? this.listUserTmuxSessions(Infinity) : [],
11227
+ attachSocket: process.env[VH_TMUX_SOCKET_ENV],
11228
+ homeDir: os.homedir()
11115
11229
  });
11116
11230
  if (plan.kind === "already-live") return { type: "success", terminalId };
11117
11231
  if (plan.kind === "error") return { type: "error", reason: plan.reason };
11118
11232
  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)" : ""}`);
11233
+ logger.info(`[WEB TERMINAL] restored closed terminal ${terminalId} in ${plan.cwd}${plan.attachTmux ? ` (attached tmux session ${plan.attachTmux})` : plan.command ? " (conversation resumed)" : ""}`);
11120
11234
  this.kickListRefresh();
11121
11235
  return { type: "success", terminalId };
11122
11236
  }
@@ -11176,6 +11290,13 @@ class WebTerminalManager {
11176
11290
  { stdio: "ignore", timeout: TMUX_PROBE_TIMEOUT_MS, env }
11177
11291
  );
11178
11292
  }
11293
+ if (plan.attachTmux) {
11294
+ node_child_process.spawnSync(
11295
+ "tmux",
11296
+ tmuxArgs(["set-option", "-t", `=${name}:`, "@vh_attach", plan.attachTmux]),
11297
+ { stdio: "ignore", timeout: TMUX_PROBE_TIMEOUT_MS, env }
11298
+ );
11299
+ }
11179
11300
  if (plan.command) {
11180
11301
  for (const argv of startupInjectionArgs(name, plan.command)) {
11181
11302
  node_child_process.spawnSync("tmux", tmuxArgs(argv), { stdio: "ignore", timeout: TMUX_PROBE_TIMEOUT_MS, env });
@@ -11188,7 +11309,7 @@ class WebTerminalManager {
11188
11309
  * would care about changed (see liveSnapshotChanged). */
11189
11310
  persistLiveSnapshot(live, now) {
11190
11311
  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 });
11312
+ 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
11313
  if (!liveSnapshotChanged(this.lastPersistedSnapshot, candidate)) return;
11193
11314
  this.lastPersistedSnapshot = candidate;
11194
11315
  saveLiveSnapshot(candidate);
@@ -11286,6 +11407,7 @@ class WebTerminalManager {
11286
11407
  const lines = opts.streamMode === "lines";
11287
11408
  const existing = this.terminals.get(id);
11288
11409
  if (existing) {
11410
+ const echo2 = opts.attachTmux && !(opts.attachOnly || opts.resub) && existing.attachedTmux?.name === opts.attachTmux.name ? { attachedTmux: existing.attachedTmux } : {};
11289
11411
  if (!opts.resub || existing.subscribers === 0) existing.subscribers += 1;
11290
11412
  existing.lastTouch = Date.now();
11291
11413
  this.applyResize(existing, cols, rows);
@@ -11298,15 +11420,16 @@ class WebTerminalManager {
11298
11420
  seq: existing.seq,
11299
11421
  mode: "replay",
11300
11422
  chunks,
11301
- ...lines ? { streamMode: "lines" } : {}
11423
+ ...lines ? { streamMode: "lines" } : {},
11424
+ ...echo2
11302
11425
  };
11303
11426
  }
11304
11427
  if (lines && existing.transport.kind === "control") {
11305
- return await this.linesSnapshotResponse(id, existing);
11428
+ return { ...await this.linesSnapshotResponse(id, existing), ...echo2 };
11306
11429
  }
11307
11430
  const state = existing.subscribeState(opts.fromSeq);
11308
11431
  logger.debug(`[WEB TERMINAL] re-subscribed ${id} (subs=${existing.subscribers}, mode=${state.mode}, seq=${state.seq})`);
11309
- return { terminalId: id, tmuxSession: existing.tmuxSession, ...state };
11432
+ return { terminalId: id, tmuxSession: existing.tmuxSession, ...state, ...echo2 };
11310
11433
  }
11311
11434
  if (opts.attachOnly || opts.resub) {
11312
11435
  const alive = isTmuxAvailable() && node_child_process.spawnSync(
@@ -11331,6 +11454,10 @@ class WebTerminalManager {
11331
11454
  let file = defaultShell();
11332
11455
  let args = [];
11333
11456
  let tmuxSession;
11457
+ let attachedTmux;
11458
+ if (opts.attachTmux && !(opts.attachOnly || opts.resub) && !isTmuxAvailable()) {
11459
+ throw new Error("tmux-unavailable");
11460
+ }
11334
11461
  if (isTmuxAvailable()) {
11335
11462
  tmuxSession = `vh-${id}`;
11336
11463
  let createdNew = false;
@@ -11343,6 +11470,7 @@ class WebTerminalManager {
11343
11470
  `VH_HAPPY_HOME_DIR=${configuration.happyHomeDir}`
11344
11471
  ] : [];
11345
11472
  const attachOnly = !!(opts.attachOnly || opts.resub);
11473
+ const attachTarget = !attachOnly && opts.attachTmux ? this.resolveAttachTarget(opts.attachTmux) : void 0;
11346
11474
  if (!attachOnly) {
11347
11475
  try {
11348
11476
  const created = node_child_process.spawnSync(
@@ -11384,16 +11512,15 @@ class WebTerminalManager {
11384
11512
  }
11385
11513
  }
11386
11514
  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
- }
11515
+ attachedTmux = this.injectIntoCreated(tmuxSession, attachTarget, opts.startupCommand, env);
11516
+ } else if (opts.attachTmux && !attachOnly) {
11517
+ const stored = node_child_process.spawnSync(
11518
+ "tmux",
11519
+ tmuxArgs(["show-options", "-qv", "-t", `=${tmuxSession}:`, "@vh_attach"]),
11520
+ { encoding: "utf8", timeout: TMUX_PROBE_TIMEOUT_MS, env }
11521
+ );
11522
+ const name = stored.status === 0 && typeof stored.stdout === "string" ? stored.stdout.replace(/\r?\n$/, "") : "";
11523
+ if (name && name === opts.attachTmux.name) attachedTmux = { id: opts.attachTmux.id, name };
11397
11524
  }
11398
11525
  if (!attachOnly && !createdNew) {
11399
11526
  const alive = node_child_process.spawnSync(
@@ -11408,17 +11535,7 @@ class WebTerminalManager {
11408
11535
  { stdio: "ignore", timeout: TMUX_CREATE_TIMEOUT_MS, env }
11409
11536
  );
11410
11537
  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
- }
11538
+ if (createdNew) attachedTmux = this.injectIntoCreated(tmuxSession, attachTarget, opts.startupCommand, env);
11422
11539
  }
11423
11540
  }
11424
11541
  if (!attachOnly && !createdNew) {
@@ -11428,6 +11545,7 @@ class WebTerminalManager {
11428
11545
  { stdio: "ignore", timeout: TMUX_PROBE_TIMEOUT_MS, env }
11429
11546
  ).status === 0;
11430
11547
  if (!alive) {
11548
+ if (attachTarget) throw new Error("tmux-unavailable");
11431
11549
  logger.info(`[WEB TERMINAL] tmux session creation unavailable; using direct PTY for ${id}`);
11432
11550
  tmuxSession = void 0;
11433
11551
  }
@@ -11510,20 +11628,110 @@ class WebTerminalManager {
11510
11628
  session = created;
11511
11629
  }
11512
11630
  session.subscribers = 1;
11631
+ if (attachedTmux) session.attachedTmux = attachedTmux;
11513
11632
  this.terminals.set(id, session);
11514
11633
  session.onTitleChange(() => this.kickListRefresh());
11515
11634
  logger.debug(`[WEB TERMINAL] opened ${id} (${tmuxSession ? "control" : "pty"}) ${cols}x${rows} cwd=${cwd}`);
11516
11635
  this.kickListRefresh();
11636
+ const echo = attachedTmux ? { attachedTmux } : {};
11517
11637
  if (session.transport.kind !== "control") {
11518
11638
  const state = session.subscribeState(void 0);
11519
- return { terminalId: id, tmuxSession, ...state, ...lines ? { streamMode: "lines" } : {} };
11639
+ return { terminalId: id, tmuxSession, ...state, ...lines ? { streamMode: "lines" } : {}, ...echo };
11520
11640
  }
11521
11641
  const restored = await this.captureRestore(session, true);
11522
11642
  if (!lines) {
11523
11643
  const state = session.subscribeState(void 0);
11524
- return { terminalId: id, tmuxSession, ...state };
11644
+ return { terminalId: id, tmuxSession, ...state, ...echo };
11645
+ }
11646
+ return { ...this.linesResponse(id, session, restored), ...echo };
11647
+ }
11648
+ /**
11649
+ * Post-create injection shared by BOTH creation sites in open(): an attach
11650
+ * request (B-273) pins the title to the user session's name (the inner
11651
+ * tmux's pane title is not reliable), stores `@vh_attach` for close
11652
+ * records / restore and types the attach line; otherwise the ordinary
11653
+ * startup command runs. Returns what to echo as `attachedTmux`.
11654
+ */
11655
+ injectIntoCreated(tmuxSession, attachTarget, startupCommand, env) {
11656
+ if (attachTarget) {
11657
+ for (const a of [
11658
+ ["set-option", "-t", `=${tmuxSession}:`, "@vh_title", attachTarget.name],
11659
+ ["set-option", "-t", `=${tmuxSession}:`, "@vh_title_manual", "1"],
11660
+ ["set-option", "-t", `=${tmuxSession}:`, "@vh_attach", attachTarget.name],
11661
+ ...startupInjectionArgs(tmuxSession, attachStartupCommand(attachTarget.id, process.env[VH_TMUX_SOCKET_ENV]))
11662
+ ]) {
11663
+ try {
11664
+ node_child_process.spawnSync("tmux", tmuxArgs(a), { stdio: "ignore", timeout: TMUX_PROBE_TIMEOUT_MS, env });
11665
+ } catch {
11666
+ }
11667
+ }
11668
+ logger.debug(`[WEB TERMINAL] ${tmuxSession} attaches user tmux session ${attachTarget.id} (${attachTarget.name})`);
11669
+ return { id: attachTarget.id, name: attachTarget.name };
11670
+ }
11671
+ const startup = normalizeStartupCommand(startupCommand);
11672
+ if (startup) {
11673
+ try {
11674
+ for (const a of startupInjectionArgs(tmuxSession, startup)) {
11675
+ node_child_process.spawnSync("tmux", tmuxArgs(a), { stdio: "ignore", timeout: TMUX_PROBE_TIMEOUT_MS, env });
11676
+ }
11677
+ logger.debug(`[WEB TERMINAL] injected startup command into new session ${tmuxSession}`);
11678
+ } catch {
11679
+ }
11680
+ }
11681
+ return void 0;
11682
+ }
11683
+ /**
11684
+ * B-273: check an attach request against the live tmux server. The id
11685
+ * must be a real `$N` (session ids only reset when the tmux server
11686
+ * restarts, so a stale id from an older list simply does not exist)
11687
+ * whose CURRENT name equals the name the web saw — the name cross-check
11688
+ * is what refuses a target that was killed and re-created in between —
11689
+ * and must not be a vh-* web terminal. Throws the 'tmux-session-gone'
11690
+ * contract string. Note `display-message -t '$N'` exits 0 with an EMPTY
11691
+ * line for an unknown id (its target lookup may fail silently), which is
11692
+ * why existence is probed with `has-session` first.
11693
+ */
11694
+ resolveAttachTarget(req) {
11695
+ const id = typeof req?.id === "string" ? req.id : "";
11696
+ const name = typeof req?.name === "string" ? req.name : "";
11697
+ if (!TMUX_SESSION_ID_RE.test(id) || !isSafeTmuxSessionName(name) || isVhSessionName(name)) throw new Error("tmux-session-gone");
11698
+ const env = ptyEnv();
11699
+ const exists = node_child_process.spawnSync("tmux", tmuxArgs(["has-session", "-t", id]), { stdio: "ignore", timeout: TMUX_PROBE_TIMEOUT_MS, env }).status === 0;
11700
+ if (!exists) throw new Error("tmux-session-gone");
11701
+ const r = node_child_process.spawnSync(
11702
+ "tmux",
11703
+ tmuxArgs(["display-message", "-p", "-t", id, "#{session_name}"]),
11704
+ { encoding: "utf8", timeout: TMUX_PROBE_TIMEOUT_MS, env }
11705
+ );
11706
+ const current = r.status === 0 && typeof r.stdout === "string" ? r.stdout.replace(/\r?\n$/, "") : void 0;
11707
+ if (current !== name) throw new Error("tmux-session-gone");
11708
+ return { id, name };
11709
+ }
11710
+ /** B-273: the user's own tmux sessions (never vh-*), newest activity
11711
+ * first. Empty when tmux is missing or no server runs. */
11712
+ /** Last raw outcome of the list-tmux-sessions spawn (diagnostics only). */
11713
+ lastUserSessionsProbe;
11714
+ listUserTmuxSessions(max) {
11715
+ if (!isTmuxAvailable()) {
11716
+ this.lastUserSessionsProbe = { status: null, error: "tmux-unavailable", stdoutBytes: 0 };
11717
+ return [];
11718
+ }
11719
+ try {
11720
+ const r = node_child_process.spawnSync(
11721
+ "tmux",
11722
+ tmuxArgs(["list-sessions", "-F", USER_SESSIONS_FORMAT]),
11723
+ { encoding: "utf8", timeout: TMUX_CREATE_TIMEOUT_MS, env: ptyEnv() }
11724
+ );
11725
+ 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 };
11726
+ if (r.status !== 0 || typeof r.stdout !== "string") {
11727
+ logger.debug(`[WEB TERMINAL] list-tmux-sessions failed: ${JSON.stringify(this.lastUserSessionsProbe)}`);
11728
+ return [];
11729
+ }
11730
+ return parseUserSessions(r.stdout, max);
11731
+ } catch (e) {
11732
+ this.lastUserSessionsProbe = { status: null, error: e instanceof Error ? e.message : String(e), stdoutBytes: 0 };
11733
+ return [];
11525
11734
  }
11526
- return this.linesResponse(id, session, restored);
11527
11735
  }
11528
11736
  /**
11529
11737
  * Run the opening/catch-up capture for a control session and prime the
@@ -11765,6 +11973,7 @@ class WebTerminalManager {
11765
11973
  cwd: info.cwd,
11766
11974
  tags: info.tags,
11767
11975
  manual: info.manual,
11976
+ ...info.attachTmux ? { attachTmux: info.attachTmux } : {},
11768
11977
  mirrorSessionId: this.mirrorResolver?.(terminalId) ?? mirror?.sessionId,
11769
11978
  claudeSessionId: mirror?.claudeSessionId,
11770
11979
  reason: "closed",
@@ -11853,7 +12062,8 @@ class WebTerminalManager {
11853
12062
  // Daemon-internal (not pushed to web / not in the signature):
11854
12063
  // the mirror reconciler's stricter "claude is really here" gate.
11855
12064
  claudeConfident: probe.claudeConfident,
11856
- manual: s.manual
12065
+ manual: s.manual,
12066
+ ...s.attachTmux ? { attachTmux: s.attachTmux } : {}
11857
12067
  });
11858
12068
  }
11859
12069
  return out;
@@ -11920,18 +12130,19 @@ class WebTerminalManager {
11920
12130
  try {
11921
12131
  const probe = node_child_process.spawnSync(
11922
12132
  "tmux",
11923
- tmuxArgs(["display-message", "-p", "-t", name, "#{pane_in_mode} #{alternate_on} #{mouse_any_flag} #{pane_width} #{pane_height} #{pane_current_command}"]),
12133
+ 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
12134
  { encoding: "utf8", timeout: TMUX_PROBE_TIMEOUT_MS, env: ptyEnv() }
11925
12135
  );
11926
12136
  if (probe.status !== 0 || typeof probe.stdout !== "string") return;
11927
- const [inMode, altOn, wantsMouse, paneW, paneH, paneCmd] = probe.stdout.trim().split(" ");
12137
+ const [inMode, altOn, wantsMouse, paneW, paneH, paneCmd] = probe.stdout.trim().split(LIST_FIELD_SEP);
11928
12138
  const action = planScrollAction(
11929
12139
  inMode === "1",
11930
12140
  altOn === "1",
11931
12141
  wantsMouse === "1",
11932
12142
  lines,
11933
12143
  looksLikeClaudeCommand(paneCmd || ""),
11934
- Number(paneH) || 24
12144
+ Number(paneH) || 24,
12145
+ (paneCmd || "").trim() === "tmux"
11935
12146
  );
11936
12147
  if (action.kind === "none") return;
11937
12148
  if (action.kind === "mouse-wheel") {
@@ -13943,7 +14154,8 @@ class ApiMachineClient {
13943
14154
  this.syncResumeSessionRpcRegistration();
13944
14155
  this.syncRestartSessionRpcRegistration();
13945
14156
  this.rpcHandlerManager.registerHandler("open-terminal", async (params) => {
13946
- const { terminalId, cols, rows, cwd, fromSeq, encStream, startupCommand, resub, attachOnly, streamMode } = params || {};
14157
+ const { terminalId, cols, rows, cwd, fromSeq, encStream, startupCommand, resub, attachOnly, streamMode, attachTmux } = params || {};
14158
+ const attach = attachTmux && typeof attachTmux === "object" && typeof attachTmux.id === "string" && typeof attachTmux.name === "string" ? { id: attachTmux.id, name: attachTmux.name } : void 0;
13947
14159
  const result = await this.webTerminal.open({
13948
14160
  terminalId,
13949
14161
  cols,
@@ -13953,7 +14165,8 @@ class ApiMachineClient {
13953
14165
  startupCommand,
13954
14166
  resub,
13955
14167
  attachOnly,
13956
- streamMode: streamMode === "lines" ? "lines" : void 0
14168
+ streamMode: streamMode === "lines" ? "lines" : void 0,
14169
+ ...attach ? { attachTmux: attach } : {}
13957
14170
  });
13958
14171
  if (encStream) {
13959
14172
  this.encTerminals.add(result.terminalId);
@@ -14028,6 +14241,9 @@ class ApiMachineClient {
14028
14241
  this.rpcHandlerManager.registerHandler("list-terminals", async () => {
14029
14242
  return { type: "success", terminals: this.webTerminal.buildTerminalList() };
14030
14243
  });
14244
+ this.rpcHandlerManager.registerHandler("list-tmux-sessions", async () => {
14245
+ return { type: "success", sessions: this.webTerminal.listUserTmuxSessions() };
14246
+ });
14031
14247
  this.rpcHandlerManager.registerHandler("set-terminal-title", async (params) => {
14032
14248
  const { terminalId, title, ifAbsent } = params || {};
14033
14249
  if (!terminalId || typeof title !== "string") {
@@ -14421,6 +14637,8 @@ class ApiMachineClient {
14421
14637
  // B-265 capability flag; restamped every connect so the
14422
14638
  // web's `detectedAt >= startedAt` trust rule holds.
14423
14639
  terminalRestore: { rpcAvailable: true, detectedAt: now },
14640
+ // B-273 capability flag (same restamp discipline).
14641
+ tmuxSessions: { rpcAvailable: true, detectedAt: now },
14424
14642
  // B-084: closed records survive daemon restarts (persisted
14425
14643
  // in closed-terminals.json), so the connect snapshot ships
14426
14644
  // them too — not just the incremental pushes.
@@ -15830,6 +16048,7 @@ exports.releaseDaemonLock = releaseDaemonLock;
15830
16048
  exports.resolveCliUpdateCheckInterval = resolveCliUpdateCheckInterval;
15831
16049
  exports.scrubTmuxClientEnv = scrubTmuxClientEnv;
15832
16050
  exports.sendToVhTerminal = sendToVhTerminal;
16051
+ exports.shellescape = shellescape;
15833
16052
  exports.startOfflineReconnection = startOfflineReconnection;
15834
16053
  exports.summarizeSpawnSessionForLog = summarizeSpawnSessionForLog;
15835
16054
  exports.tmuxArgs = tmuxArgs;