very-happy-cli 0.2.91 → 0.2.92

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-GqveWg8u.cjs → AcpBackend-BP7id8L6.cjs} +1 -1
  2. package/dist/{AcpBackend-B5RRlzLU.mjs → AcpBackend-DDEsgc1c.mjs} +1 -1
  3. package/dist/{AcpSessionManager-Ij1pI5Ex.mjs → AcpSessionManager-C29t6wb_.mjs} +1 -1
  4. package/dist/{AcpSessionManager-YcrSZwvq.cjs → AcpSessionManager-Cys1Hsb8.cjs} +1 -1
  5. package/dist/{config-C4NCw7Dz.cjs → config-B77vhdwT.cjs} +2 -2
  6. package/dist/{config-ra8iKxdU.mjs → config-CJQw0z1o.mjs} +2 -2
  7. package/dist/{index-UCYD5Wk3.mjs → index-8LDXVYWi.mjs} +337 -35
  8. package/dist/{index-FkXJNwiy.cjs → index-B63Vjt4W.cjs} +353 -51
  9. package/dist/{index-CB5H7YI6.mjs → index-BP9pfzdZ.mjs} +5 -5
  10. package/dist/{index-C1HIm7NY.cjs → index-L3o5Gz-2.cjs} +4 -4
  11. package/dist/index.cjs +2 -2
  12. package/dist/index.mjs +2 -2
  13. package/dist/{installTerminalHooks-CoK5wNcA.cjs → installTerminalHooks-BBc2nij4.cjs} +2 -2
  14. package/dist/{installTerminalHooks-TkkGzzbI.mjs → installTerminalHooks-DDRrUG_n.mjs} +2 -2
  15. package/dist/lib.cjs +1 -1
  16. package/dist/lib.d.cts +69 -3
  17. package/dist/lib.d.mts +69 -3
  18. package/dist/lib.mjs +1 -1
  19. package/dist/{mcp-C3MiqObi.mjs → mcp-CYJAl5pk.mjs} +2 -2
  20. package/dist/{mcp-DcRvswVI.cjs → mcp-DbYXyw1U.cjs} +2 -2
  21. package/dist/{runGemini-Dnvd2gWq.cjs → runGemini-BDSWSID6.cjs} +4 -4
  22. package/dist/{runGemini-BbC0uwUj.mjs → runGemini-DHYs_Beq.mjs} +4 -4
  23. package/dist/{runOpenClaw-CnuTBNQW.cjs → runOpenClaw-DC9gb1bd.cjs} +3 -3
  24. package/dist/{runOpenClaw--nbqTW3K.mjs → runOpenClaw-DTaXxvNd.mjs} +3 -3
  25. package/dist/{send-DkKOPatx.cjs → send-BEdGwmA-.cjs} +2 -2
  26. package/dist/{send-9nJxn09Y.mjs → send-BQSaVz9r.mjs} +2 -2
  27. package/dist/{spawn-DE7WhS8Y.cjs → spawn-CK1ma_qc.cjs} +2 -2
  28. package/dist/{spawn-CRW4ioms.mjs → spawn-DS-Uz_Hw.mjs} +2 -2
  29. package/dist/{types-BKyjG23_.mjs → types-B_8X_rZk.mjs} +328 -85
  30. package/dist/{types-ClDADuxP.cjs → types-CjlB_fKT.cjs} +329 -85
  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.91";
54
+ var version = "0.2.92";
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.91",
168
- "very-happy-tools-x64-darwin": "0.2.91",
169
- "very-happy-tools-arm64-linux": "0.2.91",
170
- "very-happy-tools-x64-linux": "0.2.91",
171
- "very-happy-tools-arm64-win32": "0.2.91",
172
- "very-happy-tools-x64-win32": "0.2.91"
167
+ "very-happy-tools-arm64-darwin": "0.2.92",
168
+ "very-happy-tools-x64-darwin": "0.2.92",
169
+ "very-happy-tools-arm64-linux": "0.2.92",
170
+ "very-happy-tools-x64-linux": "0.2.92",
171
+ "very-happy-tools-arm64-win32": "0.2.92",
172
+ "very-happy-tools-x64-win32": "0.2.92"
173
173
  };
174
174
  var devDependencies = {
175
175
  "@slopus/happy-wire": "workspace:*",
@@ -880,7 +880,11 @@ const UpdateSessionBodySchema = z__namespace.object({
880
880
  t: z__namespace.literal("update-session"),
881
881
  id: z__namespace.string(),
882
882
  metadata: VersionedEncryptedValueSchema.nullish(),
883
- agentState: VersionedNullableEncryptedValueSchema.nullish()
883
+ agentState: VersionedNullableEncryptedValueSchema.nullish(),
884
+ /** B-265: server-owned archive intent (ms epoch, null = not archived).
885
+ * Present only on the archive / unarchive transitions; absent elsewhere.
886
+ * Old clients strip it (default z.object) and fall back to `active`. */
887
+ archivedAt: z__namespace.number().nullable().optional()
884
888
  });
885
889
  const VersionedMachineEncryptedValueSchema = z__namespace.object({
886
890
  version: z__namespace.number(),
@@ -1019,7 +1023,9 @@ const WebTerminalListItemSchema = z.z.object({
1019
1023
  mirrorSessionId: z.z.string().optional(),
1020
1024
  /** B-150: auto-restored after a restart (ms epoch) — the directory and the
1021
1025
  * conversation carried over, the processes did not. Cleared once opened. */
1022
- restoredAt: z.z.number().optional()
1026
+ restoredAt: z.z.number().optional(),
1027
+ /** B-265: @vh_title_manual is set (user renamed it). */
1028
+ manual: z.z.boolean().optional()
1023
1029
  });
1024
1030
  const ClosedTerminalRecordSchema = z.z.object({
1025
1031
  id: z.z.string(),
@@ -1033,6 +1039,9 @@ const ClosedTerminalRecordSchema = z.z.object({
1033
1039
  /** B-149: 'daemon-gap' = died while no daemon was watching (restart/reboot);
1034
1040
  * absent means an ordinary observed close. */
1035
1041
  reason: z.z.enum(["closed", "daemon-gap"]).optional(),
1042
+ /** B-265: tags + manual-rename flag at close time (restored verbatim). */
1043
+ tags: z.z.array(z.z.string()).optional(),
1044
+ manual: z.z.boolean().optional(),
1036
1045
  closedAt: z.z.number()
1037
1046
  });
1038
1047
  z.z.object({
@@ -1077,6 +1086,15 @@ z.z.object({
1077
1086
  * ignore the field; old daemons never write it (web renders nothing).
1078
1087
  */
1079
1088
  closedTerminals: z.z.array(ClosedTerminalRecordSchema).optional(),
1089
+ /**
1090
+ * B-265 capability flag: this daemon answers `restore-terminal`. Same trust
1091
+ * rule as webTerminals — only `detectedAt >= startedAt` counts, because a
1092
+ * downgraded daemon spreads the stale field forward on connect.
1093
+ */
1094
+ terminalRestore: z.z.object({
1095
+ rpcAvailable: z.z.boolean(),
1096
+ detectedAt: z.z.number()
1097
+ }).optional(),
1080
1098
  /** Relay-owned CLI compatibility/update policy last checked by this daemon. */
1081
1099
  cliUpdate: CliUpdateStateSchema.optional()
1082
1100
  });
@@ -1527,7 +1545,7 @@ class RpcHandlerManager {
1527
1545
  }
1528
1546
  }
1529
1547
 
1530
- 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-ClDADuxP.cjs', document.baseURI).href))));
1548
+ 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-CjlB_fKT.cjs', document.baseURI).href))));
1531
1549
  function projectPath() {
1532
1550
  const path = path$1.resolve(__dirname$1, "..");
1533
1551
  return path;
@@ -3103,10 +3121,19 @@ class ApiSessionClient extends node_events.EventEmitter {
3103
3121
  directInboundLocalIds = /* @__PURE__ */ new Set();
3104
3122
  routedInboundLocalIds = /* @__PURE__ */ new Set();
3105
3123
  handoverInFlight = null;
3106
- constructor(token, session) {
3124
+ /** B-265 reconnect: the message cursor was seeded from the server, so
3125
+ * nothing before it is history to replay — but socket fast-path routing
3126
+ * must wait for the first fetch, otherwise a message that lands between
3127
+ * the seed and the first fetch is routed twice (socket + fetch). */
3128
+ awaitingInitialFetch = false;
3129
+ constructor(token, session, opts) {
3107
3130
  super();
3108
3131
  this.token = token;
3109
3132
  this.sessionId = session.id;
3133
+ if (opts?.initialSeq !== void 0 && opts.initialSeq >= 0) {
3134
+ this.lastSeq = opts.initialSeq;
3135
+ this.awaitingInitialFetch = true;
3136
+ }
3110
3137
  this.metadata = session.metadata;
3111
3138
  this.metadataVersion = session.metadataVersion;
3112
3139
  this.agentState = session.agentState;
@@ -3166,7 +3193,7 @@ class ApiSessionClient extends node_events.EventEmitter {
3166
3193
  }
3167
3194
  if (data.body.t === "new-message") {
3168
3195
  const messageSeq = data.body.message?.seq;
3169
- if (this.lastSeq === 0) {
3196
+ if (this.lastSeq === 0 || this.awaitingInitialFetch) {
3170
3197
  this.receiveSync.invalidate();
3171
3198
  return;
3172
3199
  }
@@ -3462,6 +3489,9 @@ class ApiSessionClient extends node_events.EventEmitter {
3462
3489
  if (message.localId && this.directInboundLocalIds.delete(message.localId)) {
3463
3490
  continue;
3464
3491
  }
3492
+ if (message.localId && this.routedInboundLocalIds.has(message.localId)) {
3493
+ continue;
3494
+ }
3465
3495
  if (message.content?.t !== "encrypted") {
3466
3496
  continue;
3467
3497
  }
@@ -3491,6 +3521,7 @@ class ApiSessionClient extends node_events.EventEmitter {
3491
3521
  break;
3492
3522
  }
3493
3523
  }
3524
+ this.awaitingInitialFetch = false;
3494
3525
  }
3495
3526
  static MAX_OUTBOX_BATCH_SIZE = 50;
3496
3527
  async flushOutbox() {
@@ -4738,6 +4769,101 @@ function registerTodoHandlers(manager) {
4738
4769
  });
4739
4770
  }
4740
4771
 
4772
+ function sanitizeTagList(value) {
4773
+ if (!Array.isArray(value)) return void 0;
4774
+ const out = [];
4775
+ for (const tag of value) {
4776
+ if (typeof tag !== "string") continue;
4777
+ const clean = tag.trim();
4778
+ if (clean && !out.includes(clean)) out.push(clean);
4779
+ if (out.length >= 64) break;
4780
+ }
4781
+ return out;
4782
+ }
4783
+ const LIVE_SNAPSHOT_TTL_MS = 14 * 24 * 60 * 60 * 1e3;
4784
+ const LIVE_SNAPSHOT_MAX = 100;
4785
+ function sanitizeLiveSnapshot(raw, now, ttlMs = LIVE_SNAPSHOT_TTL_MS, max = LIVE_SNAPSHOT_MAX) {
4786
+ const out = /* @__PURE__ */ new Map();
4787
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return out;
4788
+ const rows = [];
4789
+ for (const [id, value] of Object.entries(raw)) {
4790
+ if (!id || !value || typeof value !== "object") continue;
4791
+ const { title, cwd, seenAt, tags, manual } = value;
4792
+ if (typeof seenAt !== "number" || !Number.isFinite(seenAt)) continue;
4793
+ if (now - seenAt >= ttlMs) continue;
4794
+ const tagList = sanitizeTagList(tags);
4795
+ rows.push([id, {
4796
+ title: typeof title === "string" && title.trim() ? title : void 0,
4797
+ cwd: typeof cwd === "string" && cwd ? cwd : void 0,
4798
+ ...tagList !== void 0 ? { tags: tagList } : {},
4799
+ ...manual === true ? { manual: true } : {},
4800
+ seenAt
4801
+ }]);
4802
+ }
4803
+ rows.sort((a, b) => b[1].seenAt - a[1].seenAt);
4804
+ for (const [id, info] of rows.slice(0, max)) out.set(id, info);
4805
+ return out;
4806
+ }
4807
+ function serializeLiveSnapshot(map, max = LIVE_SNAPSHOT_MAX) {
4808
+ const rows = [...map.entries()].sort((a, b) => b[1].seenAt - a[1].seenAt).slice(0, max);
4809
+ const out = {};
4810
+ for (const [id, info] of rows) out[id] = info;
4811
+ return out;
4812
+ }
4813
+ function liveSnapshotChanged(prev, next) {
4814
+ if (prev.size !== next.size) return true;
4815
+ for (const [id, info] of next) {
4816
+ const before = prev.get(id);
4817
+ if (!before) return true;
4818
+ if (before.title !== info.title || before.cwd !== info.cwd) return true;
4819
+ if (!!before.manual !== !!info.manual) return true;
4820
+ if ((before.tags ?? []).join("\0") !== (info.tags ?? []).join("\0")) return true;
4821
+ }
4822
+ return false;
4823
+ }
4824
+ const CLAUDE_SESSION_ID_RE = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
4825
+ function isClaudeSessionId(value) {
4826
+ return typeof value === "string" && CLAUDE_SESSION_ID_RE.test(value);
4827
+ }
4828
+ function pickMirrorForTerminal(sessions, terminalId) {
4829
+ if (!terminalId) return null;
4830
+ let best = null;
4831
+ for (const [id, session] of Object.entries(sessions || {})) {
4832
+ const meta = session?.metadata;
4833
+ if (!meta || meta.terminalId !== terminalId) continue;
4834
+ const savedAt = typeof session.savedAt === "number" ? session.savedAt : 0;
4835
+ if (best && savedAt <= best.savedAt) continue;
4836
+ best = {
4837
+ id,
4838
+ savedAt,
4839
+ claude: isClaudeSessionId(meta.claudeSessionId) ? meta.claudeSessionId : void 0
4840
+ };
4841
+ }
4842
+ return best ? { sessionId: best.id, claudeSessionId: best.claude } : null;
4843
+ }
4844
+
4845
+ const TERMINAL_ID_RE$1 = /^[a-zA-Z0-9_-]{1,64}$/;
4846
+ function planTerminalRestore(record, facts) {
4847
+ if (facts.tmuxAlive) return { kind: "already-live" };
4848
+ if (!record.cwd || !facts.cwdExists(record.cwd)) return { kind: "error", reason: "missing-cwd" };
4849
+ const resumable = isClaudeSessionId(record.claudeSessionId) && facts.conversationExists(record.cwd, record.claudeSessionId);
4850
+ return {
4851
+ kind: "create",
4852
+ terminalId: record.id,
4853
+ cwd: record.cwd,
4854
+ title: record.title,
4855
+ manual: record.manual === true,
4856
+ tags: record.tags,
4857
+ ...resumable ? { command: `claude --resume ${record.claudeSessionId}` } : {}
4858
+ };
4859
+ }
4860
+
4861
+ function getProjectPath(workingDirectory) {
4862
+ const projectId = path.resolve(workingDirectory).replace(/[^a-zA-Z0-9-]/g, "-");
4863
+ const claudeConfigDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude");
4864
+ return path.join(claudeConfigDir, "projects", projectId);
4865
+ }
4866
+
4741
4867
  var xtermHeadless = {};
4742
4868
 
4743
4869
  var hasRequiredXtermHeadless;
@@ -9788,63 +9914,6 @@ function sendTerminalNotification(opts) {
9788
9914
  });
9789
9915
  }
9790
9916
 
9791
- const LIVE_SNAPSHOT_TTL_MS = 14 * 24 * 60 * 60 * 1e3;
9792
- const LIVE_SNAPSHOT_MAX = 100;
9793
- function sanitizeLiveSnapshot(raw, now, ttlMs = LIVE_SNAPSHOT_TTL_MS, max = LIVE_SNAPSHOT_MAX) {
9794
- const out = /* @__PURE__ */ new Map();
9795
- if (!raw || typeof raw !== "object" || Array.isArray(raw)) return out;
9796
- const rows = [];
9797
- for (const [id, value] of Object.entries(raw)) {
9798
- if (!id || !value || typeof value !== "object") continue;
9799
- const { title, cwd, seenAt } = value;
9800
- if (typeof seenAt !== "number" || !Number.isFinite(seenAt)) continue;
9801
- if (now - seenAt >= ttlMs) continue;
9802
- rows.push([id, {
9803
- title: typeof title === "string" && title.trim() ? title : void 0,
9804
- cwd: typeof cwd === "string" && cwd ? cwd : void 0,
9805
- seenAt
9806
- }]);
9807
- }
9808
- rows.sort((a, b) => b[1].seenAt - a[1].seenAt);
9809
- for (const [id, info] of rows.slice(0, max)) out.set(id, info);
9810
- return out;
9811
- }
9812
- function serializeLiveSnapshot(map, max = LIVE_SNAPSHOT_MAX) {
9813
- const rows = [...map.entries()].sort((a, b) => b[1].seenAt - a[1].seenAt).slice(0, max);
9814
- const out = {};
9815
- for (const [id, info] of rows) out[id] = info;
9816
- return out;
9817
- }
9818
- function liveSnapshotChanged(prev, next) {
9819
- if (prev.size !== next.size) return true;
9820
- for (const [id, info] of next) {
9821
- const before = prev.get(id);
9822
- if (!before) return true;
9823
- if (before.title !== info.title || before.cwd !== info.cwd) return true;
9824
- }
9825
- return false;
9826
- }
9827
- const CLAUDE_SESSION_ID_RE = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
9828
- function isClaudeSessionId(value) {
9829
- return typeof value === "string" && CLAUDE_SESSION_ID_RE.test(value);
9830
- }
9831
- function pickMirrorForTerminal(sessions, terminalId) {
9832
- if (!terminalId) return null;
9833
- let best = null;
9834
- for (const [id, session] of Object.entries(sessions || {})) {
9835
- const meta = session?.metadata;
9836
- if (!meta || meta.terminalId !== terminalId) continue;
9837
- const savedAt = typeof session.savedAt === "number" ? session.savedAt : 0;
9838
- if (best && savedAt <= best.savedAt) continue;
9839
- best = {
9840
- id,
9841
- savedAt,
9842
- claude: isClaudeSessionId(meta.claudeSessionId) ? meta.claudeSessionId : void 0
9843
- };
9844
- }
9845
- return best ? { sessionId: best.id, claudeSessionId: best.claude } : null;
9846
- }
9847
-
9848
9917
  const AUTO_RESTORE_DEFAULT_MAX = 6;
9849
9918
  const AUTO_RESTORE_DEFAULT_WINDOW_HOURS = 24;
9850
9919
  const AUTO_RESTORE_HARD_MAX = 20;
@@ -9903,6 +9972,8 @@ function selectAutoRestore(candidates, opts) {
9903
9972
  }
9904
9973
  plans.push({
9905
9974
  terminalId: c.id,
9975
+ ...c.tags !== void 0 ? { tags: c.tags } : {},
9976
+ ...c.manual ? { manual: true } : {},
9906
9977
  cwd: c.cwd,
9907
9978
  title: c.title,
9908
9979
  claudeSessionId: c.claudeSessionId,
@@ -9951,10 +10022,11 @@ function sanitizeClosedTerminals(raw, max = CLOSED_TERMINALS_MAX) {
9951
10022
  const out = [];
9952
10023
  for (const item of raw) {
9953
10024
  if (!item || typeof item !== "object") continue;
9954
- const { id, title, cwd, mirrorSessionId, claudeSessionId, reason, closedAt } = item;
10025
+ const { id, title, cwd, mirrorSessionId, claudeSessionId, reason, closedAt, tags, manual } = item;
9955
10026
  if (typeof id !== "string" || id.length === 0 || typeof closedAt !== "number") continue;
9956
10027
  if (seen.has(id)) continue;
9957
10028
  seen.add(id);
10029
+ const tagList = sanitizeTagList(tags);
9958
10030
  out.push({
9959
10031
  id,
9960
10032
  title: typeof title === "string" ? title : void 0,
@@ -9962,6 +10034,8 @@ function sanitizeClosedTerminals(raw, max = CLOSED_TERMINALS_MAX) {
9962
10034
  mirrorSessionId: typeof mirrorSessionId === "string" ? mirrorSessionId : void 0,
9963
10035
  claudeSessionId: typeof claudeSessionId === "string" ? claudeSessionId : void 0,
9964
10036
  reason: reason === "daemon-gap" || reason === "closed" ? reason : void 0,
10037
+ ...tagList !== void 0 ? { tags: tagList } : {},
10038
+ ...manual === true ? { manual: true } : {},
9965
10039
  closedAt
9966
10040
  });
9967
10041
  }
@@ -10085,6 +10159,9 @@ function startupInjectionArgs(tmuxSession, command) {
10085
10159
  ["send-keys", "-t", `=${tmuxSession}:`, "Enter"]
10086
10160
  ];
10087
10161
  }
10162
+ function seenInfoOf(t) {
10163
+ return { title: t.title, cwd: t.cwd, tags: t.tags, manual: t.manual };
10164
+ }
10088
10165
  function terminalListSignature(items) {
10089
10166
  const canon = [...items].sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0).map((t) => [
10090
10167
  t.id,
@@ -10721,7 +10798,7 @@ class WebTerminalManager {
10721
10798
  }
10722
10799
  /** Refresh the per-id {title, cwd} cache from an observed live list. */
10723
10800
  noteSeen(list) {
10724
- this.lastSeenInfo = new Map(list.map((t) => [t.id, { title: t.title, cwd: t.cwd }]));
10801
+ this.lastSeenInfo = new Map(list.map((t) => [t.id, seenInfoOf(t)]));
10725
10802
  }
10726
10803
  /** Record one terminal as closed (dedupe/cap in the pure module) and
10727
10804
  * persist. The confirming daemonState push rides the list refresh that
@@ -10795,7 +10872,7 @@ class WebTerminalManager {
10795
10872
  const liveIds = new Set(list.map((t) => t.id));
10796
10873
  this.reconcileRestoredSnapshot(liveIds);
10797
10874
  const next = new Map(
10798
- list.map((t) => [t.id, { title: t.title, cwd: t.cwd }])
10875
+ list.map((t) => [t.id, seenInfoOf(t)])
10799
10876
  );
10800
10877
  let changed = false;
10801
10878
  const now = Date.now();
@@ -10812,6 +10889,8 @@ class WebTerminalManager {
10812
10889
  id,
10813
10890
  title: info.title,
10814
10891
  cwd: info.cwd,
10892
+ tags: info.tags,
10893
+ manual: info.manual,
10815
10894
  // Live binding first (authoritative while it exists), persisted
10816
10895
  // metadata as the fallback once the mirror has been torn down.
10817
10896
  mirrorSessionId: this.mirrorResolver?.(id) ?? mirror?.sessionId,
@@ -10862,6 +10941,8 @@ class WebTerminalManager {
10862
10941
  id,
10863
10942
  title: info.title,
10864
10943
  cwd: info.cwd,
10944
+ tags: info.tags,
10945
+ manual: info.manual,
10865
10946
  mirrorSessionId: mirror?.sessionId,
10866
10947
  claudeSessionId: mirror?.claudeSessionId,
10867
10948
  reason: "daemon-gap",
@@ -10872,6 +10953,8 @@ class WebTerminalManager {
10872
10953
  id,
10873
10954
  title: info.title,
10874
10955
  cwd: info.cwd,
10956
+ tags: info.tags,
10957
+ manual: info.manual,
10875
10958
  seenAt: info.seenAt,
10876
10959
  claudeSessionId: mirror?.claudeSessionId
10877
10960
  });
@@ -10947,6 +11030,51 @@ class WebTerminalManager {
10947
11030
  * exactly like a terminal whose pty was reaped.
10948
11031
  */
10949
11032
  restoreOneTerminal(plan) {
11033
+ return this.createDetachedTerminal(plan);
11034
+ }
11035
+ /**
11036
+ * B-265: `restore-terminal` — bring ONE closed terminal back with its
11037
+ * original id / cwd / title (+ manual flag) / tags and, when the record
11038
+ * knows a claude conversation whose JSONL is still on disk, `claude
11039
+ * --resume` injected. Idempotent: an id whose tmux session already exists
11040
+ * is a success. The closed record is left to `pruneClosedAgainstLive` (the
11041
+ * next tick sees the id live again); the tombstone is deliberately kept —
11042
+ * a live tmux session attaches regardless, and dropping it would re-open
11043
+ * the stale-client resurrection hole B-149 closed.
11044
+ */
11045
+ restoreClosedTerminal(terminalId) {
11046
+ if (typeof terminalId !== "string" || !TERMINAL_ID_RE$1.test(terminalId)) return { type: "error", reason: "invalid-id" };
11047
+ if (!isTmuxAvailable()) return { type: "error", reason: "no-tmux" };
11048
+ const record = this.closedTerminals.find((r) => r.id === terminalId);
11049
+ if (!record) return { type: "error", reason: "no-record" };
11050
+ const env = ptyEnv();
11051
+ const alive = node_child_process.spawnSync(
11052
+ "tmux",
11053
+ tmuxArgs(["has-session", "-t", `=vh-${terminalId}:`]),
11054
+ { stdio: "ignore", timeout: TMUX_PROBE_TIMEOUT_MS, env }
11055
+ ).status === 0;
11056
+ const plan = planTerminalRestore(record, {
11057
+ tmuxAlive: alive,
11058
+ cwdExists: (cwd) => {
11059
+ try {
11060
+ return node_fs.existsSync(cwd) && node_fs.statSync(cwd).isDirectory();
11061
+ } catch {
11062
+ return false;
11063
+ }
11064
+ },
11065
+ conversationExists: (cwd, claudeSessionId) => node_fs.existsSync(path.join(getProjectPath(cwd), `${claudeSessionId}.jsonl`))
11066
+ });
11067
+ if (plan.kind === "already-live") return { type: "success", terminalId };
11068
+ if (plan.kind === "error") return { type: "error", reason: plan.reason };
11069
+ if (!this.createDetachedTerminal(plan)) return { type: "error", reason: "create-failed" };
11070
+ logger.info(`[WEB TERMINAL] restored closed terminal ${terminalId} in ${plan.cwd}${plan.command ? " (conversation resumed)" : ""}`);
11071
+ this.kickListRefresh();
11072
+ return { type: "success", terminalId };
11073
+ }
11074
+ /** Shared cold-create for auto-restore (B-150) and restore-terminal
11075
+ * (B-265): same create-only env as the interactive path, title/tags
11076
+ * written back, optional startup command injected. */
11077
+ createDetachedTerminal(plan) {
10950
11078
  const env = ptyEnv();
10951
11079
  const name = `vh-${plan.terminalId}`;
10952
11080
  const exists = node_child_process.spawnSync(
@@ -10983,18 +11111,35 @@ class WebTerminalManager {
10983
11111
  tmuxArgs(["set-option", "-t", `=${name}:`, "@vh_title", plan.title]),
10984
11112
  { stdio: "ignore", timeout: TMUX_PROBE_TIMEOUT_MS, env }
10985
11113
  );
11114
+ if (plan.manual) {
11115
+ node_child_process.spawnSync(
11116
+ "tmux",
11117
+ tmuxArgs(["set-option", "-t", `=${name}:`, "@vh_title_manual", "1"]),
11118
+ { stdio: "ignore", timeout: TMUX_PROBE_TIMEOUT_MS, env }
11119
+ );
11120
+ }
11121
+ }
11122
+ const tags = plan.tags !== void 0 ? validateTerminalTags(plan.tags) : void 0;
11123
+ if (tags && tags.length > 0) {
11124
+ node_child_process.spawnSync(
11125
+ "tmux",
11126
+ tmuxArgs(["set-option", "-t", `=${name}:`, "@vh_tags", JSON.stringify(tags)]),
11127
+ { stdio: "ignore", timeout: TMUX_PROBE_TIMEOUT_MS, env }
11128
+ );
10986
11129
  }
10987
- for (const argv of startupInjectionArgs(name, plan.command)) {
10988
- node_child_process.spawnSync("tmux", tmuxArgs(argv), { stdio: "ignore", timeout: TMUX_PROBE_TIMEOUT_MS, env });
11130
+ if (plan.command) {
11131
+ for (const argv of startupInjectionArgs(name, plan.command)) {
11132
+ node_child_process.spawnSync("tmux", tmuxArgs(argv), { stdio: "ignore", timeout: TMUX_PROBE_TIMEOUT_MS, env });
11133
+ }
10989
11134
  }
10990
- logger.info(`[WEB TERMINAL] auto-restored ${plan.terminalId} in ${plan.cwd}`);
11135
+ logger.info(`[WEB TERMINAL] cold-created ${plan.terminalId} in ${plan.cwd}`);
10991
11136
  return true;
10992
11137
  }
10993
11138
  /** Mirror the in-memory cache to disk, but only when something a restart
10994
11139
  * would care about changed (see liveSnapshotChanged). */
10995
11140
  persistLiveSnapshot(live, now) {
10996
11141
  const candidate = /* @__PURE__ */ new Map();
10997
- for (const [id, info] of live) candidate.set(id, { title: info.title, cwd: info.cwd, seenAt: now });
11142
+ for (const [id, info] of live) candidate.set(id, { title: info.title, cwd: info.cwd, tags: info.tags, manual: info.manual, seenAt: now });
10998
11143
  if (!liveSnapshotChanged(this.lastPersistedSnapshot, candidate)) return;
10999
11144
  this.lastPersistedSnapshot = candidate;
11000
11145
  saveLiveSnapshot(candidate);
@@ -11569,6 +11714,8 @@ class WebTerminalManager {
11569
11714
  id: terminalId,
11570
11715
  title: info.title,
11571
11716
  cwd: info.cwd,
11717
+ tags: info.tags,
11718
+ manual: info.manual,
11572
11719
  mirrorSessionId: this.mirrorResolver?.(terminalId) ?? mirror?.sessionId,
11573
11720
  claudeSessionId: mirror?.claudeSessionId,
11574
11721
  reason: "closed",
@@ -11652,7 +11799,8 @@ class WebTerminalManager {
11652
11799
  // tmux last-activity (epoch s) → ms; optional so old daemons
11653
11800
  // simply omit it and web clients fall back to createdAt.
11654
11801
  activityAt: s.activity,
11655
- agentState: this.probeAgentState(s.name, s.paneCurrentCommand)
11802
+ agentState: this.probeAgentState(s.name, s.paneCurrentCommand),
11803
+ manual: s.manual
11656
11804
  });
11657
11805
  }
11658
11806
  return out;
@@ -12102,12 +12250,6 @@ function detectResumeSupport(happyHomeDir = configuration.happyHomeDir) {
12102
12250
  };
12103
12251
  }
12104
12252
 
12105
- function getProjectPath(workingDirectory) {
12106
- const projectId = path.resolve(workingDirectory).replace(/[^a-zA-Z0-9-]/g, "-");
12107
- const claudeConfigDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude");
12108
- return path.join(claudeConfigDir, "projects", projectId);
12109
- }
12110
-
12111
12253
  class ForkTruncateUuidNotFoundError extends Error {
12112
12254
  constructor(cutAfterUuid, sourcePath) {
12113
12255
  super(`Truncation UUID ${cutAfterUuid} not found in ${sourcePath}`);
@@ -13612,6 +13754,7 @@ class ApiMachineClient {
13612
13754
  cliUpdatePushChain = Promise.resolve();
13613
13755
  rpcHandlerManager;
13614
13756
  resumeSessionHandler = null;
13757
+ restartSessionHandler = null;
13615
13758
  stopSessionHandler = null;
13616
13759
  listTrackedSessionIds = null;
13617
13760
  reconnectInterval = null;
@@ -13678,11 +13821,13 @@ class ApiMachineClient {
13678
13821
  setRPCHandlers({
13679
13822
  spawnSession,
13680
13823
  resumeSession,
13824
+ restartSession,
13681
13825
  stopSession,
13682
13826
  listTrackedSessionIds,
13683
13827
  requestShutdown
13684
13828
  }) {
13685
13829
  this.resumeSessionHandler = resumeSession ?? null;
13830
+ this.restartSessionHandler = restartSession ?? null;
13686
13831
  this.stopSessionHandler = stopSession;
13687
13832
  this.listTrackedSessionIds = listTrackedSessionIds ?? null;
13688
13833
  this.rpcHandlerManager.registerHandler("spawn-happy-session", async (params) => {
@@ -13704,6 +13849,7 @@ class ApiMachineClient {
13704
13849
  }
13705
13850
  });
13706
13851
  this.syncResumeSessionRpcRegistration();
13852
+ this.syncRestartSessionRpcRegistration();
13707
13853
  this.rpcHandlerManager.registerHandler("open-terminal", async (params) => {
13708
13854
  const { terminalId, cols, rows, cwd, fromSeq, encStream, startupCommand, resub, attachOnly, streamMode } = params || {};
13709
13855
  const result = await this.webTerminal.open({
@@ -13779,6 +13925,14 @@ class ApiMachineClient {
13779
13925
  this.encTerminals.delete(terminalId);
13780
13926
  return { type: "success" };
13781
13927
  });
13928
+ this.rpcHandlerManager.registerHandler("restore-terminal", async (params) => {
13929
+ const { terminalId } = params || {};
13930
+ const result = this.webTerminal.restoreClosedTerminal(terminalId);
13931
+ if (result.type === "error") {
13932
+ return { type: "error", reason: result.reason, errorMessage: `restore-terminal: ${result.reason}` };
13933
+ }
13934
+ return { type: "success", terminalId: result.terminalId };
13935
+ });
13782
13936
  this.rpcHandlerManager.registerHandler("list-terminals", async () => {
13783
13937
  return { type: "success", terminals: this.webTerminal.buildTerminalList() };
13784
13938
  });
@@ -13930,6 +14084,40 @@ class ApiMachineClient {
13930
14084
  return { message: "Daemon stop request acknowledged, starting shutdown sequence..." };
13931
14085
  });
13932
14086
  }
14087
+ // B-264: symmetric to resume, but the handler stops a live-but-broken
14088
+ // wrapper first. Old web never calls it; new web calling an old daemon that
14089
+ // lacks the handler gets "method not found" and shows an upgrade hint —
14090
+ // never a double-spawn.
14091
+ syncRestartSessionRpcRegistration() {
14092
+ const method = "restart-session";
14093
+ if (this.restartSessionHandler) {
14094
+ if (!this.rpcHandlerManager.hasHandler(method)) {
14095
+ this.rpcHandlerManager.registerHandler(method, async (params) => {
14096
+ const { sessionId, model, permissionMode } = params || {};
14097
+ if (!sessionId || typeof sessionId !== "string") {
14098
+ throw new Error("Session ID is required");
14099
+ }
14100
+ const handler = this.restartSessionHandler;
14101
+ if (!handler) {
14102
+ throw new Error("Restart session handler not available");
14103
+ }
14104
+ const result = await handler(sessionId, { model, permissionMode });
14105
+ switch (result.type) {
14106
+ case "success":
14107
+ return { type: "success", sessionId: result.sessionId };
14108
+ case "requestToApproveDirectoryCreation":
14109
+ return result;
14110
+ case "error":
14111
+ throw new Error(result.errorMessage);
14112
+ }
14113
+ });
14114
+ }
14115
+ return;
14116
+ }
14117
+ if (this.rpcHandlerManager.hasHandler(method)) {
14118
+ this.rpcHandlerManager.unregisterHandler(method);
14119
+ }
14120
+ }
13933
14121
  syncResumeSessionRpcRegistration() {
13934
14122
  const method = "resume-happy-session";
13935
14123
  if (this.resumeSessionHandler) {
@@ -14137,6 +14325,9 @@ class ApiMachineClient {
14137
14325
  httpPort: this.machine.daemonState?.httpPort,
14138
14326
  startedAt: now,
14139
14327
  webTerminals: { updatedAt: now, terminals: initialTerminals },
14328
+ // B-265 capability flag; restamped every connect so the
14329
+ // web's `detectedAt >= startedAt` trust rule holds.
14330
+ terminalRestore: { rpcAvailable: true, detectedAt: now },
14140
14331
  // B-084: closed records survive daemon restarts (persisted
14141
14332
  // in closed-terminals.json), so the connect snapshot ships
14142
14333
  // them too — not just the incremental pushes.
@@ -14146,6 +14337,7 @@ class ApiMachineClient {
14146
14337
  this.webTerminal.startListTracking((terminals) => this.pushTerminalList(terminals));
14147
14338
  if (!rpcAlreadyRegistered) this.rpcHandlerManager.onSocketConnect(socket);
14148
14339
  this.syncResumeSessionRpcRegistration();
14340
+ this.syncRestartSessionRpcRegistration();
14149
14341
  this.startKeepAlive();
14150
14342
  void this.reconcileArchivedSessions();
14151
14343
  void this.refreshRelayConnection();
@@ -14381,6 +14573,7 @@ class ApiMachineClient {
14381
14573
  if (this.relaySocket !== relaySocket) return;
14382
14574
  this.rpcHandlerManager.onSocketConnect(relaySocket);
14383
14575
  this.syncResumeSessionRpcRegistration();
14576
+ this.syncRestartSessionRpcRegistration();
14384
14577
  logger.debug(`[API MACHINE] Connected to regional relay ${selected.assignment.relayId}`);
14385
14578
  });
14386
14579
  relaySocket.on("disconnect", () => {
@@ -15155,8 +15348,8 @@ class ApiClient {
15155
15348
  throw error;
15156
15349
  }
15157
15350
  }
15158
- sessionSyncClient(session) {
15159
- return new ApiSessionClient(this.credential.token, session);
15351
+ sessionSyncClient(session, opts) {
15352
+ return new ApiSessionClient(this.credential.token, session, opts);
15160
15353
  }
15161
15354
  machineSyncClient(machine) {
15162
15355
  return new ApiMachineClient(this.credential.token, machine);
@@ -15332,6 +15525,56 @@ class ApiClient {
15332
15525
  return false;
15333
15526
  }
15334
15527
  }
15528
+ /**
15529
+ * B-265: one session by id — the message cursor (`seq`), metadata and
15530
+ * agent state with their versions, decrypted with the session key the
15531
+ * reconnecting process already holds. `unsupported` = the server has no
15532
+ * such route (or the row is gone): callers fall back to the legacy
15533
+ * skip-all-history reconnect. Transient failures are retried a few times
15534
+ * first, because falling back silently discards the very message a restore
15535
+ * is meant to deliver.
15536
+ */
15537
+ async getSession(sessionId, encryptionKey, encryptionVariant, opts) {
15538
+ const attempts = opts?.attempts ?? 3;
15539
+ const delays = opts?.delaysMs ?? [500, 1500];
15540
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
15541
+ try {
15542
+ const response = await axios.get(
15543
+ `${configuration.serverUrl}/v1/sessions/${encodeURIComponent(sessionId)}`,
15544
+ {
15545
+ headers: {
15546
+ "Authorization": `Bearer ${this.credential.token}`,
15547
+ "X-Happy-Client": `cli-coding-session/${configuration.currentCliVersion}`
15548
+ },
15549
+ timeout: opts?.timeoutMs ?? 4e3,
15550
+ validateStatus: () => true
15551
+ }
15552
+ );
15553
+ if (response.status === 404 || response.status === 401 || response.status === 403) {
15554
+ return { ok: false, reason: "unsupported" };
15555
+ }
15556
+ if (response.status >= 200 && response.status < 300) {
15557
+ const row = response.data.session;
15558
+ if (!row || typeof row.seq !== "number" || typeof row.metadata !== "string") {
15559
+ return { ok: false, reason: "unsupported" };
15560
+ }
15561
+ const metadata = decrypt(encryptionKey, encryptionVariant, decodeBase64(row.metadata));
15562
+ if (!metadata) return { ok: false, reason: "unavailable" };
15563
+ const agentState = row.agentState ? decrypt(encryptionKey, encryptionVariant, decodeBase64(row.agentState)) : null;
15564
+ return {
15565
+ ok: true,
15566
+ session: { seq: row.seq, metadata, metadataVersion: row.metadataVersion, agentState, agentStateVersion: row.agentStateVersion }
15567
+ };
15568
+ }
15569
+ logger.debug(`[API] getSession ${sessionId}: status ${response.status} (attempt ${attempt + 1}/${attempts})`);
15570
+ } catch (error) {
15571
+ logger.debug(`[API] getSession ${sessionId} failed (attempt ${attempt + 1}/${attempts}):`, error);
15572
+ }
15573
+ const delayMs = delays[Math.min(attempt, delays.length - 1)];
15574
+ if (attempt < attempts - 1 && delayMs > 0) await new Promise((r) => setTimeout(r, delayMs));
15575
+ }
15576
+ return { ok: false, reason: "unavailable" };
15577
+ }
15335
15578
  /** Clear the server-owned archive tombstone before an intentional resume.
15336
15579
  * Older servers return 404 because the endpoint does not exist; that is a
15337
15580
  * compatible no-op because those servers also have no durable tombstone. */
@@ -15468,6 +15711,7 @@ exports.getProjectPath = getProjectPath;
15468
15711
  exports.hardenPrivateFile = hardenPrivateFile;
15469
15712
  exports.hardenPrivateFileSync = hardenPrivateFileSync;
15470
15713
  exports.initializeSandbox = initializeSandbox;
15714
+ exports.isClaudeSessionId = isClaudeSessionId;
15471
15715
  exports.isValidSessionId = isValidSessionId;
15472
15716
  exports.isValidTerminalId = isValidTerminalId;
15473
15717
  exports.listVhTerminals = listVhTerminals;