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
@@ -29,7 +29,7 @@ import { SandboxManager } from '@anthropic-ai/sandbox-runtime';
29
29
  import { Expo } from 'expo-server-sdk';
30
30
 
31
31
  var name = "very-happy-cli";
32
- var version = "0.2.91";
32
+ var version = "0.2.92";
33
33
  var description = "Remote multi-agent workspace CLI with deep Claude integration, native terminal streaming, and self-hosting.";
34
34
  var author = "Kirill Dubovitskiy";
35
35
  var contributors = [
@@ -142,12 +142,12 @@ var dependencies = {
142
142
  zod: "^4.0.0"
143
143
  };
144
144
  var optionalDependencies = {
145
- "very-happy-tools-arm64-darwin": "0.2.91",
146
- "very-happy-tools-x64-darwin": "0.2.91",
147
- "very-happy-tools-arm64-linux": "0.2.91",
148
- "very-happy-tools-x64-linux": "0.2.91",
149
- "very-happy-tools-arm64-win32": "0.2.91",
150
- "very-happy-tools-x64-win32": "0.2.91"
145
+ "very-happy-tools-arm64-darwin": "0.2.92",
146
+ "very-happy-tools-x64-darwin": "0.2.92",
147
+ "very-happy-tools-arm64-linux": "0.2.92",
148
+ "very-happy-tools-x64-linux": "0.2.92",
149
+ "very-happy-tools-arm64-win32": "0.2.92",
150
+ "very-happy-tools-x64-win32": "0.2.92"
151
151
  };
152
152
  var devDependencies = {
153
153
  "@slopus/happy-wire": "workspace:*",
@@ -858,7 +858,11 @@ const UpdateSessionBodySchema = z.object({
858
858
  t: z.literal("update-session"),
859
859
  id: z.string(),
860
860
  metadata: VersionedEncryptedValueSchema.nullish(),
861
- agentState: VersionedNullableEncryptedValueSchema.nullish()
861
+ agentState: VersionedNullableEncryptedValueSchema.nullish(),
862
+ /** B-265: server-owned archive intent (ms epoch, null = not archived).
863
+ * Present only on the archive / unarchive transitions; absent elsewhere.
864
+ * Old clients strip it (default z.object) and fall back to `active`. */
865
+ archivedAt: z.number().nullable().optional()
862
866
  });
863
867
  const VersionedMachineEncryptedValueSchema = z.object({
864
868
  version: z.number(),
@@ -997,7 +1001,9 @@ const WebTerminalListItemSchema = z$1.object({
997
1001
  mirrorSessionId: z$1.string().optional(),
998
1002
  /** B-150: auto-restored after a restart (ms epoch) — the directory and the
999
1003
  * conversation carried over, the processes did not. Cleared once opened. */
1000
- restoredAt: z$1.number().optional()
1004
+ restoredAt: z$1.number().optional(),
1005
+ /** B-265: @vh_title_manual is set (user renamed it). */
1006
+ manual: z$1.boolean().optional()
1001
1007
  });
1002
1008
  const ClosedTerminalRecordSchema = z$1.object({
1003
1009
  id: z$1.string(),
@@ -1011,6 +1017,9 @@ const ClosedTerminalRecordSchema = z$1.object({
1011
1017
  /** B-149: 'daemon-gap' = died while no daemon was watching (restart/reboot);
1012
1018
  * absent means an ordinary observed close. */
1013
1019
  reason: z$1.enum(["closed", "daemon-gap"]).optional(),
1020
+ /** B-265: tags + manual-rename flag at close time (restored verbatim). */
1021
+ tags: z$1.array(z$1.string()).optional(),
1022
+ manual: z$1.boolean().optional(),
1014
1023
  closedAt: z$1.number()
1015
1024
  });
1016
1025
  z$1.object({
@@ -1055,6 +1064,15 @@ z$1.object({
1055
1064
  * ignore the field; old daemons never write it (web renders nothing).
1056
1065
  */
1057
1066
  closedTerminals: z$1.array(ClosedTerminalRecordSchema).optional(),
1067
+ /**
1068
+ * B-265 capability flag: this daemon answers `restore-terminal`. Same trust
1069
+ * rule as webTerminals — only `detectedAt >= startedAt` counts, because a
1070
+ * downgraded daemon spreads the stale field forward on connect.
1071
+ */
1072
+ terminalRestore: z$1.object({
1073
+ rpcAvailable: z$1.boolean(),
1074
+ detectedAt: z$1.number()
1075
+ }).optional(),
1058
1076
  /** Relay-owned CLI compatibility/update policy last checked by this daemon. */
1059
1077
  cliUpdate: CliUpdateStateSchema.optional()
1060
1078
  });
@@ -3081,10 +3099,19 @@ class ApiSessionClient extends EventEmitter {
3081
3099
  directInboundLocalIds = /* @__PURE__ */ new Set();
3082
3100
  routedInboundLocalIds = /* @__PURE__ */ new Set();
3083
3101
  handoverInFlight = null;
3084
- constructor(token, session) {
3102
+ /** B-265 reconnect: the message cursor was seeded from the server, so
3103
+ * nothing before it is history to replay — but socket fast-path routing
3104
+ * must wait for the first fetch, otherwise a message that lands between
3105
+ * the seed and the first fetch is routed twice (socket + fetch). */
3106
+ awaitingInitialFetch = false;
3107
+ constructor(token, session, opts) {
3085
3108
  super();
3086
3109
  this.token = token;
3087
3110
  this.sessionId = session.id;
3111
+ if (opts?.initialSeq !== void 0 && opts.initialSeq >= 0) {
3112
+ this.lastSeq = opts.initialSeq;
3113
+ this.awaitingInitialFetch = true;
3114
+ }
3088
3115
  this.metadata = session.metadata;
3089
3116
  this.metadataVersion = session.metadataVersion;
3090
3117
  this.agentState = session.agentState;
@@ -3144,7 +3171,7 @@ class ApiSessionClient extends EventEmitter {
3144
3171
  }
3145
3172
  if (data.body.t === "new-message") {
3146
3173
  const messageSeq = data.body.message?.seq;
3147
- if (this.lastSeq === 0) {
3174
+ if (this.lastSeq === 0 || this.awaitingInitialFetch) {
3148
3175
  this.receiveSync.invalidate();
3149
3176
  return;
3150
3177
  }
@@ -3440,6 +3467,9 @@ class ApiSessionClient extends EventEmitter {
3440
3467
  if (message.localId && this.directInboundLocalIds.delete(message.localId)) {
3441
3468
  continue;
3442
3469
  }
3470
+ if (message.localId && this.routedInboundLocalIds.has(message.localId)) {
3471
+ continue;
3472
+ }
3443
3473
  if (message.content?.t !== "encrypted") {
3444
3474
  continue;
3445
3475
  }
@@ -3469,6 +3499,7 @@ class ApiSessionClient extends EventEmitter {
3469
3499
  break;
3470
3500
  }
3471
3501
  }
3502
+ this.awaitingInitialFetch = false;
3472
3503
  }
3473
3504
  static MAX_OUTBOX_BATCH_SIZE = 50;
3474
3505
  async flushOutbox() {
@@ -4716,6 +4747,101 @@ function registerTodoHandlers(manager) {
4716
4747
  });
4717
4748
  }
4718
4749
 
4750
+ function sanitizeTagList(value) {
4751
+ if (!Array.isArray(value)) return void 0;
4752
+ const out = [];
4753
+ for (const tag of value) {
4754
+ if (typeof tag !== "string") continue;
4755
+ const clean = tag.trim();
4756
+ if (clean && !out.includes(clean)) out.push(clean);
4757
+ if (out.length >= 64) break;
4758
+ }
4759
+ return out;
4760
+ }
4761
+ const LIVE_SNAPSHOT_TTL_MS = 14 * 24 * 60 * 60 * 1e3;
4762
+ const LIVE_SNAPSHOT_MAX = 100;
4763
+ function sanitizeLiveSnapshot(raw, now, ttlMs = LIVE_SNAPSHOT_TTL_MS, max = LIVE_SNAPSHOT_MAX) {
4764
+ const out = /* @__PURE__ */ new Map();
4765
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return out;
4766
+ const rows = [];
4767
+ for (const [id, value] of Object.entries(raw)) {
4768
+ if (!id || !value || typeof value !== "object") continue;
4769
+ const { title, cwd, seenAt, tags, manual } = value;
4770
+ if (typeof seenAt !== "number" || !Number.isFinite(seenAt)) continue;
4771
+ if (now - seenAt >= ttlMs) continue;
4772
+ const tagList = sanitizeTagList(tags);
4773
+ rows.push([id, {
4774
+ title: typeof title === "string" && title.trim() ? title : void 0,
4775
+ cwd: typeof cwd === "string" && cwd ? cwd : void 0,
4776
+ ...tagList !== void 0 ? { tags: tagList } : {},
4777
+ ...manual === true ? { manual: true } : {},
4778
+ seenAt
4779
+ }]);
4780
+ }
4781
+ rows.sort((a, b) => b[1].seenAt - a[1].seenAt);
4782
+ for (const [id, info] of rows.slice(0, max)) out.set(id, info);
4783
+ return out;
4784
+ }
4785
+ function serializeLiveSnapshot(map, max = LIVE_SNAPSHOT_MAX) {
4786
+ const rows = [...map.entries()].sort((a, b) => b[1].seenAt - a[1].seenAt).slice(0, max);
4787
+ const out = {};
4788
+ for (const [id, info] of rows) out[id] = info;
4789
+ return out;
4790
+ }
4791
+ function liveSnapshotChanged(prev, next) {
4792
+ if (prev.size !== next.size) return true;
4793
+ for (const [id, info] of next) {
4794
+ const before = prev.get(id);
4795
+ if (!before) return true;
4796
+ if (before.title !== info.title || before.cwd !== info.cwd) return true;
4797
+ if (!!before.manual !== !!info.manual) return true;
4798
+ if ((before.tags ?? []).join("\0") !== (info.tags ?? []).join("\0")) return true;
4799
+ }
4800
+ return false;
4801
+ }
4802
+ 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}$/;
4803
+ function isClaudeSessionId(value) {
4804
+ return typeof value === "string" && CLAUDE_SESSION_ID_RE.test(value);
4805
+ }
4806
+ function pickMirrorForTerminal(sessions, terminalId) {
4807
+ if (!terminalId) return null;
4808
+ let best = null;
4809
+ for (const [id, session] of Object.entries(sessions || {})) {
4810
+ const meta = session?.metadata;
4811
+ if (!meta || meta.terminalId !== terminalId) continue;
4812
+ const savedAt = typeof session.savedAt === "number" ? session.savedAt : 0;
4813
+ if (best && savedAt <= best.savedAt) continue;
4814
+ best = {
4815
+ id,
4816
+ savedAt,
4817
+ claude: isClaudeSessionId(meta.claudeSessionId) ? meta.claudeSessionId : void 0
4818
+ };
4819
+ }
4820
+ return best ? { sessionId: best.id, claudeSessionId: best.claude } : null;
4821
+ }
4822
+
4823
+ const TERMINAL_ID_RE$1 = /^[a-zA-Z0-9_-]{1,64}$/;
4824
+ function planTerminalRestore(record, facts) {
4825
+ if (facts.tmuxAlive) return { kind: "already-live" };
4826
+ if (!record.cwd || !facts.cwdExists(record.cwd)) return { kind: "error", reason: "missing-cwd" };
4827
+ const resumable = isClaudeSessionId(record.claudeSessionId) && facts.conversationExists(record.cwd, record.claudeSessionId);
4828
+ return {
4829
+ kind: "create",
4830
+ terminalId: record.id,
4831
+ cwd: record.cwd,
4832
+ title: record.title,
4833
+ manual: record.manual === true,
4834
+ tags: record.tags,
4835
+ ...resumable ? { command: `claude --resume ${record.claudeSessionId}` } : {}
4836
+ };
4837
+ }
4838
+
4839
+ function getProjectPath(workingDirectory) {
4840
+ const projectId = resolve$1(workingDirectory).replace(/[^a-zA-Z0-9-]/g, "-");
4841
+ const claudeConfigDir = process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude");
4842
+ return join(claudeConfigDir, "projects", projectId);
4843
+ }
4844
+
4719
4845
  var xtermHeadless = {};
4720
4846
 
4721
4847
  var hasRequiredXtermHeadless;
@@ -9766,63 +9892,6 @@ function sendTerminalNotification(opts) {
9766
9892
  });
9767
9893
  }
9768
9894
 
9769
- const LIVE_SNAPSHOT_TTL_MS = 14 * 24 * 60 * 60 * 1e3;
9770
- const LIVE_SNAPSHOT_MAX = 100;
9771
- function sanitizeLiveSnapshot(raw, now, ttlMs = LIVE_SNAPSHOT_TTL_MS, max = LIVE_SNAPSHOT_MAX) {
9772
- const out = /* @__PURE__ */ new Map();
9773
- if (!raw || typeof raw !== "object" || Array.isArray(raw)) return out;
9774
- const rows = [];
9775
- for (const [id, value] of Object.entries(raw)) {
9776
- if (!id || !value || typeof value !== "object") continue;
9777
- const { title, cwd, seenAt } = value;
9778
- if (typeof seenAt !== "number" || !Number.isFinite(seenAt)) continue;
9779
- if (now - seenAt >= ttlMs) continue;
9780
- rows.push([id, {
9781
- title: typeof title === "string" && title.trim() ? title : void 0,
9782
- cwd: typeof cwd === "string" && cwd ? cwd : void 0,
9783
- seenAt
9784
- }]);
9785
- }
9786
- rows.sort((a, b) => b[1].seenAt - a[1].seenAt);
9787
- for (const [id, info] of rows.slice(0, max)) out.set(id, info);
9788
- return out;
9789
- }
9790
- function serializeLiveSnapshot(map, max = LIVE_SNAPSHOT_MAX) {
9791
- const rows = [...map.entries()].sort((a, b) => b[1].seenAt - a[1].seenAt).slice(0, max);
9792
- const out = {};
9793
- for (const [id, info] of rows) out[id] = info;
9794
- return out;
9795
- }
9796
- function liveSnapshotChanged(prev, next) {
9797
- if (prev.size !== next.size) return true;
9798
- for (const [id, info] of next) {
9799
- const before = prev.get(id);
9800
- if (!before) return true;
9801
- if (before.title !== info.title || before.cwd !== info.cwd) return true;
9802
- }
9803
- return false;
9804
- }
9805
- 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}$/;
9806
- function isClaudeSessionId(value) {
9807
- return typeof value === "string" && CLAUDE_SESSION_ID_RE.test(value);
9808
- }
9809
- function pickMirrorForTerminal(sessions, terminalId) {
9810
- if (!terminalId) return null;
9811
- let best = null;
9812
- for (const [id, session] of Object.entries(sessions || {})) {
9813
- const meta = session?.metadata;
9814
- if (!meta || meta.terminalId !== terminalId) continue;
9815
- const savedAt = typeof session.savedAt === "number" ? session.savedAt : 0;
9816
- if (best && savedAt <= best.savedAt) continue;
9817
- best = {
9818
- id,
9819
- savedAt,
9820
- claude: isClaudeSessionId(meta.claudeSessionId) ? meta.claudeSessionId : void 0
9821
- };
9822
- }
9823
- return best ? { sessionId: best.id, claudeSessionId: best.claude } : null;
9824
- }
9825
-
9826
9895
  const AUTO_RESTORE_DEFAULT_MAX = 6;
9827
9896
  const AUTO_RESTORE_DEFAULT_WINDOW_HOURS = 24;
9828
9897
  const AUTO_RESTORE_HARD_MAX = 20;
@@ -9881,6 +9950,8 @@ function selectAutoRestore(candidates, opts) {
9881
9950
  }
9882
9951
  plans.push({
9883
9952
  terminalId: c.id,
9953
+ ...c.tags !== void 0 ? { tags: c.tags } : {},
9954
+ ...c.manual ? { manual: true } : {},
9884
9955
  cwd: c.cwd,
9885
9956
  title: c.title,
9886
9957
  claudeSessionId: c.claudeSessionId,
@@ -9929,10 +10000,11 @@ function sanitizeClosedTerminals(raw, max = CLOSED_TERMINALS_MAX) {
9929
10000
  const out = [];
9930
10001
  for (const item of raw) {
9931
10002
  if (!item || typeof item !== "object") continue;
9932
- const { id, title, cwd, mirrorSessionId, claudeSessionId, reason, closedAt } = item;
10003
+ const { id, title, cwd, mirrorSessionId, claudeSessionId, reason, closedAt, tags, manual } = item;
9933
10004
  if (typeof id !== "string" || id.length === 0 || typeof closedAt !== "number") continue;
9934
10005
  if (seen.has(id)) continue;
9935
10006
  seen.add(id);
10007
+ const tagList = sanitizeTagList(tags);
9936
10008
  out.push({
9937
10009
  id,
9938
10010
  title: typeof title === "string" ? title : void 0,
@@ -9940,6 +10012,8 @@ function sanitizeClosedTerminals(raw, max = CLOSED_TERMINALS_MAX) {
9940
10012
  mirrorSessionId: typeof mirrorSessionId === "string" ? mirrorSessionId : void 0,
9941
10013
  claudeSessionId: typeof claudeSessionId === "string" ? claudeSessionId : void 0,
9942
10014
  reason: reason === "daemon-gap" || reason === "closed" ? reason : void 0,
10015
+ ...tagList !== void 0 ? { tags: tagList } : {},
10016
+ ...manual === true ? { manual: true } : {},
9943
10017
  closedAt
9944
10018
  });
9945
10019
  }
@@ -10063,6 +10137,9 @@ function startupInjectionArgs(tmuxSession, command) {
10063
10137
  ["send-keys", "-t", `=${tmuxSession}:`, "Enter"]
10064
10138
  ];
10065
10139
  }
10140
+ function seenInfoOf(t) {
10141
+ return { title: t.title, cwd: t.cwd, tags: t.tags, manual: t.manual };
10142
+ }
10066
10143
  function terminalListSignature(items) {
10067
10144
  const canon = [...items].sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0).map((t) => [
10068
10145
  t.id,
@@ -10699,7 +10776,7 @@ class WebTerminalManager {
10699
10776
  }
10700
10777
  /** Refresh the per-id {title, cwd} cache from an observed live list. */
10701
10778
  noteSeen(list) {
10702
- this.lastSeenInfo = new Map(list.map((t) => [t.id, { title: t.title, cwd: t.cwd }]));
10779
+ this.lastSeenInfo = new Map(list.map((t) => [t.id, seenInfoOf(t)]));
10703
10780
  }
10704
10781
  /** Record one terminal as closed (dedupe/cap in the pure module) and
10705
10782
  * persist. The confirming daemonState push rides the list refresh that
@@ -10773,7 +10850,7 @@ class WebTerminalManager {
10773
10850
  const liveIds = new Set(list.map((t) => t.id));
10774
10851
  this.reconcileRestoredSnapshot(liveIds);
10775
10852
  const next = new Map(
10776
- list.map((t) => [t.id, { title: t.title, cwd: t.cwd }])
10853
+ list.map((t) => [t.id, seenInfoOf(t)])
10777
10854
  );
10778
10855
  let changed = false;
10779
10856
  const now = Date.now();
@@ -10790,6 +10867,8 @@ class WebTerminalManager {
10790
10867
  id,
10791
10868
  title: info.title,
10792
10869
  cwd: info.cwd,
10870
+ tags: info.tags,
10871
+ manual: info.manual,
10793
10872
  // Live binding first (authoritative while it exists), persisted
10794
10873
  // metadata as the fallback once the mirror has been torn down.
10795
10874
  mirrorSessionId: this.mirrorResolver?.(id) ?? mirror?.sessionId,
@@ -10840,6 +10919,8 @@ class WebTerminalManager {
10840
10919
  id,
10841
10920
  title: info.title,
10842
10921
  cwd: info.cwd,
10922
+ tags: info.tags,
10923
+ manual: info.manual,
10843
10924
  mirrorSessionId: mirror?.sessionId,
10844
10925
  claudeSessionId: mirror?.claudeSessionId,
10845
10926
  reason: "daemon-gap",
@@ -10850,6 +10931,8 @@ class WebTerminalManager {
10850
10931
  id,
10851
10932
  title: info.title,
10852
10933
  cwd: info.cwd,
10934
+ tags: info.tags,
10935
+ manual: info.manual,
10853
10936
  seenAt: info.seenAt,
10854
10937
  claudeSessionId: mirror?.claudeSessionId
10855
10938
  });
@@ -10925,6 +11008,51 @@ class WebTerminalManager {
10925
11008
  * exactly like a terminal whose pty was reaped.
10926
11009
  */
10927
11010
  restoreOneTerminal(plan) {
11011
+ return this.createDetachedTerminal(plan);
11012
+ }
11013
+ /**
11014
+ * B-265: `restore-terminal` — bring ONE closed terminal back with its
11015
+ * original id / cwd / title (+ manual flag) / tags and, when the record
11016
+ * knows a claude conversation whose JSONL is still on disk, `claude
11017
+ * --resume` injected. Idempotent: an id whose tmux session already exists
11018
+ * is a success. The closed record is left to `pruneClosedAgainstLive` (the
11019
+ * next tick sees the id live again); the tombstone is deliberately kept —
11020
+ * a live tmux session attaches regardless, and dropping it would re-open
11021
+ * the stale-client resurrection hole B-149 closed.
11022
+ */
11023
+ restoreClosedTerminal(terminalId) {
11024
+ if (typeof terminalId !== "string" || !TERMINAL_ID_RE$1.test(terminalId)) return { type: "error", reason: "invalid-id" };
11025
+ if (!isTmuxAvailable()) return { type: "error", reason: "no-tmux" };
11026
+ const record = this.closedTerminals.find((r) => r.id === terminalId);
11027
+ if (!record) return { type: "error", reason: "no-record" };
11028
+ const env = ptyEnv();
11029
+ const alive = spawnSync(
11030
+ "tmux",
11031
+ tmuxArgs(["has-session", "-t", `=vh-${terminalId}:`]),
11032
+ { stdio: "ignore", timeout: TMUX_PROBE_TIMEOUT_MS, env }
11033
+ ).status === 0;
11034
+ const plan = planTerminalRestore(record, {
11035
+ tmuxAlive: alive,
11036
+ cwdExists: (cwd) => {
11037
+ try {
11038
+ return existsSync(cwd) && statSync(cwd).isDirectory();
11039
+ } catch {
11040
+ return false;
11041
+ }
11042
+ },
11043
+ conversationExists: (cwd, claudeSessionId) => existsSync(join(getProjectPath(cwd), `${claudeSessionId}.jsonl`))
11044
+ });
11045
+ if (plan.kind === "already-live") return { type: "success", terminalId };
11046
+ if (plan.kind === "error") return { type: "error", reason: plan.reason };
11047
+ if (!this.createDetachedTerminal(plan)) return { type: "error", reason: "create-failed" };
11048
+ logger.info(`[WEB TERMINAL] restored closed terminal ${terminalId} in ${plan.cwd}${plan.command ? " (conversation resumed)" : ""}`);
11049
+ this.kickListRefresh();
11050
+ return { type: "success", terminalId };
11051
+ }
11052
+ /** Shared cold-create for auto-restore (B-150) and restore-terminal
11053
+ * (B-265): same create-only env as the interactive path, title/tags
11054
+ * written back, optional startup command injected. */
11055
+ createDetachedTerminal(plan) {
10928
11056
  const env = ptyEnv();
10929
11057
  const name = `vh-${plan.terminalId}`;
10930
11058
  const exists = spawnSync(
@@ -10961,18 +11089,35 @@ class WebTerminalManager {
10961
11089
  tmuxArgs(["set-option", "-t", `=${name}:`, "@vh_title", plan.title]),
10962
11090
  { stdio: "ignore", timeout: TMUX_PROBE_TIMEOUT_MS, env }
10963
11091
  );
11092
+ if (plan.manual) {
11093
+ spawnSync(
11094
+ "tmux",
11095
+ tmuxArgs(["set-option", "-t", `=${name}:`, "@vh_title_manual", "1"]),
11096
+ { stdio: "ignore", timeout: TMUX_PROBE_TIMEOUT_MS, env }
11097
+ );
11098
+ }
11099
+ }
11100
+ const tags = plan.tags !== void 0 ? validateTerminalTags(plan.tags) : void 0;
11101
+ if (tags && tags.length > 0) {
11102
+ spawnSync(
11103
+ "tmux",
11104
+ tmuxArgs(["set-option", "-t", `=${name}:`, "@vh_tags", JSON.stringify(tags)]),
11105
+ { stdio: "ignore", timeout: TMUX_PROBE_TIMEOUT_MS, env }
11106
+ );
10964
11107
  }
10965
- for (const argv of startupInjectionArgs(name, plan.command)) {
10966
- spawnSync("tmux", tmuxArgs(argv), { stdio: "ignore", timeout: TMUX_PROBE_TIMEOUT_MS, env });
11108
+ if (plan.command) {
11109
+ for (const argv of startupInjectionArgs(name, plan.command)) {
11110
+ spawnSync("tmux", tmuxArgs(argv), { stdio: "ignore", timeout: TMUX_PROBE_TIMEOUT_MS, env });
11111
+ }
10967
11112
  }
10968
- logger.info(`[WEB TERMINAL] auto-restored ${plan.terminalId} in ${plan.cwd}`);
11113
+ logger.info(`[WEB TERMINAL] cold-created ${plan.terminalId} in ${plan.cwd}`);
10969
11114
  return true;
10970
11115
  }
10971
11116
  /** Mirror the in-memory cache to disk, but only when something a restart
10972
11117
  * would care about changed (see liveSnapshotChanged). */
10973
11118
  persistLiveSnapshot(live, now) {
10974
11119
  const candidate = /* @__PURE__ */ new Map();
10975
- for (const [id, info] of live) candidate.set(id, { title: info.title, cwd: info.cwd, seenAt: now });
11120
+ for (const [id, info] of live) candidate.set(id, { title: info.title, cwd: info.cwd, tags: info.tags, manual: info.manual, seenAt: now });
10976
11121
  if (!liveSnapshotChanged(this.lastPersistedSnapshot, candidate)) return;
10977
11122
  this.lastPersistedSnapshot = candidate;
10978
11123
  saveLiveSnapshot(candidate);
@@ -11547,6 +11692,8 @@ class WebTerminalManager {
11547
11692
  id: terminalId,
11548
11693
  title: info.title,
11549
11694
  cwd: info.cwd,
11695
+ tags: info.tags,
11696
+ manual: info.manual,
11550
11697
  mirrorSessionId: this.mirrorResolver?.(terminalId) ?? mirror?.sessionId,
11551
11698
  claudeSessionId: mirror?.claudeSessionId,
11552
11699
  reason: "closed",
@@ -11630,7 +11777,8 @@ class WebTerminalManager {
11630
11777
  // tmux last-activity (epoch s) → ms; optional so old daemons
11631
11778
  // simply omit it and web clients fall back to createdAt.
11632
11779
  activityAt: s.activity,
11633
- agentState: this.probeAgentState(s.name, s.paneCurrentCommand)
11780
+ agentState: this.probeAgentState(s.name, s.paneCurrentCommand),
11781
+ manual: s.manual
11634
11782
  });
11635
11783
  }
11636
11784
  return out;
@@ -12080,12 +12228,6 @@ function detectResumeSupport(happyHomeDir = configuration.happyHomeDir) {
12080
12228
  };
12081
12229
  }
12082
12230
 
12083
- function getProjectPath(workingDirectory) {
12084
- const projectId = resolve$1(workingDirectory).replace(/[^a-zA-Z0-9-]/g, "-");
12085
- const claudeConfigDir = process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude");
12086
- return join(claudeConfigDir, "projects", projectId);
12087
- }
12088
-
12089
12231
  class ForkTruncateUuidNotFoundError extends Error {
12090
12232
  constructor(cutAfterUuid, sourcePath) {
12091
12233
  super(`Truncation UUID ${cutAfterUuid} not found in ${sourcePath}`);
@@ -13590,6 +13732,7 @@ class ApiMachineClient {
13590
13732
  cliUpdatePushChain = Promise.resolve();
13591
13733
  rpcHandlerManager;
13592
13734
  resumeSessionHandler = null;
13735
+ restartSessionHandler = null;
13593
13736
  stopSessionHandler = null;
13594
13737
  listTrackedSessionIds = null;
13595
13738
  reconnectInterval = null;
@@ -13656,11 +13799,13 @@ class ApiMachineClient {
13656
13799
  setRPCHandlers({
13657
13800
  spawnSession,
13658
13801
  resumeSession,
13802
+ restartSession,
13659
13803
  stopSession,
13660
13804
  listTrackedSessionIds,
13661
13805
  requestShutdown
13662
13806
  }) {
13663
13807
  this.resumeSessionHandler = resumeSession ?? null;
13808
+ this.restartSessionHandler = restartSession ?? null;
13664
13809
  this.stopSessionHandler = stopSession;
13665
13810
  this.listTrackedSessionIds = listTrackedSessionIds ?? null;
13666
13811
  this.rpcHandlerManager.registerHandler("spawn-happy-session", async (params) => {
@@ -13682,6 +13827,7 @@ class ApiMachineClient {
13682
13827
  }
13683
13828
  });
13684
13829
  this.syncResumeSessionRpcRegistration();
13830
+ this.syncRestartSessionRpcRegistration();
13685
13831
  this.rpcHandlerManager.registerHandler("open-terminal", async (params) => {
13686
13832
  const { terminalId, cols, rows, cwd, fromSeq, encStream, startupCommand, resub, attachOnly, streamMode } = params || {};
13687
13833
  const result = await this.webTerminal.open({
@@ -13757,6 +13903,14 @@ class ApiMachineClient {
13757
13903
  this.encTerminals.delete(terminalId);
13758
13904
  return { type: "success" };
13759
13905
  });
13906
+ this.rpcHandlerManager.registerHandler("restore-terminal", async (params) => {
13907
+ const { terminalId } = params || {};
13908
+ const result = this.webTerminal.restoreClosedTerminal(terminalId);
13909
+ if (result.type === "error") {
13910
+ return { type: "error", reason: result.reason, errorMessage: `restore-terminal: ${result.reason}` };
13911
+ }
13912
+ return { type: "success", terminalId: result.terminalId };
13913
+ });
13760
13914
  this.rpcHandlerManager.registerHandler("list-terminals", async () => {
13761
13915
  return { type: "success", terminals: this.webTerminal.buildTerminalList() };
13762
13916
  });
@@ -13908,6 +14062,40 @@ class ApiMachineClient {
13908
14062
  return { message: "Daemon stop request acknowledged, starting shutdown sequence..." };
13909
14063
  });
13910
14064
  }
14065
+ // B-264: symmetric to resume, but the handler stops a live-but-broken
14066
+ // wrapper first. Old web never calls it; new web calling an old daemon that
14067
+ // lacks the handler gets "method not found" and shows an upgrade hint —
14068
+ // never a double-spawn.
14069
+ syncRestartSessionRpcRegistration() {
14070
+ const method = "restart-session";
14071
+ if (this.restartSessionHandler) {
14072
+ if (!this.rpcHandlerManager.hasHandler(method)) {
14073
+ this.rpcHandlerManager.registerHandler(method, async (params) => {
14074
+ const { sessionId, model, permissionMode } = params || {};
14075
+ if (!sessionId || typeof sessionId !== "string") {
14076
+ throw new Error("Session ID is required");
14077
+ }
14078
+ const handler = this.restartSessionHandler;
14079
+ if (!handler) {
14080
+ throw new Error("Restart session handler not available");
14081
+ }
14082
+ const result = await handler(sessionId, { model, permissionMode });
14083
+ switch (result.type) {
14084
+ case "success":
14085
+ return { type: "success", sessionId: result.sessionId };
14086
+ case "requestToApproveDirectoryCreation":
14087
+ return result;
14088
+ case "error":
14089
+ throw new Error(result.errorMessage);
14090
+ }
14091
+ });
14092
+ }
14093
+ return;
14094
+ }
14095
+ if (this.rpcHandlerManager.hasHandler(method)) {
14096
+ this.rpcHandlerManager.unregisterHandler(method);
14097
+ }
14098
+ }
13911
14099
  syncResumeSessionRpcRegistration() {
13912
14100
  const method = "resume-happy-session";
13913
14101
  if (this.resumeSessionHandler) {
@@ -14115,6 +14303,9 @@ class ApiMachineClient {
14115
14303
  httpPort: this.machine.daemonState?.httpPort,
14116
14304
  startedAt: now,
14117
14305
  webTerminals: { updatedAt: now, terminals: initialTerminals },
14306
+ // B-265 capability flag; restamped every connect so the
14307
+ // web's `detectedAt >= startedAt` trust rule holds.
14308
+ terminalRestore: { rpcAvailable: true, detectedAt: now },
14118
14309
  // B-084: closed records survive daemon restarts (persisted
14119
14310
  // in closed-terminals.json), so the connect snapshot ships
14120
14311
  // them too — not just the incremental pushes.
@@ -14124,6 +14315,7 @@ class ApiMachineClient {
14124
14315
  this.webTerminal.startListTracking((terminals) => this.pushTerminalList(terminals));
14125
14316
  if (!rpcAlreadyRegistered) this.rpcHandlerManager.onSocketConnect(socket);
14126
14317
  this.syncResumeSessionRpcRegistration();
14318
+ this.syncRestartSessionRpcRegistration();
14127
14319
  this.startKeepAlive();
14128
14320
  void this.reconcileArchivedSessions();
14129
14321
  void this.refreshRelayConnection();
@@ -14359,6 +14551,7 @@ class ApiMachineClient {
14359
14551
  if (this.relaySocket !== relaySocket) return;
14360
14552
  this.rpcHandlerManager.onSocketConnect(relaySocket);
14361
14553
  this.syncResumeSessionRpcRegistration();
14554
+ this.syncRestartSessionRpcRegistration();
14362
14555
  logger.debug(`[API MACHINE] Connected to regional relay ${selected.assignment.relayId}`);
14363
14556
  });
14364
14557
  relaySocket.on("disconnect", () => {
@@ -15133,8 +15326,8 @@ class ApiClient {
15133
15326
  throw error;
15134
15327
  }
15135
15328
  }
15136
- sessionSyncClient(session) {
15137
- return new ApiSessionClient(this.credential.token, session);
15329
+ sessionSyncClient(session, opts) {
15330
+ return new ApiSessionClient(this.credential.token, session, opts);
15138
15331
  }
15139
15332
  machineSyncClient(machine) {
15140
15333
  return new ApiMachineClient(this.credential.token, machine);
@@ -15310,6 +15503,56 @@ class ApiClient {
15310
15503
  return false;
15311
15504
  }
15312
15505
  }
15506
+ /**
15507
+ * B-265: one session by id — the message cursor (`seq`), metadata and
15508
+ * agent state with their versions, decrypted with the session key the
15509
+ * reconnecting process already holds. `unsupported` = the server has no
15510
+ * such route (or the row is gone): callers fall back to the legacy
15511
+ * skip-all-history reconnect. Transient failures are retried a few times
15512
+ * first, because falling back silently discards the very message a restore
15513
+ * is meant to deliver.
15514
+ */
15515
+ async getSession(sessionId, encryptionKey, encryptionVariant, opts) {
15516
+ const attempts = opts?.attempts ?? 3;
15517
+ const delays = opts?.delaysMs ?? [500, 1500];
15518
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
15519
+ try {
15520
+ const response = await axios.get(
15521
+ `${configuration.serverUrl}/v1/sessions/${encodeURIComponent(sessionId)}`,
15522
+ {
15523
+ headers: {
15524
+ "Authorization": `Bearer ${this.credential.token}`,
15525
+ "X-Happy-Client": `cli-coding-session/${configuration.currentCliVersion}`
15526
+ },
15527
+ timeout: opts?.timeoutMs ?? 4e3,
15528
+ validateStatus: () => true
15529
+ }
15530
+ );
15531
+ if (response.status === 404 || response.status === 401 || response.status === 403) {
15532
+ return { ok: false, reason: "unsupported" };
15533
+ }
15534
+ if (response.status >= 200 && response.status < 300) {
15535
+ const row = response.data.session;
15536
+ if (!row || typeof row.seq !== "number" || typeof row.metadata !== "string") {
15537
+ return { ok: false, reason: "unsupported" };
15538
+ }
15539
+ const metadata = decrypt(encryptionKey, encryptionVariant, decodeBase64(row.metadata));
15540
+ if (!metadata) return { ok: false, reason: "unavailable" };
15541
+ const agentState = row.agentState ? decrypt(encryptionKey, encryptionVariant, decodeBase64(row.agentState)) : null;
15542
+ return {
15543
+ ok: true,
15544
+ session: { seq: row.seq, metadata, metadataVersion: row.metadataVersion, agentState, agentStateVersion: row.agentStateVersion }
15545
+ };
15546
+ }
15547
+ logger.debug(`[API] getSession ${sessionId}: status ${response.status} (attempt ${attempt + 1}/${attempts})`);
15548
+ } catch (error) {
15549
+ logger.debug(`[API] getSession ${sessionId} failed (attempt ${attempt + 1}/${attempts}):`, error);
15550
+ }
15551
+ const delayMs = delays[Math.min(attempt, delays.length - 1)];
15552
+ if (attempt < attempts - 1 && delayMs > 0) await new Promise((r) => setTimeout(r, delayMs));
15553
+ }
15554
+ return { ok: false, reason: "unavailable" };
15555
+ }
15313
15556
  /** Clear the server-owned archive tombstone before an intentional resume.
15314
15557
  * Older servers return 404 because the endpoint does not exist; that is a
15315
15558
  * compatible no-op because those servers also have no durable tombstone. */
@@ -15401,4 +15644,4 @@ const RawJSONLinesSchema = z$1.discriminatedUnion("type", [
15401
15644
  }).passthrough()
15402
15645
  ]);
15403
15646
 
15404
- export { detectCLIAvailability as $, ApiClient as A, deriveLocalCliUpdateSummary as B, updateSettings as C, encodeBase64Url as D, decodeBase64 as E, writeCredentialsLegacy as F, writeCredentialsDataKey as G, readCredentialsForConfiguredRelay as H, InvalidateSync as I, encrypt as J, getLocalHappyAgentCredentialPath as K, readLocalHappyAgentCredentials as L, MAX_CHAT_ATTACHMENT_SOURCE_BYTES as M, decryptWithDataKey as N, decryptLegacy as O, extractCompleteLines as P, decideBackfill as Q, RawJSONLinesSchema as R, parseTerminalHookPayload as S, tmuxArgs as T, scrubTmuxClientEnv as U, decideMirrorBinding as V, persistSession as W, MIRROR_BACKFILL_LINES_DEFAULT as X, mirrorLineKey as Y, mirrorLocalId as Z, detectResumeSupport as _, ApiSessionClient as a, acquireDaemonLock as a0, summarizeSpawnSessionForLog as a1, deletePersistedSessions as a2, decrypt as a3, writeDaemonState as a4, fetchCliUpdateState as a5, resolveCliUpdateCheckInterval as a6, DEFAULT_CLI_UPDATE_CHECK_INTERVAL_MS as a7, releaseDaemonLock as a8, isValidSessionId as a9, listVhTerminals as aa, isValidTerminalId as ab, readVhTerminal as ac, sendToVhTerminal as ad, startOfflineReconnection as ae, clearCredentials as af, clearMachineId as ag, SandboxConfigSchema as ah, CodexAppServerClient as ai, getLatestDaemonLog as aj, persistence as ak, api as al, createEnvelope as b, configuration as c, contentLogMetadata as d, errorLogMetadata as e, packageJson as f, delay as g, connectionState as h, readSettings as i, encodeBase64 as j, ensurePrivateDirectorySync as k, logger as l, hardenPrivateFileSync as m, readDaemonState as n, clearDaemonState as o, projectPath as p, getProjectPath as q, readPersistedSessions as r, initializeSandbox as s, wrapCommand as t, AsyncLock as u, ensurePrivateDirectory as v, writePrivateFileSync as w, hardenPrivateFile as x, readCredentials as y, credentialRelayProblem as z };
15647
+ export { detectResumeSupport as $, ApiClient as A, deriveLocalCliUpdateSummary as B, updateSettings as C, encodeBase64Url as D, decodeBase64 as E, writeCredentialsLegacy as F, writeCredentialsDataKey as G, readCredentialsForConfiguredRelay as H, InvalidateSync as I, encrypt as J, isClaudeSessionId as K, getLocalHappyAgentCredentialPath as L, MAX_CHAT_ATTACHMENT_SOURCE_BYTES as M, readLocalHappyAgentCredentials as N, decryptWithDataKey as O, decryptLegacy as P, extractCompleteLines as Q, RawJSONLinesSchema as R, decideBackfill as S, parseTerminalHookPayload as T, tmuxArgs as U, scrubTmuxClientEnv as V, decideMirrorBinding as W, persistSession as X, MIRROR_BACKFILL_LINES_DEFAULT as Y, mirrorLineKey as Z, mirrorLocalId as _, ApiSessionClient as a, detectCLIAvailability as a0, acquireDaemonLock as a1, summarizeSpawnSessionForLog as a2, deletePersistedSessions as a3, decrypt as a4, writeDaemonState as a5, fetchCliUpdateState as a6, resolveCliUpdateCheckInterval as a7, DEFAULT_CLI_UPDATE_CHECK_INTERVAL_MS as a8, releaseDaemonLock as a9, isValidSessionId as aa, listVhTerminals as ab, isValidTerminalId as ac, readVhTerminal as ad, sendToVhTerminal as ae, startOfflineReconnection as af, clearCredentials as ag, clearMachineId as ah, SandboxConfigSchema as ai, CodexAppServerClient as aj, getLatestDaemonLog as ak, persistence as al, api as am, createEnvelope as b, configuration as c, contentLogMetadata as d, errorLogMetadata as e, packageJson as f, delay as g, connectionState as h, readSettings as i, encodeBase64 as j, ensurePrivateDirectorySync as k, logger as l, hardenPrivateFileSync as m, readDaemonState as n, clearDaemonState as o, projectPath as p, getProjectPath as q, readPersistedSessions as r, initializeSandbox as s, wrapCommand as t, AsyncLock as u, ensurePrivateDirectory as v, writePrivateFileSync as w, hardenPrivateFile as x, readCredentials as y, credentialRelayProblem as z };