very-happy-cli 0.2.90 → 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-CnYZFdd0.cjs → AcpBackend-BP7id8L6.cjs} +1 -1
  2. package/dist/{AcpBackend-BV4wSnJ3.mjs → AcpBackend-DDEsgc1c.mjs} +1 -1
  3. package/dist/{AcpSessionManager-Cyne6Rmg.mjs → AcpSessionManager-C29t6wb_.mjs} +1 -1
  4. package/dist/{AcpSessionManager-BPBdrrME.cjs → AcpSessionManager-Cys1Hsb8.cjs} +1 -1
  5. package/dist/{config-CgXitj0f.cjs → config-B77vhdwT.cjs} +2 -2
  6. package/dist/{config-BEdyyciP.mjs → config-CJQw0z1o.mjs} +2 -2
  7. package/dist/{index-an09rejO.mjs → index-8LDXVYWi.mjs} +409 -51
  8. package/dist/{index-4YmVbx7j.cjs → index-B63Vjt4W.cjs} +425 -67
  9. package/dist/{index-Dfu5uvjk.mjs → index-BP9pfzdZ.mjs} +5 -5
  10. package/dist/{index-B1wTVXjt.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-DDpVxk8-.cjs → installTerminalHooks-BBc2nij4.cjs} +2 -2
  14. package/dist/{installTerminalHooks-C-OgZr5k.mjs → installTerminalHooks-DDRrUG_n.mjs} +2 -2
  15. package/dist/lib.cjs +1 -1
  16. package/dist/lib.d.cts +103 -3
  17. package/dist/lib.d.mts +103 -3
  18. package/dist/lib.mjs +1 -1
  19. package/dist/{mcp-Du-N0qlK.mjs → mcp-CYJAl5pk.mjs} +2 -2
  20. package/dist/{mcp-B6Nq-RUL.cjs → mcp-DbYXyw1U.cjs} +2 -2
  21. package/dist/{runGemini-ChG7cGMC.cjs → runGemini-BDSWSID6.cjs} +4 -4
  22. package/dist/{runGemini-DBr_TiTL.mjs → runGemini-DHYs_Beq.mjs} +4 -4
  23. package/dist/{runOpenClaw-D2jtVgpC.cjs → runOpenClaw-DC9gb1bd.cjs} +3 -3
  24. package/dist/{runOpenClaw-Cbde8t92.mjs → runOpenClaw-DTaXxvNd.mjs} +3 -3
  25. package/dist/{send-Bjen1TUE.cjs → send-BEdGwmA-.cjs} +2 -2
  26. package/dist/{send-BHliX59m.mjs → send-BQSaVz9r.mjs} +2 -2
  27. package/dist/{spawn-CH1Rq-LW.cjs → spawn-CK1ma_qc.cjs} +2 -2
  28. package/dist/{spawn-JxaPHAoP.mjs → spawn-DS-Uz_Hw.mjs} +2 -2
  29. package/dist/{types-DS3i_5LV.mjs → types-B_8X_rZk.mjs} +572 -93
  30. package/dist/{types-BNGeiP1C.cjs → types-CjlB_fKT.cjs} +573 -93
  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.90";
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.90",
168
- "very-happy-tools-x64-darwin": "0.2.90",
169
- "very-happy-tools-arm64-linux": "0.2.90",
170
- "very-happy-tools-x64-linux": "0.2.90",
171
- "very-happy-tools-arm64-win32": "0.2.90",
172
- "very-happy-tools-x64-win32": "0.2.90"
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:*",
@@ -661,9 +661,27 @@ const sessionToolCallStartEventSchema = z__namespace.object({
661
661
  description: z__namespace.string(),
662
662
  args: z__namespace.record(z__namespace.string(), z__namespace.unknown())
663
663
  });
664
+ const sessionSubagentResultSchema = z__namespace.object({
665
+ text: z__namespace.string(),
666
+ truncated: z__namespace.boolean().optional()
667
+ });
668
+ const sessionSubagentUsageSchema = z__namespace.object({
669
+ toolUses: z__namespace.number().optional(),
670
+ totalTokens: z__namespace.number().optional(),
671
+ durationMs: z__namespace.number().optional()
672
+ });
664
673
  const sessionToolCallEndEventSchema = z__namespace.object({
665
674
  t: z__namespace.literal("tool-call-end"),
666
- call: z__namespace.string()
675
+ call: z__namespace.string(),
676
+ // Foreground Agent/Task completion: the sub-agent's final report + run
677
+ // totals (SDK `tool_use_result`). Absent for ordinary tools and for the
678
+ // background stub ("Async agent launched").
679
+ result: sessionSubagentResultSchema.extend({
680
+ isError: z__namespace.boolean().optional(),
681
+ stats: sessionSubagentUsageSchema.extend({
682
+ toolStats: z__namespace.record(z__namespace.string(), z__namespace.unknown()).optional()
683
+ }).optional()
684
+ }).optional()
667
685
  });
668
686
  const sessionFileEventSchema = z__namespace.object({
669
687
  t: z__namespace.literal("file"),
@@ -682,7 +700,10 @@ const sessionTurnStartEventSchema = z__namespace.object({
682
700
  });
683
701
  const sessionStartEventSchema = z__namespace.object({
684
702
  t: z__namespace.literal("start"),
685
- title: z__namespace.string().optional()
703
+ title: z__namespace.string().optional(),
704
+ // B-260-P2: identity from SDK task_started / Agent tool input.
705
+ description: z__namespace.string().optional(),
706
+ subagentType: z__namespace.string().optional()
686
707
  });
687
708
  const sessionTurnEndStatusSchema = z__namespace.enum(["completed", "failed", "cancelled"]);
688
709
  const sessionTurnUsageSchema = z__namespace.object({
@@ -705,7 +726,21 @@ const sessionTurnEndEventSchema = z__namespace.object({
705
726
  usage: sessionTurnUsageSchema.optional()
706
727
  });
707
728
  const sessionStopEventSchema = z__namespace.object({
708
- t: z__namespace.literal("stop")
729
+ t: z__namespace.literal("stop"),
730
+ // B-260-P2: from SDK task_notification. A second `stop` for the same
731
+ // subagent may follow carrying `result` once the notification user message
732
+ // (which holds the <result> block) arrives; consumers fold by subagent id.
733
+ status: z__namespace.enum(["completed", "failed", "stopped"]).optional(),
734
+ result: sessionSubagentResultSchema.optional(),
735
+ usage: sessionSubagentUsageSchema.optional()
736
+ });
737
+ const sessionProgressEventSchema = z__namespace.object({
738
+ t: z__namespace.literal("progress"),
739
+ toolUses: z__namespace.number(),
740
+ lastTool: z__namespace.string().optional(),
741
+ totalTokens: z__namespace.number().optional(),
742
+ durationMs: z__namespace.number().optional(),
743
+ summary: z__namespace.string().optional()
709
744
  });
710
745
  const sessionEventSchema = z__namespace.discriminatedUnion("t", [
711
746
  sessionTextEventSchema,
@@ -716,7 +751,8 @@ const sessionEventSchema = z__namespace.discriminatedUnion("t", [
716
751
  sessionTurnStartEventSchema,
717
752
  sessionStartEventSchema,
718
753
  sessionTurnEndEventSchema,
719
- sessionStopEventSchema
754
+ sessionStopEventSchema,
755
+ sessionProgressEventSchema
720
756
  ]);
721
757
  const sessionEnvelopeSchema = z__namespace.object({
722
758
  id: z__namespace.string(),
@@ -747,7 +783,14 @@ const sessionEnvelopeSchema = z__namespace.object({
747
783
  path: ["role"]
748
784
  });
749
785
  }
750
- if ((envelope.ev.t === "start" || envelope.ev.t === "stop") && envelope.role !== "agent") {
786
+ if (envelope.ev.t === "progress" && !envelope.subagent) {
787
+ ctx.addIssue({
788
+ code: z__namespace.ZodIssueCode.custom,
789
+ message: "progress events must carry a subagent",
790
+ path: ["subagent"]
791
+ });
792
+ }
793
+ if ((envelope.ev.t === "start" || envelope.ev.t === "stop" || envelope.ev.t === "progress") && envelope.role !== "agent") {
751
794
  ctx.addIssue({
752
795
  code: z__namespace.ZodIssueCode.custom,
753
796
  message: `${envelope.ev.t} events must use role "agent"`,
@@ -837,7 +880,11 @@ const UpdateSessionBodySchema = z__namespace.object({
837
880
  t: z__namespace.literal("update-session"),
838
881
  id: z__namespace.string(),
839
882
  metadata: VersionedEncryptedValueSchema.nullish(),
840
- 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()
841
888
  });
842
889
  const VersionedMachineEncryptedValueSchema = z__namespace.object({
843
890
  version: z__namespace.number(),
@@ -976,7 +1023,9 @@ const WebTerminalListItemSchema = z.z.object({
976
1023
  mirrorSessionId: z.z.string().optional(),
977
1024
  /** B-150: auto-restored after a restart (ms epoch) — the directory and the
978
1025
  * conversation carried over, the processes did not. Cleared once opened. */
979
- 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()
980
1029
  });
981
1030
  const ClosedTerminalRecordSchema = z.z.object({
982
1031
  id: z.z.string(),
@@ -990,6 +1039,9 @@ const ClosedTerminalRecordSchema = z.z.object({
990
1039
  /** B-149: 'daemon-gap' = died while no daemon was watching (restart/reboot);
991
1040
  * absent means an ordinary observed close. */
992
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(),
993
1045
  closedAt: z.z.number()
994
1046
  });
995
1047
  z.z.object({
@@ -1034,6 +1086,15 @@ z.z.object({
1034
1086
  * ignore the field; old daemons never write it (web renders nothing).
1035
1087
  */
1036
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(),
1037
1098
  /** Relay-owned CLI compatibility/update policy last checked by this daemon. */
1038
1099
  cliUpdate: CliUpdateStateSchema.optional()
1039
1100
  });
@@ -1484,7 +1545,7 @@ class RpcHandlerManager {
1484
1545
  }
1485
1546
  }
1486
1547
 
1487
- 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-BNGeiP1C.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))));
1488
1549
  function projectPath() {
1489
1550
  const path = path$1.resolve(__dirname$1, "..");
1490
1551
  return path;
@@ -2171,6 +2232,69 @@ function shouldReconnect() {
2171
2232
  return true;
2172
2233
  }
2173
2234
 
2235
+ const SUBAGENT_RESULT_MAX_CHARS = 16384;
2236
+ const SUBAGENT_PROGRESS_MIN_INTERVAL_MS = 5e3;
2237
+ function capSubagentText(text, max = SUBAGENT_RESULT_MAX_CHARS) {
2238
+ const trimmed = text.trim();
2239
+ if (trimmed.length <= max) return { text: trimmed };
2240
+ return { text: trimmed.slice(0, max), truncated: true };
2241
+ }
2242
+ function toolUseResultToText(toolUseResult) {
2243
+ if (!toolUseResult || typeof toolUseResult !== "object") return null;
2244
+ const content = toolUseResult.content;
2245
+ if (typeof content === "string") return content;
2246
+ if (!Array.isArray(content)) return null;
2247
+ const parts = [];
2248
+ for (const block of content) {
2249
+ if (block && typeof block === "object" && block.type === "text" && typeof block.text === "string") {
2250
+ parts.push(block.text);
2251
+ }
2252
+ }
2253
+ return parts.length > 0 ? parts.join("\n") : null;
2254
+ }
2255
+ function toolUseResultStatus(toolUseResult) {
2256
+ if (!toolUseResult || typeof toolUseResult !== "object") return "unknown";
2257
+ const status = toolUseResult.status;
2258
+ if (status === "async_launched") return "async_launched";
2259
+ if (status === "completed") return "completed";
2260
+ return "unknown";
2261
+ }
2262
+ function toolUseResultStats(toolUseResult) {
2263
+ if (!toolUseResult || typeof toolUseResult !== "object") return void 0;
2264
+ const r = toolUseResult;
2265
+ const stats = {};
2266
+ if (typeof r.totalToolUseCount === "number") stats.toolUses = r.totalToolUseCount;
2267
+ if (typeof r.totalTokens === "number") stats.totalTokens = r.totalTokens;
2268
+ if (typeof r.totalDurationMs === "number") stats.durationMs = r.totalDurationMs;
2269
+ if (r.toolStats && typeof r.toolStats === "object") stats.toolStats = r.toolStats;
2270
+ return Object.keys(stats).length > 0 ? stats : void 0;
2271
+ }
2272
+ function parseTaskNotificationText(text) {
2273
+ if (!/<task-notification>/i.test(text)) return null;
2274
+ const pick = (tag) => text.match(new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`, "i"))?.[1] ?? null;
2275
+ const rawStatus = pick("status")?.trim().toLowerCase() ?? null;
2276
+ const status = rawStatus === "completed" || rawStatus === "failed" || rawStatus === "stopped" ? rawStatus : null;
2277
+ let resultText = pick("result");
2278
+ let result = null;
2279
+ if (resultText !== null) {
2280
+ resultText = resultText.replace(/^\s*\[harness:[^\]]*\]\s*/i, "").replace(/<usage>[\s\S]*?<\/usage>/gi, "").trim();
2281
+ if (resultText.length > 0) result = capSubagentText(resultText);
2282
+ }
2283
+ return {
2284
+ toolUseId: pick("tool-use-id")?.trim() || null,
2285
+ taskId: pick("task-id")?.trim() || null,
2286
+ status,
2287
+ summary: pick("summary")?.trim() || null,
2288
+ result
2289
+ };
2290
+ }
2291
+ function shouldEmitProgress(previous, toolUses, now) {
2292
+ if (!previous) return true;
2293
+ if (previous.lastToolUses === toolUses) return false;
2294
+ return now - previous.lastAt >= SUBAGENT_PROGRESS_MIN_INTERVAL_MS;
2295
+ }
2296
+
2297
+ const SUBAGENT_BUFFER_MAX_MESSAGES = 100;
2174
2298
  function isSubagentTool(name) {
2175
2299
  return name === "Task" || name === "Agent";
2176
2300
  }
@@ -2239,6 +2363,7 @@ function bufferSubagentMessage(state, subagent, message) {
2239
2363
  const buffer = getBufferedSubagentMessages(state);
2240
2364
  const queue = buffer.get(subagent) ?? [];
2241
2365
  queue.push(message);
2366
+ while (queue.length > SUBAGENT_BUFFER_MAX_MESSAGES) queue.shift();
2242
2367
  buffer.set(subagent, queue);
2243
2368
  }
2244
2369
  function consumeBufferedSubagentMessages(state, subagent) {
@@ -2259,6 +2384,31 @@ function getActiveSubagents(state) {
2259
2384
  }
2260
2385
  return state.activeSubagents;
2261
2386
  }
2387
+ function getAgentToolCalls(state) {
2388
+ if (!state.agentToolCalls) state.agentToolCalls = /* @__PURE__ */ new Set();
2389
+ return state.agentToolCalls;
2390
+ }
2391
+ function getSubagentMeta(state) {
2392
+ if (!state.subagentMeta) state.subagentMeta = /* @__PURE__ */ new Map();
2393
+ return state.subagentMeta;
2394
+ }
2395
+ function getStubSubagents(state) {
2396
+ if (!state.stubSubagents) state.stubSubagents = /* @__PURE__ */ new Set();
2397
+ return state.stubSubagents;
2398
+ }
2399
+ function getProgressThrottle(state) {
2400
+ if (!state.progressThrottle) state.progressThrottle = /* @__PURE__ */ new Map();
2401
+ return state.progressThrottle;
2402
+ }
2403
+ function nowOf(state) {
2404
+ return state.now ? state.now() : Date.now();
2405
+ }
2406
+ function rememberSubagentMeta(state, subagent, meta) {
2407
+ const existing = getSubagentMeta(state).get(subagent) ?? {};
2408
+ const description = typeof meta.description === "string" && meta.description.trim() ? meta.description.trim() : existing.description;
2409
+ const subagentType = typeof meta.subagentType === "string" && meta.subagentType.trim() ? meta.subagentType.trim() : existing.subagentType;
2410
+ getSubagentMeta(state).set(subagent, { ...description ? { description } : {}, ...subagentType ? { subagentType } : {} });
2411
+ }
2262
2412
  function pickAssistantUsage(message) {
2263
2413
  if (message.type !== "assistant") return void 0;
2264
2414
  const usage = message.message?.usage;
@@ -2435,13 +2585,26 @@ function maybeEmitSubagentStart(state, turn, subagent, envelopes) {
2435
2585
  return;
2436
2586
  }
2437
2587
  const title = getSubagentTitles(state).get(subagent);
2588
+ const meta = getSubagentMeta(state).get(subagent);
2438
2589
  envelopes.push(createEnvelope("agent", {
2439
2590
  t: "start",
2440
- ...title ? { title } : {}
2591
+ ...title ? { title } : {},
2592
+ ...meta?.description ? { description: meta.description } : {},
2593
+ ...meta?.subagentType ? { subagentType: meta.subagentType } : {}
2441
2594
  }, { turn, subagent }));
2442
2595
  started.add(subagent);
2443
2596
  getActiveSubagents(state).add(subagent);
2444
2597
  }
2598
+ function emitSubagentStopWithStatus(state, turn, subagent, envelopes, payload) {
2599
+ envelopes.push(createEnvelope("agent", {
2600
+ t: "stop",
2601
+ ...payload.status ? { status: payload.status } : {},
2602
+ ...payload.result ? { result: payload.result } : {},
2603
+ ...payload.usage ? { usage: payload.usage } : {}
2604
+ }, { ...turn ? { turn } : {}, subagent }));
2605
+ getActiveSubagents(state).delete(subagent);
2606
+ getStartedSubagents(state).delete(subagent);
2607
+ }
2445
2608
  function maybeEmitSubagentStop(state, turn, subagent, envelopes) {
2446
2609
  const active = getActiveSubagents(state);
2447
2610
  if (!active.has(subagent)) {
@@ -2453,8 +2616,6 @@ function maybeEmitSubagentStop(state, turn, subagent, envelopes) {
2453
2616
  function clearSubagentTracking(state) {
2454
2617
  getUuidToProviderSubagent(state).clear();
2455
2618
  getTaskPromptToSubagents(state).clear();
2456
- getProviderSubagentToSessionSubagent(state).clear();
2457
- getSubagentTitles(state).clear();
2458
2619
  getBufferedSubagentMessages(state).clear();
2459
2620
  getHiddenParentToolCalls(state).clear();
2460
2621
  getStartedSubagents(state).clear();
@@ -2515,6 +2676,58 @@ function closeClaudeTurnWithStatus(state, status, meta) {
2515
2676
  function mapClaudeLogMessageToSessionEnvelopes(message, state) {
2516
2677
  return mapClaudeLogMessageToSessionEnvelopesInternal(message, state);
2517
2678
  }
2679
+ function mapTaskLifecycleSystemMessage(message, state, envelopes) {
2680
+ const raw = message;
2681
+ const subtype = typeof raw.subtype === "string" ? raw.subtype : "";
2682
+ if (!subtype.startsWith("task_")) return;
2683
+ if (raw.skip_transcript === true) return;
2684
+ const toolUseId = typeof raw.tool_use_id === "string" && raw.tool_use_id.length > 0 ? raw.tool_use_id : null;
2685
+ if (!toolUseId || !getAgentToolCalls(state).has(toolUseId)) return;
2686
+ const subagent = ensureSessionSubagentIdForProviderSubagent(state, toolUseId);
2687
+ const usage = raw.usage && typeof raw.usage === "object" ? {
2688
+ ...typeof raw.usage.tool_uses === "number" ? { toolUses: raw.usage.tool_uses } : {},
2689
+ ...typeof raw.usage.total_tokens === "number" ? { totalTokens: raw.usage.total_tokens } : {},
2690
+ ...typeof raw.usage.duration_ms === "number" ? { durationMs: raw.usage.duration_ms } : {}
2691
+ } : void 0;
2692
+ if (subtype === "task_started") {
2693
+ rememberSubagentMeta(state, subagent, { description: raw.description, subagentType: raw.subagent_type });
2694
+ const turn = ensureTurn(state, envelopes);
2695
+ getStartedSubagents(state).delete(subagent);
2696
+ maybeEmitSubagentStart(state, turn, subagent, envelopes);
2697
+ return;
2698
+ }
2699
+ if (subtype === "task_progress") {
2700
+ const toolUses = typeof raw.usage?.tool_uses === "number" ? raw.usage.tool_uses : 0;
2701
+ const throttle = getProgressThrottle(state);
2702
+ const now = nowOf(state);
2703
+ if (!shouldEmitProgress(throttle.get(subagent), toolUses, now)) return;
2704
+ throttle.set(subagent, { lastAt: now, lastToolUses: toolUses });
2705
+ const turn = ensureTurn(state, envelopes);
2706
+ maybeEmitSubagentStart(state, turn, subagent, envelopes);
2707
+ envelopes.push(createEnvelope("agent", {
2708
+ t: "progress",
2709
+ toolUses,
2710
+ ...typeof raw.last_tool_name === "string" && raw.last_tool_name ? { lastTool: raw.last_tool_name } : {},
2711
+ ...usage?.totalTokens !== void 0 ? { totalTokens: usage.totalTokens } : {},
2712
+ ...usage?.durationMs !== void 0 ? { durationMs: usage.durationMs } : {},
2713
+ ...typeof raw.summary === "string" && raw.summary.trim() ? { summary: raw.summary.trim() } : {}
2714
+ }, { turn, subagent }));
2715
+ return;
2716
+ }
2717
+ if (subtype === "task_notification") {
2718
+ const status = raw.status === "completed" || raw.status === "failed" || raw.status === "stopped" ? raw.status : "completed";
2719
+ emitSubagentStopWithStatus(state, state.currentTurnId ?? void 0, subagent, envelopes, {
2720
+ status,
2721
+ ...usage ? { usage } : {}
2722
+ });
2723
+ getStubSubagents(state).delete(subagent);
2724
+ getProgressThrottle(state).delete(subagent);
2725
+ return;
2726
+ }
2727
+ if (subtype === "task_updated" && raw.patch?.is_backgrounded === true) {
2728
+ getStubSubagents(state).add(subagent);
2729
+ }
2730
+ }
2518
2731
  function mapClaudeLogMessageToSessionEnvelopesInternal(message, state) {
2519
2732
  const envelopes = [];
2520
2733
  const claudeUuid = pickUuid(message);
@@ -2535,6 +2748,7 @@ function mapClaudeLogMessageToSessionEnvelopesInternal(message, state) {
2535
2748
  };
2536
2749
  }
2537
2750
  if (message.type === "system") {
2751
+ mapTaskLifecycleSystemMessage(message, state, envelopes);
2538
2752
  return {
2539
2753
  currentTurnId: state.currentTurnId,
2540
2754
  envelopes
@@ -2576,6 +2790,9 @@ function mapClaudeLogMessageToSessionEnvelopesInternal(message, state) {
2576
2790
  queueTaskPromptSubagent(state, prompt, call);
2577
2791
  }
2578
2792
  setSubagentTitle(state, sessionSubagentForCall, pickTaskTitle(block.input) ?? prompt);
2793
+ getAgentToolCalls(state).add(call);
2794
+ const input = block.input ?? {};
2795
+ rememberSubagentMeta(state, sessionSubagentForCall, { description: input.description, subagentType: input.subagent_type });
2579
2796
  }
2580
2797
  if (shouldHideParentToolCall(name)) {
2581
2798
  getHiddenParentToolCalls(state).add(call);
@@ -2620,6 +2837,18 @@ function mapClaudeLogMessageToSessionEnvelopesInternal(message, state) {
2620
2837
  maybeEmitSubagentStart(state, turnId2, subagent, envelopes);
2621
2838
  envelopes.push(createEnvelope("agent", { t: "text", text: message.message.content }, { turn: turnId2, subagent, claudeUuid }));
2622
2839
  } else {
2840
+ const origin = message.origin;
2841
+ if (origin?.kind === "task-notification" || /<task-notification>/i.test(message.message.content)) {
2842
+ const fields = parseTaskNotificationText(message.message.content);
2843
+ const providerId = fields?.toolUseId ?? null;
2844
+ const subagentForNotification = providerId && getAgentToolCalls(state).has(providerId) ? getSessionSubagentIdForProviderSubagent(state, providerId) : void 0;
2845
+ if (fields && subagentForNotification && (fields.result || fields.status)) {
2846
+ emitSubagentStopWithStatus(state, state.currentTurnId ?? void 0, subagentForNotification, envelopes, {
2847
+ ...fields.status ? { status: fields.status } : {},
2848
+ ...fields.result ? { result: fields.result } : {}
2849
+ });
2850
+ }
2851
+ }
2623
2852
  closeTurn(state, "completed", envelopes);
2624
2853
  envelopes.push(createEnvelope("user", { t: "text", text: message.message.content }, { claudeUuid }));
2625
2854
  }
@@ -2650,6 +2879,31 @@ function mapClaudeLogMessageToSessionEnvelopesInternal(message, state) {
2650
2879
  getHiddenParentToolCalls(state).delete(block.tool_use_id);
2651
2880
  continue;
2652
2881
  }
2882
+ if (sessionSubagentForToolResult && getAgentToolCalls(state).has(block.tool_use_id)) {
2883
+ const toolUseResult = message.tool_use_result;
2884
+ const resultStatus = toolUseResultStatus(toolUseResult);
2885
+ if (resultStatus === "async_launched") {
2886
+ getStubSubagents(state).add(sessionSubagentForToolResult);
2887
+ envelopes.push(createEnvelope("agent", { t: "tool-call-end", call: block.tool_use_id }, { turn: turnId, subagent }));
2888
+ continue;
2889
+ }
2890
+ if (resultStatus === "completed") {
2891
+ const text = toolUseResultToText(toolUseResult);
2892
+ const stats = toolUseResultStats(toolUseResult);
2893
+ const result = text ? capSubagentText(text) : void 0;
2894
+ emitSubagentStopWithStatus(state, turnId, sessionSubagentForToolResult, envelopes, {
2895
+ status: "completed",
2896
+ ...result ? { result } : {},
2897
+ ...stats ? { usage: { toolUses: stats.toolUses, totalTokens: stats.totalTokens, durationMs: stats.durationMs } } : {}
2898
+ });
2899
+ envelopes.push(createEnvelope("agent", {
2900
+ t: "tool-call-end",
2901
+ call: block.tool_use_id,
2902
+ ...result ? { result: { ...result, ...stats ? { stats } : {} } } : {}
2903
+ }, { turn: turnId, subagent }));
2904
+ continue;
2905
+ }
2906
+ }
2653
2907
  if (sessionSubagentForToolResult) {
2654
2908
  maybeEmitSubagentStop(state, turnId, sessionSubagentForToolResult, envelopes);
2655
2909
  }
@@ -2867,10 +3121,19 @@ class ApiSessionClient extends node_events.EventEmitter {
2867
3121
  directInboundLocalIds = /* @__PURE__ */ new Set();
2868
3122
  routedInboundLocalIds = /* @__PURE__ */ new Set();
2869
3123
  handoverInFlight = null;
2870
- 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) {
2871
3130
  super();
2872
3131
  this.token = token;
2873
3132
  this.sessionId = session.id;
3133
+ if (opts?.initialSeq !== void 0 && opts.initialSeq >= 0) {
3134
+ this.lastSeq = opts.initialSeq;
3135
+ this.awaitingInitialFetch = true;
3136
+ }
2874
3137
  this.metadata = session.metadata;
2875
3138
  this.metadataVersion = session.metadataVersion;
2876
3139
  this.agentState = session.agentState;
@@ -2930,7 +3193,7 @@ class ApiSessionClient extends node_events.EventEmitter {
2930
3193
  }
2931
3194
  if (data.body.t === "new-message") {
2932
3195
  const messageSeq = data.body.message?.seq;
2933
- if (this.lastSeq === 0) {
3196
+ if (this.lastSeq === 0 || this.awaitingInitialFetch) {
2934
3197
  this.receiveSync.invalidate();
2935
3198
  return;
2936
3199
  }
@@ -3226,6 +3489,9 @@ class ApiSessionClient extends node_events.EventEmitter {
3226
3489
  if (message.localId && this.directInboundLocalIds.delete(message.localId)) {
3227
3490
  continue;
3228
3491
  }
3492
+ if (message.localId && this.routedInboundLocalIds.has(message.localId)) {
3493
+ continue;
3494
+ }
3229
3495
  if (message.content?.t !== "encrypted") {
3230
3496
  continue;
3231
3497
  }
@@ -3255,6 +3521,7 @@ class ApiSessionClient extends node_events.EventEmitter {
3255
3521
  break;
3256
3522
  }
3257
3523
  }
3524
+ this.awaitingInitialFetch = false;
3258
3525
  }
3259
3526
  static MAX_OUTBOX_BATCH_SIZE = 50;
3260
3527
  async flushOutbox() {
@@ -4502,6 +4769,101 @@ function registerTodoHandlers(manager) {
4502
4769
  });
4503
4770
  }
4504
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
+
4505
4867
  var xtermHeadless = {};
4506
4868
 
4507
4869
  var hasRequiredXtermHeadless;
@@ -9552,63 +9914,6 @@ function sendTerminalNotification(opts) {
9552
9914
  });
9553
9915
  }
9554
9916
 
9555
- const LIVE_SNAPSHOT_TTL_MS = 14 * 24 * 60 * 60 * 1e3;
9556
- const LIVE_SNAPSHOT_MAX = 100;
9557
- function sanitizeLiveSnapshot(raw, now, ttlMs = LIVE_SNAPSHOT_TTL_MS, max = LIVE_SNAPSHOT_MAX) {
9558
- const out = /* @__PURE__ */ new Map();
9559
- if (!raw || typeof raw !== "object" || Array.isArray(raw)) return out;
9560
- const rows = [];
9561
- for (const [id, value] of Object.entries(raw)) {
9562
- if (!id || !value || typeof value !== "object") continue;
9563
- const { title, cwd, seenAt } = value;
9564
- if (typeof seenAt !== "number" || !Number.isFinite(seenAt)) continue;
9565
- if (now - seenAt >= ttlMs) continue;
9566
- rows.push([id, {
9567
- title: typeof title === "string" && title.trim() ? title : void 0,
9568
- cwd: typeof cwd === "string" && cwd ? cwd : void 0,
9569
- seenAt
9570
- }]);
9571
- }
9572
- rows.sort((a, b) => b[1].seenAt - a[1].seenAt);
9573
- for (const [id, info] of rows.slice(0, max)) out.set(id, info);
9574
- return out;
9575
- }
9576
- function serializeLiveSnapshot(map, max = LIVE_SNAPSHOT_MAX) {
9577
- const rows = [...map.entries()].sort((a, b) => b[1].seenAt - a[1].seenAt).slice(0, max);
9578
- const out = {};
9579
- for (const [id, info] of rows) out[id] = info;
9580
- return out;
9581
- }
9582
- function liveSnapshotChanged(prev, next) {
9583
- if (prev.size !== next.size) return true;
9584
- for (const [id, info] of next) {
9585
- const before = prev.get(id);
9586
- if (!before) return true;
9587
- if (before.title !== info.title || before.cwd !== info.cwd) return true;
9588
- }
9589
- return false;
9590
- }
9591
- 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}$/;
9592
- function isClaudeSessionId(value) {
9593
- return typeof value === "string" && CLAUDE_SESSION_ID_RE.test(value);
9594
- }
9595
- function pickMirrorForTerminal(sessions, terminalId) {
9596
- if (!terminalId) return null;
9597
- let best = null;
9598
- for (const [id, session] of Object.entries(sessions || {})) {
9599
- const meta = session?.metadata;
9600
- if (!meta || meta.terminalId !== terminalId) continue;
9601
- const savedAt = typeof session.savedAt === "number" ? session.savedAt : 0;
9602
- if (best && savedAt <= best.savedAt) continue;
9603
- best = {
9604
- id,
9605
- savedAt,
9606
- claude: isClaudeSessionId(meta.claudeSessionId) ? meta.claudeSessionId : void 0
9607
- };
9608
- }
9609
- return best ? { sessionId: best.id, claudeSessionId: best.claude } : null;
9610
- }
9611
-
9612
9917
  const AUTO_RESTORE_DEFAULT_MAX = 6;
9613
9918
  const AUTO_RESTORE_DEFAULT_WINDOW_HOURS = 24;
9614
9919
  const AUTO_RESTORE_HARD_MAX = 20;
@@ -9667,6 +9972,8 @@ function selectAutoRestore(candidates, opts) {
9667
9972
  }
9668
9973
  plans.push({
9669
9974
  terminalId: c.id,
9975
+ ...c.tags !== void 0 ? { tags: c.tags } : {},
9976
+ ...c.manual ? { manual: true } : {},
9670
9977
  cwd: c.cwd,
9671
9978
  title: c.title,
9672
9979
  claudeSessionId: c.claudeSessionId,
@@ -9715,10 +10022,11 @@ function sanitizeClosedTerminals(raw, max = CLOSED_TERMINALS_MAX) {
9715
10022
  const out = [];
9716
10023
  for (const item of raw) {
9717
10024
  if (!item || typeof item !== "object") continue;
9718
- const { id, title, cwd, mirrorSessionId, claudeSessionId, reason, closedAt } = item;
10025
+ const { id, title, cwd, mirrorSessionId, claudeSessionId, reason, closedAt, tags, manual } = item;
9719
10026
  if (typeof id !== "string" || id.length === 0 || typeof closedAt !== "number") continue;
9720
10027
  if (seen.has(id)) continue;
9721
10028
  seen.add(id);
10029
+ const tagList = sanitizeTagList(tags);
9722
10030
  out.push({
9723
10031
  id,
9724
10032
  title: typeof title === "string" ? title : void 0,
@@ -9726,6 +10034,8 @@ function sanitizeClosedTerminals(raw, max = CLOSED_TERMINALS_MAX) {
9726
10034
  mirrorSessionId: typeof mirrorSessionId === "string" ? mirrorSessionId : void 0,
9727
10035
  claudeSessionId: typeof claudeSessionId === "string" ? claudeSessionId : void 0,
9728
10036
  reason: reason === "daemon-gap" || reason === "closed" ? reason : void 0,
10037
+ ...tagList !== void 0 ? { tags: tagList } : {},
10038
+ ...manual === true ? { manual: true } : {},
9729
10039
  closedAt
9730
10040
  });
9731
10041
  }
@@ -9849,6 +10159,9 @@ function startupInjectionArgs(tmuxSession, command) {
9849
10159
  ["send-keys", "-t", `=${tmuxSession}:`, "Enter"]
9850
10160
  ];
9851
10161
  }
10162
+ function seenInfoOf(t) {
10163
+ return { title: t.title, cwd: t.cwd, tags: t.tags, manual: t.manual };
10164
+ }
9852
10165
  function terminalListSignature(items) {
9853
10166
  const canon = [...items].sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0).map((t) => [
9854
10167
  t.id,
@@ -10485,7 +10798,7 @@ class WebTerminalManager {
10485
10798
  }
10486
10799
  /** Refresh the per-id {title, cwd} cache from an observed live list. */
10487
10800
  noteSeen(list) {
10488
- 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)]));
10489
10802
  }
10490
10803
  /** Record one terminal as closed (dedupe/cap in the pure module) and
10491
10804
  * persist. The confirming daemonState push rides the list refresh that
@@ -10559,7 +10872,7 @@ class WebTerminalManager {
10559
10872
  const liveIds = new Set(list.map((t) => t.id));
10560
10873
  this.reconcileRestoredSnapshot(liveIds);
10561
10874
  const next = new Map(
10562
- list.map((t) => [t.id, { title: t.title, cwd: t.cwd }])
10875
+ list.map((t) => [t.id, seenInfoOf(t)])
10563
10876
  );
10564
10877
  let changed = false;
10565
10878
  const now = Date.now();
@@ -10576,6 +10889,8 @@ class WebTerminalManager {
10576
10889
  id,
10577
10890
  title: info.title,
10578
10891
  cwd: info.cwd,
10892
+ tags: info.tags,
10893
+ manual: info.manual,
10579
10894
  // Live binding first (authoritative while it exists), persisted
10580
10895
  // metadata as the fallback once the mirror has been torn down.
10581
10896
  mirrorSessionId: this.mirrorResolver?.(id) ?? mirror?.sessionId,
@@ -10626,6 +10941,8 @@ class WebTerminalManager {
10626
10941
  id,
10627
10942
  title: info.title,
10628
10943
  cwd: info.cwd,
10944
+ tags: info.tags,
10945
+ manual: info.manual,
10629
10946
  mirrorSessionId: mirror?.sessionId,
10630
10947
  claudeSessionId: mirror?.claudeSessionId,
10631
10948
  reason: "daemon-gap",
@@ -10636,6 +10953,8 @@ class WebTerminalManager {
10636
10953
  id,
10637
10954
  title: info.title,
10638
10955
  cwd: info.cwd,
10956
+ tags: info.tags,
10957
+ manual: info.manual,
10639
10958
  seenAt: info.seenAt,
10640
10959
  claudeSessionId: mirror?.claudeSessionId
10641
10960
  });
@@ -10711,6 +11030,51 @@ class WebTerminalManager {
10711
11030
  * exactly like a terminal whose pty was reaped.
10712
11031
  */
10713
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) {
10714
11078
  const env = ptyEnv();
10715
11079
  const name = `vh-${plan.terminalId}`;
10716
11080
  const exists = node_child_process.spawnSync(
@@ -10747,18 +11111,35 @@ class WebTerminalManager {
10747
11111
  tmuxArgs(["set-option", "-t", `=${name}:`, "@vh_title", plan.title]),
10748
11112
  { stdio: "ignore", timeout: TMUX_PROBE_TIMEOUT_MS, env }
10749
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
+ );
10750
11129
  }
10751
- for (const argv of startupInjectionArgs(name, plan.command)) {
10752
- 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
+ }
10753
11134
  }
10754
- logger.info(`[WEB TERMINAL] auto-restored ${plan.terminalId} in ${plan.cwd}`);
11135
+ logger.info(`[WEB TERMINAL] cold-created ${plan.terminalId} in ${plan.cwd}`);
10755
11136
  return true;
10756
11137
  }
10757
11138
  /** Mirror the in-memory cache to disk, but only when something a restart
10758
11139
  * would care about changed (see liveSnapshotChanged). */
10759
11140
  persistLiveSnapshot(live, now) {
10760
11141
  const candidate = /* @__PURE__ */ new Map();
10761
- 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 });
10762
11143
  if (!liveSnapshotChanged(this.lastPersistedSnapshot, candidate)) return;
10763
11144
  this.lastPersistedSnapshot = candidate;
10764
11145
  saveLiveSnapshot(candidate);
@@ -11333,6 +11714,8 @@ class WebTerminalManager {
11333
11714
  id: terminalId,
11334
11715
  title: info.title,
11335
11716
  cwd: info.cwd,
11717
+ tags: info.tags,
11718
+ manual: info.manual,
11336
11719
  mirrorSessionId: this.mirrorResolver?.(terminalId) ?? mirror?.sessionId,
11337
11720
  claudeSessionId: mirror?.claudeSessionId,
11338
11721
  reason: "closed",
@@ -11416,7 +11799,8 @@ class WebTerminalManager {
11416
11799
  // tmux last-activity (epoch s) → ms; optional so old daemons
11417
11800
  // simply omit it and web clients fall back to createdAt.
11418
11801
  activityAt: s.activity,
11419
- agentState: this.probeAgentState(s.name, s.paneCurrentCommand)
11802
+ agentState: this.probeAgentState(s.name, s.paneCurrentCommand),
11803
+ manual: s.manual
11420
11804
  });
11421
11805
  }
11422
11806
  return out;
@@ -11866,12 +12250,6 @@ function detectResumeSupport(happyHomeDir = configuration.happyHomeDir) {
11866
12250
  };
11867
12251
  }
11868
12252
 
11869
- function getProjectPath(workingDirectory) {
11870
- const projectId = path.resolve(workingDirectory).replace(/[^a-zA-Z0-9-]/g, "-");
11871
- const claudeConfigDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude");
11872
- return path.join(claudeConfigDir, "projects", projectId);
11873
- }
11874
-
11875
12253
  class ForkTruncateUuidNotFoundError extends Error {
11876
12254
  constructor(cutAfterUuid, sourcePath) {
11877
12255
  super(`Truncation UUID ${cutAfterUuid} not found in ${sourcePath}`);
@@ -13376,6 +13754,7 @@ class ApiMachineClient {
13376
13754
  cliUpdatePushChain = Promise.resolve();
13377
13755
  rpcHandlerManager;
13378
13756
  resumeSessionHandler = null;
13757
+ restartSessionHandler = null;
13379
13758
  stopSessionHandler = null;
13380
13759
  listTrackedSessionIds = null;
13381
13760
  reconnectInterval = null;
@@ -13442,11 +13821,13 @@ class ApiMachineClient {
13442
13821
  setRPCHandlers({
13443
13822
  spawnSession,
13444
13823
  resumeSession,
13824
+ restartSession,
13445
13825
  stopSession,
13446
13826
  listTrackedSessionIds,
13447
13827
  requestShutdown
13448
13828
  }) {
13449
13829
  this.resumeSessionHandler = resumeSession ?? null;
13830
+ this.restartSessionHandler = restartSession ?? null;
13450
13831
  this.stopSessionHandler = stopSession;
13451
13832
  this.listTrackedSessionIds = listTrackedSessionIds ?? null;
13452
13833
  this.rpcHandlerManager.registerHandler("spawn-happy-session", async (params) => {
@@ -13468,6 +13849,7 @@ class ApiMachineClient {
13468
13849
  }
13469
13850
  });
13470
13851
  this.syncResumeSessionRpcRegistration();
13852
+ this.syncRestartSessionRpcRegistration();
13471
13853
  this.rpcHandlerManager.registerHandler("open-terminal", async (params) => {
13472
13854
  const { terminalId, cols, rows, cwd, fromSeq, encStream, startupCommand, resub, attachOnly, streamMode } = params || {};
13473
13855
  const result = await this.webTerminal.open({
@@ -13543,6 +13925,14 @@ class ApiMachineClient {
13543
13925
  this.encTerminals.delete(terminalId);
13544
13926
  return { type: "success" };
13545
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
+ });
13546
13936
  this.rpcHandlerManager.registerHandler("list-terminals", async () => {
13547
13937
  return { type: "success", terminals: this.webTerminal.buildTerminalList() };
13548
13938
  });
@@ -13694,6 +14084,40 @@ class ApiMachineClient {
13694
14084
  return { message: "Daemon stop request acknowledged, starting shutdown sequence..." };
13695
14085
  });
13696
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
+ }
13697
14121
  syncResumeSessionRpcRegistration() {
13698
14122
  const method = "resume-happy-session";
13699
14123
  if (this.resumeSessionHandler) {
@@ -13901,6 +14325,9 @@ class ApiMachineClient {
13901
14325
  httpPort: this.machine.daemonState?.httpPort,
13902
14326
  startedAt: now,
13903
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 },
13904
14331
  // B-084: closed records survive daemon restarts (persisted
13905
14332
  // in closed-terminals.json), so the connect snapshot ships
13906
14333
  // them too — not just the incremental pushes.
@@ -13910,6 +14337,7 @@ class ApiMachineClient {
13910
14337
  this.webTerminal.startListTracking((terminals) => this.pushTerminalList(terminals));
13911
14338
  if (!rpcAlreadyRegistered) this.rpcHandlerManager.onSocketConnect(socket);
13912
14339
  this.syncResumeSessionRpcRegistration();
14340
+ this.syncRestartSessionRpcRegistration();
13913
14341
  this.startKeepAlive();
13914
14342
  void this.reconcileArchivedSessions();
13915
14343
  void this.refreshRelayConnection();
@@ -14145,6 +14573,7 @@ class ApiMachineClient {
14145
14573
  if (this.relaySocket !== relaySocket) return;
14146
14574
  this.rpcHandlerManager.onSocketConnect(relaySocket);
14147
14575
  this.syncResumeSessionRpcRegistration();
14576
+ this.syncRestartSessionRpcRegistration();
14148
14577
  logger.debug(`[API MACHINE] Connected to regional relay ${selected.assignment.relayId}`);
14149
14578
  });
14150
14579
  relaySocket.on("disconnect", () => {
@@ -14919,8 +15348,8 @@ class ApiClient {
14919
15348
  throw error;
14920
15349
  }
14921
15350
  }
14922
- sessionSyncClient(session) {
14923
- return new ApiSessionClient(this.credential.token, session);
15351
+ sessionSyncClient(session, opts) {
15352
+ return new ApiSessionClient(this.credential.token, session, opts);
14924
15353
  }
14925
15354
  machineSyncClient(machine) {
14926
15355
  return new ApiMachineClient(this.credential.token, machine);
@@ -15096,6 +15525,56 @@ class ApiClient {
15096
15525
  return false;
15097
15526
  }
15098
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
+ }
15099
15578
  /** Clear the server-owned archive tombstone before an intentional resume.
15100
15579
  * Older servers return 404 because the endpoint does not exist; that is a
15101
15580
  * compatible no-op because those servers also have no durable tombstone. */
@@ -15232,6 +15711,7 @@ exports.getProjectPath = getProjectPath;
15232
15711
  exports.hardenPrivateFile = hardenPrivateFile;
15233
15712
  exports.hardenPrivateFileSync = hardenPrivateFileSync;
15234
15713
  exports.initializeSandbox = initializeSandbox;
15714
+ exports.isClaudeSessionId = isClaudeSessionId;
15235
15715
  exports.isValidSessionId = isValidSessionId;
15236
15716
  exports.isValidTerminalId = isValidTerminalId;
15237
15717
  exports.listVhTerminals = listVhTerminals;