very-happy-cli 0.2.146 → 0.2.148

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 (51) hide show
  1. package/dist/{AcpBackend-D4J-b7TB.cjs → AcpBackend-BrM-mcjR.cjs} +2 -2
  2. package/dist/{AcpBackend--1XmtY13.mjs → AcpBackend-CrcdMnL6.mjs} +2 -2
  3. package/dist/{AcpSessionManager-BUh-XYb7.cjs → AcpSessionManager-CKhv-rBo.cjs} +1 -1
  4. package/dist/{AcpSessionManager-BXHAABP5.mjs → AcpSessionManager-DIafxIVU.mjs} +1 -1
  5. package/dist/{agentGuidance-BL1SZ7I3.mjs → agentGuidance-CR7rOQUF.mjs} +1 -1
  6. package/dist/{agentGuidance-BI4l2HRo.cjs → agentGuidance-gzl2qRkG.cjs} +1 -1
  7. package/dist/{agentHome-BdFP00V7.mjs → agentHome-CQrd95jc.mjs} +2 -2
  8. package/dist/{agentHome-Ck-uqSJ2.cjs → agentHome-Qh6SWyHx.cjs} +2 -2
  9. package/dist/codex/happyMcpStdioBridge.cjs +2 -2
  10. package/dist/codex/happyMcpStdioBridge.mjs +2 -2
  11. package/dist/{config-CpY7JwBj.cjs → config-CFSD65_X.cjs} +4 -4
  12. package/dist/{config-1MpGxQ9y.mjs → config-DkCS4663.mjs} +4 -4
  13. package/dist/{index-BRygi44L.cjs → index-C1OeYxxj.cjs} +85 -35
  14. package/dist/{index-k5Q1BYVe.mjs → index-C4JoaSCO.mjs} +8 -8
  15. package/dist/{index-BEpwCxqf.cjs → index-C_hZVtR-.cjs} +4 -4
  16. package/dist/{index-GZ--m9D1.mjs → index-Cobfu9V_.mjs} +81 -31
  17. package/dist/{index-BnkxOdPZ.mjs → index-eLVXOce3.mjs} +4 -4
  18. package/dist/{index-BDts8AaI.cjs → index-x5-N7ElS.cjs} +7 -7
  19. package/dist/index.cjs +4 -4
  20. package/dist/index.mjs +4 -4
  21. package/dist/{installTerminalHooks-CcDZxqew.cjs → installTerminalHooks-CuqhiAo9.cjs} +4 -4
  22. package/dist/{installTerminalHooks-BYMuFagt.mjs → installTerminalHooks-D9pIHXxU.mjs} +4 -4
  23. package/dist/lib.cjs +2 -2
  24. package/dist/lib.d.cts +10 -0
  25. package/dist/lib.d.mts +10 -0
  26. package/dist/lib.mjs +2 -2
  27. package/dist/{limits-DNPVbu0o.mjs → limits-BfYfA7tt.mjs} +7 -7
  28. package/dist/{limits-Cs9ZqLsR.cjs → limits-D364Cpe3.cjs} +7 -7
  29. package/dist/{mcp-B0SoNWuQ.cjs → mcp-B5ojE8_m.cjs} +4 -4
  30. package/dist/{mcp-BtuzLwSL.mjs → mcp-DTXsdDwc.mjs} +4 -4
  31. package/dist/{piRuntime-RzukAlha.mjs → piRuntime-BWEgqBHm.mjs} +2 -2
  32. package/dist/{piRuntime-C52DKuoS.cjs → piRuntime-CbSaG74b.cjs} +2 -2
  33. package/dist/{piTerminal-BjATNNi_.cjs → piTerminal-Bzremid-.cjs} +6 -6
  34. package/dist/{piTerminal-BhHNN6o7.mjs → piTerminal-CnS3TF7M.mjs} +6 -6
  35. package/dist/{runGemini-H0nzGgAr.mjs → runGemini-C13-aRss.mjs} +6 -6
  36. package/dist/{runGemini-CSbocY0x.cjs → runGemini-Ci6ULPA7.cjs} +6 -6
  37. package/dist/{runOpenClaw-DTf27x3y.cjs → runOpenClaw-D2DW9Umc.cjs} +5 -5
  38. package/dist/{runOpenClaw-DgLSw9ye.mjs → runOpenClaw-D4ABejEy.mjs} +5 -5
  39. package/dist/{send-D_gFbnUb.cjs → send-1hJXKzeP.cjs} +4 -4
  40. package/dist/{send-B1xGe7BR.mjs → send-D-9_jOIS.mjs} +4 -4
  41. package/dist/{sessions-C_5xh0tc.mjs → sessions-C9VwpMKY.mjs} +4 -4
  42. package/dist/{sessions-DTef64KU.cjs → sessions-DOrymTYB.cjs} +4 -4
  43. package/dist/{spawn-CaClsWIZ.cjs → spawn-B1IlltVe.cjs} +4 -4
  44. package/dist/{spawn-jDF92zCE.mjs → spawn-C4uID9JX.mjs} +4 -4
  45. package/dist/{teams-BL-EFcfu.mjs → teams-CCv46ebr.mjs} +4 -4
  46. package/dist/{teams-CA6BHcPk.cjs → teams-xIRGgxwJ.cjs} +4 -4
  47. package/dist/{todo-DxNoHaPw.mjs → todo-5QemH15n.mjs} +3 -3
  48. package/dist/{todo-B-CAD9Y3.cjs → todo-DrsOEfYU.cjs} +3 -3
  49. package/dist/{types-DjB5fcAr.mjs → types-CXF6tULJ.mjs} +113 -7
  50. package/dist/{types-CgOMSh1y.cjs → types-IU5nGuaQ.cjs} +113 -7
  51. package/package.json +7 -7
@@ -1,5 +1,5 @@
1
1
  import axios from 'axios';
2
- import { l as logger, g as encodeBase64, j as encrypt, k as decrypt, m as decodeBase64, c as configuration, o as readDaemonState, q as clearDaemonState, s as decryptBlob, t as prepareClipboardText, v as prepareClipboardHistoryText, r as readSettings, d as readPersistedSessions, x as credentialRelayProblem, p as packageJson, y as readTrackedClaudeSessionIds, z as readTrackedCodexThreadIds, A as libsodiumEncryptForPublicKey, B as libsodiumPublicKeyFromSecretKey, D as getRandomBytes, E as redactLogValue } from './limits-DNPVbu0o.mjs';
2
+ import { l as logger, g as encodeBase64, j as encrypt, k as decrypt, m as decodeBase64, c as configuration, o as readDaemonState, q as clearDaemonState, s as decryptBlob, t as prepareClipboardText, v as prepareClipboardHistoryText, r as readSettings, d as readPersistedSessions, x as credentialRelayProblem, p as packageJson, y as readTrackedClaudeSessionIds, z as readTrackedCodexThreadIds, A as libsodiumEncryptForPublicKey, B as libsodiumPublicKeyFromSecretKey, D as getRandomBytes, E as redactLogValue } from './limits-BfYfA7tt.mjs';
3
3
  import { EventEmitter } from 'node:events';
4
4
  import { io } from 'socket.io-client';
5
5
  import * as z from 'zod';
@@ -887,7 +887,9 @@ const WebTerminalListItemSchema = z$1.object({
887
887
  /** B-265: @vh_title_manual is set (user renamed it). */
888
888
  manual: z$1.boolean().optional(),
889
889
  /** B-273: opened attached to the user's tmux session of this name. */
890
- attachTmux: z$1.string().optional()
890
+ attachTmux: z$1.string().optional(),
891
+ /** B-486: direct-pty shell (no tmux) — not restorable after a daemon restart. */
892
+ direct: z$1.boolean().optional()
891
893
  });
892
894
  const ClosedTerminalRecordSchema = z$1.object({
893
895
  id: z$1.string(),
@@ -991,6 +993,17 @@ z$1.object({
991
993
  rpcAvailable: z$1.boolean(),
992
994
  detectedAt: z$1.number()
993
995
  }).optional(),
996
+ /**
997
+ * B-486: can web terminals on this machine be durable? `tmuxAvailable:
998
+ * false` ⇒ every new terminal is a direct shell that dies with the daemon,
999
+ * so the web tells the user to install tmux. Same trust rule
1000
+ * (`detectedAt >= startedAt`); old webs ignore it.
1001
+ */
1002
+ terminalHost: z$1.object({
1003
+ tmuxAvailable: z$1.boolean(),
1004
+ tmuxVersion: z$1.string().optional(),
1005
+ detectedAt: z$1.number()
1006
+ }).optional(),
994
1007
  /** Relay-owned CLI compatibility/update policy last checked by this daemon. */
995
1008
  cliUpdate: CliUpdateStateSchema.optional(),
996
1009
  /**
@@ -10479,6 +10492,25 @@ function sanitizeClosedTerminals(raw, max = CLOSED_TERMINALS_MAX) {
10479
10492
  return out.slice(0, max);
10480
10493
  }
10481
10494
 
10495
+ function directTerminalItems(sessions, tmuxIds) {
10496
+ const out = [];
10497
+ for (const s of sessions) {
10498
+ if (!s.direct || tmuxIds.has(s.id)) continue;
10499
+ const title = (s.direct.title ?? "").trim();
10500
+ out.push({
10501
+ id: s.id,
10502
+ ...title ? { title } : {},
10503
+ tags: s.direct.tags ?? [],
10504
+ ...s.direct.manual ? { manual: true } : {},
10505
+ cwd: s.direct.cwd,
10506
+ createdAt: s.direct.createdAt,
10507
+ ...s.lastOutputAt ? { activityAt: s.lastOutputAt } : {},
10508
+ direct: true
10509
+ });
10510
+ }
10511
+ return out.sort((a, b) => a.createdAt - b.createdAt || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
10512
+ }
10513
+
10482
10514
  const TOMBSTONE_TTL_MS = 7 * 24 * 60 * 60 * 1e3;
10483
10515
  function tmuxKillVerified(kill, probe) {
10484
10516
  return !kill.error && !probe.error && typeof probe.status === "number" && probe.status !== 0;
@@ -10615,10 +10647,14 @@ function terminalListSignature(items) {
10615
10647
  // B-105: a mirror binding appearing/disappearing MUST push the
10616
10648
  // list, or the web never learns the toggle became available.
10617
10649
  t.mirrorSessionId ?? "",
10618
- t.attachTmux ?? ""
10650
+ t.attachTmux ?? "",
10651
+ t.direct ? 1 : 0
10619
10652
  ]);
10620
10653
  return JSON.stringify(canon);
10621
10654
  }
10655
+ function hostListSignature(items) {
10656
+ return `${isTmuxAvailable() ? "tmux" : "no-tmux"}|${terminalListSignature(items)}`;
10657
+ }
10622
10658
  const SHELL_COMMANDS = /* @__PURE__ */ new Set(["zsh", "bash", "fish", "sh", "dash", "ksh", "tcsh", "csh"]);
10623
10659
  const TITLE_MAX_CHARS = 60;
10624
10660
  const JUNK_TITLES = /* @__PURE__ */ new Set(["tmux", "claude", "node", ...SHELL_COMMANDS]);
@@ -10767,16 +10803,38 @@ function tmuxSupportsNewSessionEnv(versionOutput) {
10767
10803
  return maj > 3 || maj === 3 && min >= 2;
10768
10804
  }
10769
10805
  let tmuxAvailableCache = null;
10806
+ let tmuxMissingSince = 0;
10807
+ const TMUX_MISSING_REPROBE_MS = 3e4;
10770
10808
  function isTmuxAvailable() {
10771
- if (tmuxAvailableCache !== null) return tmuxAvailableCache;
10809
+ if (tmuxAvailableCache === true) return true;
10810
+ if (tmuxAvailableCache === false && Date.now() - tmuxMissingSince < TMUX_MISSING_REPROBE_MS) return false;
10772
10811
  try {
10773
10812
  const r = spawnSync("tmux", tmuxArgs(["-V"]), { stdio: "ignore", env: ptyEnv() });
10774
10813
  tmuxAvailableCache = r.status === 0;
10775
10814
  } catch {
10776
10815
  tmuxAvailableCache = false;
10777
10816
  }
10817
+ if (tmuxAvailableCache) {
10818
+ tmuxEnvFlagCache = null;
10819
+ tmuxVersionCache = void 0;
10820
+ } else {
10821
+ tmuxMissingSince = Date.now();
10822
+ }
10778
10823
  return tmuxAvailableCache;
10779
10824
  }
10825
+ function tmuxRuntimeInfo() {
10826
+ if (!isTmuxAvailable()) return { tmuxAvailable: false };
10827
+ if (tmuxVersionCache === void 0) {
10828
+ try {
10829
+ const r = spawnSync("tmux", tmuxArgs(["-V"]), { encoding: "utf8", timeout: TMUX_PROBE_TIMEOUT_MS, env: ptyEnv() });
10830
+ tmuxVersionCache = (r.stdout || "").trim().slice(0, 64);
10831
+ } catch {
10832
+ tmuxVersionCache = "";
10833
+ }
10834
+ }
10835
+ return tmuxVersionCache ? { tmuxAvailable: true, tmuxVersion: tmuxVersionCache } : { tmuxAvailable: true };
10836
+ }
10837
+ let tmuxVersionCache;
10780
10838
  let tmuxEnvFlagCache = null;
10781
10839
  function tmuxSupportsEnvFlag() {
10782
10840
  if (tmuxEnvFlagCache !== null) return tmuxEnvFlagCache;
@@ -10861,6 +10919,9 @@ class TerminalSession {
10861
10919
  id;
10862
10920
  tmuxSession;
10863
10921
  transport;
10922
+ /** B-486: set only for a direct-pty (no tmux) session — the list row's
10923
+ * source, since tmux knows nothing about it. */
10924
+ direct;
10864
10925
  /** B-273: the user tmux session this terminal was created to attach —
10865
10926
  * echoed to any later open of the same live session. */
10866
10927
  attachedTmux;
@@ -11324,7 +11385,7 @@ class WebTerminalManager {
11324
11385
  * other means (the connect-time daemonState write carries the initial
11325
11386
  * snapshot), so the first tick doesn't re-push an identical list. */
11326
11387
  primeListSignature(list) {
11327
- this.lastListSignature = terminalListSignature(list);
11388
+ this.lastListSignature = hostListSignature(list);
11328
11389
  this.noteSeen(list);
11329
11390
  }
11330
11391
  /** The retained closed-terminal records (newest first) — shipped by
@@ -11365,6 +11426,7 @@ class WebTerminalManager {
11365
11426
  */
11366
11427
  buildTerminalList() {
11367
11428
  const list = this.listSessions();
11429
+ list.push(...directTerminalItems(this.terminals.values(), new Set(list.map((t) => t.id))));
11368
11430
  for (const item of list) {
11369
11431
  const live = this.terminals.get(item.id);
11370
11432
  if (live?.lastOutputAt) {
@@ -11402,7 +11464,7 @@ class WebTerminalManager {
11402
11464
  list.filter((t) => t.activityAt).map((t) => [t.id, t.activityAt])
11403
11465
  ));
11404
11466
  this.mirrorTickObserver?.(list);
11405
- const sig = terminalListSignature(list);
11467
+ const sig = hostListSignature(list);
11406
11468
  if (sig === this.lastListSignature) return;
11407
11469
  this.lastListSignature = sig;
11408
11470
  cb(list);
@@ -11750,6 +11812,7 @@ class WebTerminalManager {
11750
11812
  const now = Date.now();
11751
11813
  this.snapshots.sweep(now);
11752
11814
  for (const [id, session] of [...this.terminals]) {
11815
+ if (session.direct) continue;
11753
11816
  if (session.subscribers === 0 && now - session.lastTouch > SESSION_IDLE_MS) {
11754
11817
  logger.debug(`[WEB TERMINAL] reaping orphaned idle session ${id} (idle ${Math.round((now - session.lastTouch) / 6e4)}m)`);
11755
11818
  this.detach(id);
@@ -12026,6 +12089,10 @@ class WebTerminalManager {
12026
12089
  }
12027
12090
  }
12028
12091
  const created = new TerminalSession(id, { kind: "pty", pty: proc }, void 0, cols, rows);
12092
+ created.direct = { cwd, createdAt: Date.now() };
12093
+ created.onTitleChange((title) => {
12094
+ if (created.direct && !created.direct.manual) created.direct.title = deriveAutoTitle(title, os$1.hostname());
12095
+ });
12029
12096
  created.onOutputChunk = (chunk) => {
12030
12097
  this.emit("terminal-output", { terminalId: id, data: chunk.data, seq: chunk.seq });
12031
12098
  this.noteActivity();
@@ -12378,6 +12445,26 @@ class WebTerminalManager {
12378
12445
  * session (so a local `tmux attach` won't find it either). Used when the
12379
12446
  * user deletes the terminal from the sidebar. */
12380
12447
  killSession(terminalId, opts) {
12448
+ const liveDirect = this.terminals.get(terminalId);
12449
+ if (liveDirect?.direct) {
12450
+ this.recordClosed({
12451
+ id: terminalId,
12452
+ ...liveDirect.direct.title?.trim() ? { title: liveDirect.direct.title.trim() } : {},
12453
+ cwd: liveDirect.direct.cwd,
12454
+ tags: liveDirect.direct.tags ?? [],
12455
+ ...liveDirect.direct.manual ? { manual: true } : {},
12456
+ reason: "closed",
12457
+ closedAt: Date.now()
12458
+ });
12459
+ this.lastSeenInfo.delete(terminalId);
12460
+ this.detach(terminalId);
12461
+ this.tombstones[terminalId] = Date.now();
12462
+ saveTombstones(this.tombstones);
12463
+ this.notifyTracker.remove(terminalId);
12464
+ logger.debug(`[WEB TERMINAL] killed direct pty ${terminalId}`);
12465
+ this.kickListRefresh();
12466
+ return true;
12467
+ }
12381
12468
  const info = this.lastSeenInfo.get(terminalId) ?? (() => {
12382
12469
  const t = this.listSessions().find((x) => x.id === terminalId);
12383
12470
  return t ? seenInfoOf(t) : void 0;
@@ -12662,6 +12749,14 @@ class WebTerminalManager {
12662
12749
  * landed. `ifAbsent` finding an existing title counts as success (there IS
12663
12750
  * a title; nothing to retry). */
12664
12751
  setTitle(terminalId, title, ifAbsent = false) {
12752
+ const direct = this.terminals.get(terminalId)?.direct;
12753
+ if (direct) {
12754
+ if (ifAbsent && (direct.manual || direct.title?.trim())) return true;
12755
+ direct.title = title;
12756
+ if (!ifAbsent) direct.manual = true;
12757
+ this.kickListRefresh();
12758
+ return true;
12759
+ }
12665
12760
  if (!isTmuxAvailable()) return false;
12666
12761
  if (!/^[a-zA-Z0-9_-]{1,64}$/.test(terminalId)) return false;
12667
12762
  const name = `vh-${terminalId}`;
@@ -12686,10 +12781,16 @@ class WebTerminalManager {
12686
12781
  }
12687
12782
  /** Persist terminal tags in tmux, parallel to @vh_title. */
12688
12783
  setTags(terminalId, tags) {
12689
- if (!isTmuxAvailable()) return false;
12784
+ const direct = this.terminals.get(terminalId)?.direct;
12785
+ if (!direct && !isTmuxAvailable()) return false;
12690
12786
  if (!/^[a-zA-Z0-9_-]{1,64}$/.test(terminalId)) return false;
12691
12787
  const valid = validateTerminalTags(tags);
12692
12788
  if (!valid) return false;
12789
+ if (direct) {
12790
+ direct.tags = valid;
12791
+ this.kickListRefresh();
12792
+ return true;
12793
+ }
12693
12794
  const name = `vh-${terminalId}`;
12694
12795
  try {
12695
12796
  const r = spawnSync("tmux", tmuxArgs(["set-option", "-t", name, "@vh_tags", JSON.stringify(valid)]), {
@@ -15777,6 +15878,9 @@ class ApiMachineClient {
15777
15878
  // even in the (unreachable) case of a null prior state.
15778
15879
  status: state?.status ?? "running",
15779
15880
  webTerminals: { updatedAt: Date.now(), terminals: list },
15881
+ // B-486: tmux can appear (user installs it) mid-run; the
15882
+ // tracker forces a push on that flip, so restamp here too.
15883
+ terminalHost: { ...tmuxRuntimeInfo(), detectedAt: Date.now() },
15780
15884
  // B-084: the closed-terminal records ride every list push
15781
15885
  // (a close always changes the list, so they stay in step).
15782
15886
  // Read at write time — same freshness rule as the list.
@@ -15926,6 +16030,8 @@ class ApiMachineClient {
15926
16030
  terminalRestore: { rpcAvailable: true, detectedAt: now },
15927
16031
  // B-273 capability flag (same restamp discipline).
15928
16032
  tmuxSessions: { rpcAvailable: true, detectedAt: now, killAttached: true },
16033
+ // B-486: tmux presence (same restamp discipline).
16034
+ terminalHost: { ...tmuxRuntimeInfo(), detectedAt: now },
15929
16035
  // B-290 capability flag: `claude-list-history` (same restamp discipline).
15930
16036
  claudeHistory: { rpcAvailable: true, detectedAt: now },
15931
16037
  // B-464 capability flag: `codex-list-history` / `codex-import-session`.
@@ -1,7 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  var axios = require('axios');
4
- var persistence = require('./limits-Cs9ZqLsR.cjs');
4
+ var persistence = require('./limits-D364Cpe3.cjs');
5
5
  var node_events = require('node:events');
6
6
  var socket_ioClient = require('socket.io-client');
7
7
  var z = require('zod');
@@ -908,7 +908,9 @@ const WebTerminalListItemSchema = z.z.object({
908
908
  /** B-265: @vh_title_manual is set (user renamed it). */
909
909
  manual: z.z.boolean().optional(),
910
910
  /** B-273: opened attached to the user's tmux session of this name. */
911
- attachTmux: z.z.string().optional()
911
+ attachTmux: z.z.string().optional(),
912
+ /** B-486: direct-pty shell (no tmux) — not restorable after a daemon restart. */
913
+ direct: z.z.boolean().optional()
912
914
  });
913
915
  const ClosedTerminalRecordSchema = z.z.object({
914
916
  id: z.z.string(),
@@ -1012,6 +1014,17 @@ z.z.object({
1012
1014
  rpcAvailable: z.z.boolean(),
1013
1015
  detectedAt: z.z.number()
1014
1016
  }).optional(),
1017
+ /**
1018
+ * B-486: can web terminals on this machine be durable? `tmuxAvailable:
1019
+ * false` ⇒ every new terminal is a direct shell that dies with the daemon,
1020
+ * so the web tells the user to install tmux. Same trust rule
1021
+ * (`detectedAt >= startedAt`); old webs ignore it.
1022
+ */
1023
+ terminalHost: z.z.object({
1024
+ tmuxAvailable: z.z.boolean(),
1025
+ tmuxVersion: z.z.string().optional(),
1026
+ detectedAt: z.z.number()
1027
+ }).optional(),
1015
1028
  /** Relay-owned CLI compatibility/update policy last checked by this daemon. */
1016
1029
  cliUpdate: CliUpdateStateSchema.optional(),
1017
1030
  /**
@@ -10500,6 +10513,25 @@ function sanitizeClosedTerminals(raw, max = CLOSED_TERMINALS_MAX) {
10500
10513
  return out.slice(0, max);
10501
10514
  }
10502
10515
 
10516
+ function directTerminalItems(sessions, tmuxIds) {
10517
+ const out = [];
10518
+ for (const s of sessions) {
10519
+ if (!s.direct || tmuxIds.has(s.id)) continue;
10520
+ const title = (s.direct.title ?? "").trim();
10521
+ out.push({
10522
+ id: s.id,
10523
+ ...title ? { title } : {},
10524
+ tags: s.direct.tags ?? [],
10525
+ ...s.direct.manual ? { manual: true } : {},
10526
+ cwd: s.direct.cwd,
10527
+ createdAt: s.direct.createdAt,
10528
+ ...s.lastOutputAt ? { activityAt: s.lastOutputAt } : {},
10529
+ direct: true
10530
+ });
10531
+ }
10532
+ return out.sort((a, b) => a.createdAt - b.createdAt || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
10533
+ }
10534
+
10503
10535
  const TOMBSTONE_TTL_MS = 7 * 24 * 60 * 60 * 1e3;
10504
10536
  function tmuxKillVerified(kill, probe) {
10505
10537
  return !kill.error && !probe.error && typeof probe.status === "number" && probe.status !== 0;
@@ -10636,10 +10668,14 @@ function terminalListSignature(items) {
10636
10668
  // B-105: a mirror binding appearing/disappearing MUST push the
10637
10669
  // list, or the web never learns the toggle became available.
10638
10670
  t.mirrorSessionId ?? "",
10639
- t.attachTmux ?? ""
10671
+ t.attachTmux ?? "",
10672
+ t.direct ? 1 : 0
10640
10673
  ]);
10641
10674
  return JSON.stringify(canon);
10642
10675
  }
10676
+ function hostListSignature(items) {
10677
+ return `${isTmuxAvailable() ? "tmux" : "no-tmux"}|${terminalListSignature(items)}`;
10678
+ }
10643
10679
  const SHELL_COMMANDS = /* @__PURE__ */ new Set(["zsh", "bash", "fish", "sh", "dash", "ksh", "tcsh", "csh"]);
10644
10680
  const TITLE_MAX_CHARS = 60;
10645
10681
  const JUNK_TITLES = /* @__PURE__ */ new Set(["tmux", "claude", "node", ...SHELL_COMMANDS]);
@@ -10788,16 +10824,38 @@ function tmuxSupportsNewSessionEnv(versionOutput) {
10788
10824
  return maj > 3 || maj === 3 && min >= 2;
10789
10825
  }
10790
10826
  let tmuxAvailableCache = null;
10827
+ let tmuxMissingSince = 0;
10828
+ const TMUX_MISSING_REPROBE_MS = 3e4;
10791
10829
  function isTmuxAvailable() {
10792
- if (tmuxAvailableCache !== null) return tmuxAvailableCache;
10830
+ if (tmuxAvailableCache === true) return true;
10831
+ if (tmuxAvailableCache === false && Date.now() - tmuxMissingSince < TMUX_MISSING_REPROBE_MS) return false;
10793
10832
  try {
10794
10833
  const r = node_child_process.spawnSync("tmux", tmuxArgs(["-V"]), { stdio: "ignore", env: ptyEnv() });
10795
10834
  tmuxAvailableCache = r.status === 0;
10796
10835
  } catch {
10797
10836
  tmuxAvailableCache = false;
10798
10837
  }
10838
+ if (tmuxAvailableCache) {
10839
+ tmuxEnvFlagCache = null;
10840
+ tmuxVersionCache = void 0;
10841
+ } else {
10842
+ tmuxMissingSince = Date.now();
10843
+ }
10799
10844
  return tmuxAvailableCache;
10800
10845
  }
10846
+ function tmuxRuntimeInfo() {
10847
+ if (!isTmuxAvailable()) return { tmuxAvailable: false };
10848
+ if (tmuxVersionCache === void 0) {
10849
+ try {
10850
+ const r = node_child_process.spawnSync("tmux", tmuxArgs(["-V"]), { encoding: "utf8", timeout: TMUX_PROBE_TIMEOUT_MS, env: ptyEnv() });
10851
+ tmuxVersionCache = (r.stdout || "").trim().slice(0, 64);
10852
+ } catch {
10853
+ tmuxVersionCache = "";
10854
+ }
10855
+ }
10856
+ return tmuxVersionCache ? { tmuxAvailable: true, tmuxVersion: tmuxVersionCache } : { tmuxAvailable: true };
10857
+ }
10858
+ let tmuxVersionCache;
10801
10859
  let tmuxEnvFlagCache = null;
10802
10860
  function tmuxSupportsEnvFlag() {
10803
10861
  if (tmuxEnvFlagCache !== null) return tmuxEnvFlagCache;
@@ -10882,6 +10940,9 @@ class TerminalSession {
10882
10940
  id;
10883
10941
  tmuxSession;
10884
10942
  transport;
10943
+ /** B-486: set only for a direct-pty (no tmux) session — the list row's
10944
+ * source, since tmux knows nothing about it. */
10945
+ direct;
10885
10946
  /** B-273: the user tmux session this terminal was created to attach —
10886
10947
  * echoed to any later open of the same live session. */
10887
10948
  attachedTmux;
@@ -11345,7 +11406,7 @@ class WebTerminalManager {
11345
11406
  * other means (the connect-time daemonState write carries the initial
11346
11407
  * snapshot), so the first tick doesn't re-push an identical list. */
11347
11408
  primeListSignature(list) {
11348
- this.lastListSignature = terminalListSignature(list);
11409
+ this.lastListSignature = hostListSignature(list);
11349
11410
  this.noteSeen(list);
11350
11411
  }
11351
11412
  /** The retained closed-terminal records (newest first) — shipped by
@@ -11386,6 +11447,7 @@ class WebTerminalManager {
11386
11447
  */
11387
11448
  buildTerminalList() {
11388
11449
  const list = this.listSessions();
11450
+ list.push(...directTerminalItems(this.terminals.values(), new Set(list.map((t) => t.id))));
11389
11451
  for (const item of list) {
11390
11452
  const live = this.terminals.get(item.id);
11391
11453
  if (live?.lastOutputAt) {
@@ -11423,7 +11485,7 @@ class WebTerminalManager {
11423
11485
  list.filter((t) => t.activityAt).map((t) => [t.id, t.activityAt])
11424
11486
  ));
11425
11487
  this.mirrorTickObserver?.(list);
11426
- const sig = terminalListSignature(list);
11488
+ const sig = hostListSignature(list);
11427
11489
  if (sig === this.lastListSignature) return;
11428
11490
  this.lastListSignature = sig;
11429
11491
  cb(list);
@@ -11771,6 +11833,7 @@ class WebTerminalManager {
11771
11833
  const now = Date.now();
11772
11834
  this.snapshots.sweep(now);
11773
11835
  for (const [id, session] of [...this.terminals]) {
11836
+ if (session.direct) continue;
11774
11837
  if (session.subscribers === 0 && now - session.lastTouch > SESSION_IDLE_MS) {
11775
11838
  persistence.logger.debug(`[WEB TERMINAL] reaping orphaned idle session ${id} (idle ${Math.round((now - session.lastTouch) / 6e4)}m)`);
11776
11839
  this.detach(id);
@@ -12047,6 +12110,10 @@ class WebTerminalManager {
12047
12110
  }
12048
12111
  }
12049
12112
  const created = new TerminalSession(id, { kind: "pty", pty: proc }, void 0, cols, rows);
12113
+ created.direct = { cwd, createdAt: Date.now() };
12114
+ created.onTitleChange((title) => {
12115
+ if (created.direct && !created.direct.manual) created.direct.title = deriveAutoTitle(title, os$1.hostname());
12116
+ });
12050
12117
  created.onOutputChunk = (chunk) => {
12051
12118
  this.emit("terminal-output", { terminalId: id, data: chunk.data, seq: chunk.seq });
12052
12119
  this.noteActivity();
@@ -12399,6 +12466,26 @@ class WebTerminalManager {
12399
12466
  * session (so a local `tmux attach` won't find it either). Used when the
12400
12467
  * user deletes the terminal from the sidebar. */
12401
12468
  killSession(terminalId, opts) {
12469
+ const liveDirect = this.terminals.get(terminalId);
12470
+ if (liveDirect?.direct) {
12471
+ this.recordClosed({
12472
+ id: terminalId,
12473
+ ...liveDirect.direct.title?.trim() ? { title: liveDirect.direct.title.trim() } : {},
12474
+ cwd: liveDirect.direct.cwd,
12475
+ tags: liveDirect.direct.tags ?? [],
12476
+ ...liveDirect.direct.manual ? { manual: true } : {},
12477
+ reason: "closed",
12478
+ closedAt: Date.now()
12479
+ });
12480
+ this.lastSeenInfo.delete(terminalId);
12481
+ this.detach(terminalId);
12482
+ this.tombstones[terminalId] = Date.now();
12483
+ saveTombstones(this.tombstones);
12484
+ this.notifyTracker.remove(terminalId);
12485
+ persistence.logger.debug(`[WEB TERMINAL] killed direct pty ${terminalId}`);
12486
+ this.kickListRefresh();
12487
+ return true;
12488
+ }
12402
12489
  const info = this.lastSeenInfo.get(terminalId) ?? (() => {
12403
12490
  const t = this.listSessions().find((x) => x.id === terminalId);
12404
12491
  return t ? seenInfoOf(t) : void 0;
@@ -12683,6 +12770,14 @@ class WebTerminalManager {
12683
12770
  * landed. `ifAbsent` finding an existing title counts as success (there IS
12684
12771
  * a title; nothing to retry). */
12685
12772
  setTitle(terminalId, title, ifAbsent = false) {
12773
+ const direct = this.terminals.get(terminalId)?.direct;
12774
+ if (direct) {
12775
+ if (ifAbsent && (direct.manual || direct.title?.trim())) return true;
12776
+ direct.title = title;
12777
+ if (!ifAbsent) direct.manual = true;
12778
+ this.kickListRefresh();
12779
+ return true;
12780
+ }
12686
12781
  if (!isTmuxAvailable()) return false;
12687
12782
  if (!/^[a-zA-Z0-9_-]{1,64}$/.test(terminalId)) return false;
12688
12783
  const name = `vh-${terminalId}`;
@@ -12707,10 +12802,16 @@ class WebTerminalManager {
12707
12802
  }
12708
12803
  /** Persist terminal tags in tmux, parallel to @vh_title. */
12709
12804
  setTags(terminalId, tags) {
12710
- if (!isTmuxAvailable()) return false;
12805
+ const direct = this.terminals.get(terminalId)?.direct;
12806
+ if (!direct && !isTmuxAvailable()) return false;
12711
12807
  if (!/^[a-zA-Z0-9_-]{1,64}$/.test(terminalId)) return false;
12712
12808
  const valid = validateTerminalTags(tags);
12713
12809
  if (!valid) return false;
12810
+ if (direct) {
12811
+ direct.tags = valid;
12812
+ this.kickListRefresh();
12813
+ return true;
12814
+ }
12714
12815
  const name = `vh-${terminalId}`;
12715
12816
  try {
12716
12817
  const r = node_child_process.spawnSync("tmux", tmuxArgs(["set-option", "-t", name, "@vh_tags", JSON.stringify(valid)]), {
@@ -15798,6 +15899,9 @@ class ApiMachineClient {
15798
15899
  // even in the (unreachable) case of a null prior state.
15799
15900
  status: state?.status ?? "running",
15800
15901
  webTerminals: { updatedAt: Date.now(), terminals: list },
15902
+ // B-486: tmux can appear (user installs it) mid-run; the
15903
+ // tracker forces a push on that flip, so restamp here too.
15904
+ terminalHost: { ...tmuxRuntimeInfo(), detectedAt: Date.now() },
15801
15905
  // B-084: the closed-terminal records ride every list push
15802
15906
  // (a close always changes the list, so they stay in step).
15803
15907
  // Read at write time — same freshness rule as the list.
@@ -15947,6 +16051,8 @@ class ApiMachineClient {
15947
16051
  terminalRestore: { rpcAvailable: true, detectedAt: now },
15948
16052
  // B-273 capability flag (same restamp discipline).
15949
16053
  tmuxSessions: { rpcAvailable: true, detectedAt: now, killAttached: true },
16054
+ // B-486: tmux presence (same restamp discipline).
16055
+ terminalHost: { ...tmuxRuntimeInfo(), detectedAt: now },
15950
16056
  // B-290 capability flag: `claude-list-history` (same restamp discipline).
15951
16057
  claudeHistory: { rpcAvailable: true, detectedAt: now },
15952
16058
  // B-464 capability flag: `codex-list-history` / `codex-import-session`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "very-happy-cli",
3
- "version": "0.2.146",
3
+ "version": "0.2.148",
4
4
  "description": "Remote multi-agent workspace CLI with deep Claude integration, native terminal streaming, and self-hosting.",
5
5
  "author": "Kirill Dubovitskiy",
6
6
  "contributors": [
@@ -117,12 +117,12 @@
117
117
  "zod": "^4.0.0"
118
118
  },
119
119
  "optionalDependencies": {
120
- "very-happy-tools-arm64-darwin": "0.2.146",
121
- "very-happy-tools-x64-darwin": "0.2.146",
122
- "very-happy-tools-arm64-linux": "0.2.146",
123
- "very-happy-tools-x64-linux": "0.2.146",
124
- "very-happy-tools-arm64-win32": "0.2.146",
125
- "very-happy-tools-x64-win32": "0.2.146"
120
+ "very-happy-tools-arm64-darwin": "0.2.148",
121
+ "very-happy-tools-x64-darwin": "0.2.148",
122
+ "very-happy-tools-arm64-linux": "0.2.148",
123
+ "very-happy-tools-x64-linux": "0.2.148",
124
+ "very-happy-tools-arm64-win32": "0.2.148",
125
+ "very-happy-tools-x64-win32": "0.2.148"
126
126
  },
127
127
  "devDependencies": {
128
128
  "@slopus/happy-wire": "workspace:*",