@klarkxy/dsh-session-manager 0.3.2 → 0.3.3

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.
package/lib/index.js CHANGED
@@ -7,9 +7,9 @@ import { deriveEventMessage, foldSurface, isAppendSurfaceEvent } from "@deepseek
7
7
  import { BlockAssembler, ReasoningEffortId, createUserMessage } from "@deepseek-ai/dsh-llm";
8
8
  import { lstat, mkdir, realpath } from "node:fs/promises";
9
9
  import { join } from "node:path";
10
- import { createModelSettingsChanges } from "@klarkxy/dsh-plugin-kit/model-settings-changes";
11
10
  import { defineDomain, domainTable } from "@deepseek-ai/dsh-storage-domain";
12
11
  import { z } from "zod";
12
+ import { createModelSettingsChanges } from "@klarkxy/dsh-plugin-kit/model-settings-changes";
13
13
  //#region src/contracts.ts
14
14
  /** Shared names and bounds for cross-session discovery. */
15
15
  const PLUGIN_NAME = "@klarkxy/dsh-session-manager";
@@ -2157,7 +2157,7 @@ function registerHostRpc(ctx, channel, call, options = {}) {
2157
2157
  }
2158
2158
  //#endregion
2159
2159
  //#region src/welcome-back.ts
2160
- const UUID$1 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
2160
+ const UUID$2 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
2161
2161
  /** Assistant-side durable activity: the moment the session last responded or worked. */
2162
2162
  const RESPONSE_EVENTS = /* @__PURE__ */ new Set([
2163
2163
  "assistant/message",
@@ -2317,7 +2317,7 @@ function isBusyFailure(error) {
2317
2317
  }
2318
2318
  async function compactSession(services, request, signal) {
2319
2319
  const sessionId = requiredSessionId(request?.sessionId);
2320
- if (typeof request?.requestId !== "string" || !UUID$1.test(request.requestId)) throw new PresenceError("session-manager/invalid-request", "requestId must be a UUID");
2320
+ if (typeof request?.requestId !== "string" || !UUID$2.test(request.requestId)) throw new PresenceError("session-manager/invalid-request", "requestId must be a UUID");
2321
2321
  const controller = services.sessionController;
2322
2322
  const compaction = services.compaction;
2323
2323
  if (controller === void 0 || compaction === void 0) throw new PresenceError("session-manager/unavailable", "this runtime cannot compact a session");
@@ -2466,11 +2466,17 @@ function presenceServices(ctx, sessionQuery) {
2466
2466
  const agents = optional("agents");
2467
2467
  const sessionController = optional("sessionController");
2468
2468
  const compaction = optional("compaction");
2469
+ const workspaceController = optional("workspaceController");
2470
+ const attachments = optional("attachments");
2471
+ const fileUploads = optional("fileUploads");
2469
2472
  return {
2470
2473
  sessionQuery,
2471
2474
  ...agents === void 0 ? {} : { agents },
2472
2475
  ...sessionController === void 0 ? {} : { sessionController },
2473
- ...compaction === void 0 ? {} : { compaction }
2476
+ ...compaction === void 0 ? {} : { compaction },
2477
+ ...workspaceController === void 0 ? {} : { workspaceController },
2478
+ ...attachments === void 0 ? {} : { attachments },
2479
+ ...fileUploads === void 0 ? {} : { fileUploads }
2474
2480
  };
2475
2481
  }
2476
2482
  //#endregion
@@ -2560,12 +2566,12 @@ async function writePresenceConfig(root, config, patch, store, signal) {
2560
2566
  }
2561
2567
  //#endregion
2562
2568
  //#region src/temporary-directory.ts
2563
- const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
2569
+ const UUID$1 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
2564
2570
  /** Allocate only beneath the active profile. Replaying a request retains its directory.
2565
2571
  * No user paths, session formats, free-session metadata or deletion are involved.
2566
2572
  */
2567
2573
  async function allocateTemporaryDirectory(profileDir, requestId, signal) {
2568
- if (typeof requestId !== "string" || !UUID.test(requestId)) throw new Error("Invalid temporary workspace request ID");
2574
+ if (typeof requestId !== "string" || !UUID$1.test(requestId)) throw new Error("Invalid temporary workspace request ID");
2569
2575
  signal.throwIfAborted();
2570
2576
  let directory = await realpath(profileDir);
2571
2577
  for (const part of [
@@ -2609,13 +2615,682 @@ async function runTemporaryAllocate(profileDir, payload, signal) {
2609
2615
  }
2610
2616
  }
2611
2617
  //#endregion
2618
+ //#region src/edit-prepare.ts
2619
+ function unavailable(message) {
2620
+ return new PresenceError("session-manager/unavailable", message);
2621
+ }
2622
+ function promptContent$1(event) {
2623
+ const content = event.data.content.map((block) => {
2624
+ if (block.type === "text") return {
2625
+ type: "text",
2626
+ text: block.text
2627
+ };
2628
+ if (block.type === "image") return {
2629
+ type: "image",
2630
+ attachmentId: block.attachment.attachmentId
2631
+ };
2632
+ if (block.type === "file") return {
2633
+ type: "file",
2634
+ ...block.attachment
2635
+ };
2636
+ throw unavailable("This prompt contains an unsupported content block");
2637
+ });
2638
+ const text = event.data.content.map((block) => block.type === "text" ? block.text : "").join("");
2639
+ if (text.trim() === "" && !content.some((block) => block.type !== "text")) throw unavailable("The selected prompt has no content to resend");
2640
+ return {
2641
+ text,
2642
+ content
2643
+ };
2644
+ }
2645
+ /** Resolve one exact human prompt on the active branch, with its original attachments. */
2646
+ async function prepareEdit(sessionQuery, request, signal) {
2647
+ if (typeof request?.sessionId !== "string" || request.sessionId.trim() === "") throw new PresenceError("session-manager/invalid-request", "sessionId must be a non-empty string");
2648
+ if (!Number.isSafeInteger(request.promptSeq) || request.promptSeq < 0) throw new PresenceError("session-manager/invalid-request", "promptSeq must be a non-negative integer");
2649
+ const observation = await sessionQuery.observeSession(SessionId(request.sessionId), {
2650
+ signal,
2651
+ projectionMode: "none"
2652
+ });
2653
+ try {
2654
+ if (isSubagentSession({ header: observation.header })) throw unavailable("Subagent prompts cannot be edited here");
2655
+ const events = activeEventsOf(observation);
2656
+ if (events === void 0) throw unavailable("The active conversation branch cannot be inspected");
2657
+ const prompt = events.find((event) => event.type === "user/message" && event.seq === request.promptSeq && event.data.source.kind === "user");
2658
+ if (prompt === void 0) throw unavailable("The selected prompt is unavailable");
2659
+ const { text, content } = promptContent$1(prompt);
2660
+ const modified = events.some((event) => event.type === "workspace/changes" && event.seq > prompt.seq);
2661
+ return {
2662
+ sessionId: request.sessionId,
2663
+ promptSeq: prompt.seq,
2664
+ text,
2665
+ ...content.some((block) => block.type !== "text") ? { content } : {},
2666
+ modified
2667
+ };
2668
+ } finally {
2669
+ observation[Symbol.dispose]();
2670
+ }
2671
+ }
2672
+ //#endregion
2673
+ //#region src/retry-journal.ts
2674
+ const retryRecordSchema = z.object({
2675
+ requestId: z.string().uuid(),
2676
+ source: z.string().min(1),
2677
+ messageId: z.string().min(1),
2678
+ text: z.string().optional(),
2679
+ expectedRevision: z.number().int().nonnegative(),
2680
+ expectedInputRevision: z.string(),
2681
+ forkSeq: z.number().int().nonnegative(),
2682
+ title: z.string().optional(),
2683
+ forkStarted: z.boolean(),
2684
+ childId: z.string().min(1).optional(),
2685
+ renamed: z.boolean().optional(),
2686
+ prompted: z.boolean(),
2687
+ result: z.object({
2688
+ sessionId: z.string().min(1),
2689
+ sourceSessionId: z.string().min(1),
2690
+ archived: z.boolean()
2691
+ }).strict().optional()
2692
+ }).strict().refine((row) => (!row.childId || row.forkStarted) && (!row.prompted || Boolean(row.childId)) && (!row.result || row.prompted && row.result.sessionId === row.childId && row.result.sourceSessionId === row.source));
2693
+ /** Separate from preferences: unreadable retry evidence must never become an empty ledger. */
2694
+ const retryDomain = defineDomain({
2695
+ name: "dsh_session_manager_retries",
2696
+ version: 1,
2697
+ layout: "per-record",
2698
+ tables: { requests: domainTable(retryRecordSchema) }
2699
+ });
2700
+ var RetryJournalError = class extends Error {
2701
+ code;
2702
+ constructor(code, message) {
2703
+ super(message);
2704
+ this.code = code;
2705
+ }
2706
+ };
2707
+ /** Owns one profile's request identities and drains active commits before releasing storage. */
2708
+ var RetryJournal = class {
2709
+ domain;
2710
+ records = /* @__PURE__ */ new Map();
2711
+ running = /* @__PURE__ */ new Map();
2712
+ runningIds = /* @__PURE__ */ new Set();
2713
+ closing;
2714
+ closed = false;
2715
+ constructor(domain) {
2716
+ this.domain = domain;
2717
+ for (const [key, value] of domain.table("requests").entries()) {
2718
+ const row = retryRecordSchema.parse(value);
2719
+ if (key !== row.requestId) throw new Error("Retry record identity mismatch");
2720
+ this.records.set(row.requestId, row);
2721
+ }
2722
+ }
2723
+ get(requestId) {
2724
+ return this.records.get(requestId);
2725
+ }
2726
+ async put(row) {
2727
+ const stored = retryRecordSchema.parse(row);
2728
+ await this.domain.table("requests").put(row.requestId, stored);
2729
+ this.records.set(row.requestId, row);
2730
+ }
2731
+ run(source, requestId, action) {
2732
+ if (this.closed) return Promise.reject(new RetryJournalError("session-manager/unavailable", "retry storage is closing"));
2733
+ if (this.runningIds.has(requestId)) return Promise.reject(new RetryJournalError("session/retry-pending", "this retry is still running"));
2734
+ if (this.running.has(source)) return Promise.reject(new RetryJournalError("session-manager/busy", "another retry is running"));
2735
+ for (const row of this.records.values()) if (row.source === source && row.requestId !== requestId && row.forkStarted && !row.result) return Promise.reject(new RetryJournalError("session/retry-uncertain", "an earlier resend must be resolved first"));
2736
+ const work = Promise.resolve().then(action).finally(() => {
2737
+ this.running.delete(source);
2738
+ this.runningIds.delete(requestId);
2739
+ });
2740
+ this.running.set(source, work);
2741
+ this.runningIds.add(requestId);
2742
+ return work;
2743
+ }
2744
+ close() {
2745
+ this.closed = true;
2746
+ return this.closing ??= Promise.allSettled([...this.running.values()]).then(() => this.domain.close());
2747
+ }
2748
+ };
2749
+ //#endregion
2750
+ //#region src/edit-file.ts
2751
+ /** Reuse the host's streaming intake; only a file in the exact source prompt may be copied. */
2752
+ async function stageEditFile(query, services, request, signal) {
2753
+ if (![request.sessionId, request.attachmentId].every((value) => typeof value === "string" && value !== "") || !Number.isSafeInteger(request.promptSeq) || request.promptSeq < 0) throw new PresenceError("session-manager/invalid-request", "Invalid attachment request");
2754
+ const observation = await query.observeSession(SessionId(request.sessionId), {
2755
+ signal,
2756
+ projectionMode: "none"
2757
+ });
2758
+ try {
2759
+ const part = (activeEventsOf(observation)?.find((event) => event.seq === request.promptSeq && event.type === "user/message" && event.data.source.kind === "user"))?.data.content.find((part) => part.type === "file" && part.attachment.attachmentId === request.attachmentId);
2760
+ if (part?.type !== "file" || !services.attachments || !services.fileUploads) throw new PresenceError("session-manager/unavailable", "The selected file is unavailable");
2761
+ return await services.fileUploads.uploadStream({
2762
+ sessionId: request.sessionId,
2763
+ name: part.attachment.name,
2764
+ data: services.attachments.readFileStream(part.attachment, signal),
2765
+ signal
2766
+ });
2767
+ } finally {
2768
+ observation[Symbol.dispose]();
2769
+ }
2770
+ }
2771
+ //#endregion
2772
+ //#region src/retry-branch.ts
2773
+ /**
2774
+ * Content digest of one prompt. Not a hash: the token only has to change when
2775
+ * the client edited between preview and commit, so the JSON of the durable
2776
+ * content is the honest cheapest representation.
2777
+ */
2778
+ function inputRevisionOf(event) {
2779
+ return JSON.stringify(event.data.content);
2780
+ }
2781
+ /**
2782
+ * An edit of a turn's first input must not inherit its empty turn/step shell.
2783
+ * Find the last balanced, drained prefix before that shell, including inbox
2784
+ * splices: cutting just before turn/start can resurrect the original queued
2785
+ * prompt. A later input in the same turn keeps the real work before it.
2786
+ */
2787
+ function editForkBoundary(events, promptSeq) {
2788
+ const pending = {
2789
+ "next-turn": 0,
2790
+ "next-step": 0
2791
+ };
2792
+ let open = false;
2793
+ let hasContent = false;
2794
+ let balanced;
2795
+ let previous;
2796
+ for (const event of events) {
2797
+ if (event.seq >= promptSeq) break;
2798
+ const type = event.type;
2799
+ if (type === "agent/inbox/spliced") {
2800
+ const splice = event.data;
2801
+ const count = pending[splice.target];
2802
+ const removed = splice.removedCount ?? 0;
2803
+ if (count === void 0 || !Array.isArray(splice.inserted) || !Number.isSafeInteger(splice.start) || splice.start < 0 || splice.start > count || !Number.isSafeInteger(removed) || removed < 0 || splice.start + removed > count) return void 0;
2804
+ pending[splice.target] += splice.inserted.length - removed;
2805
+ }
2806
+ if (type === "turn/start") {
2807
+ open = true;
2808
+ hasContent = false;
2809
+ } else if (type === "turn/end") {
2810
+ open = false;
2811
+ hasContent = false;
2812
+ } else if (type === "user/message" || type === "assistant/message" || type === "tool/call" || type === "tool/result" || type === "command/run" || type === "job/registered") hasContent = true;
2813
+ previous = event.seq;
2814
+ if (!open && pending["next-turn"] === 0 && pending["next-step"] === 0) balanced = event.seq;
2815
+ }
2816
+ if (open && !hasContent) return balanced;
2817
+ return pending["next-turn"] === 0 && pending["next-step"] === 0 ? previous : void 0;
2818
+ }
2819
+ /**
2820
+ * Whether an already-observed prompt is still on the active surface: its seq
2821
+ * must not appear in any later event's `sourceEventSeqs`.
2822
+ */
2823
+ function isOnActiveSurface(events, promptSeq) {
2824
+ for (const event of events) {
2825
+ if (event.seq <= promptSeq) continue;
2826
+ const seqs = event.sourceEventSeqs;
2827
+ if (Array.isArray(seqs) && seqs.includes(promptSeq)) return false;
2828
+ }
2829
+ return true;
2830
+ }
2831
+ /**
2832
+ * Later model-side work the user must explicitly keep (`allowModified`).
2833
+ * `command/run` and `job/*` are not in the stock SessionEventMap but a restored
2834
+ * log from a newer host can carry them, so the scan compares plain strings.
2835
+ */
2836
+ const MODIFIED_EVIDENCE = /* @__PURE__ */ new Set([
2837
+ "tool/call",
2838
+ "tool/result",
2839
+ "command/run",
2840
+ "job/registered"
2841
+ ]);
2842
+ function modificationEvidence(events, promptSeq) {
2843
+ const seen = /* @__PURE__ */ new Set();
2844
+ for (const event of events) {
2845
+ const type = event.type;
2846
+ if (event.seq > promptSeq && MODIFIED_EVIDENCE.has(type)) seen.add(type);
2847
+ }
2848
+ return [...seen];
2849
+ }
2850
+ /**
2851
+ * A live job started and not yet settled blocks retry like a running turn.
2852
+ * Job events are absent from the stock event union; the scan reads `jobId`
2853
+ * defensively so a log restored from a newer host still reports live work.
2854
+ */
2855
+ function hasLiveJobs(events) {
2856
+ const live = /* @__PURE__ */ new Set();
2857
+ for (const event of events) {
2858
+ const type = event.type;
2859
+ if (type !== "job/registered" && type !== "job/settled") continue;
2860
+ const jobId = event.data.jobId;
2861
+ if (typeof jobId !== "string") continue;
2862
+ if (type === "job/registered") live.add(jobId);
2863
+ else live.delete(jobId);
2864
+ }
2865
+ return live.size > 0;
2866
+ }
2867
+ //#endregion
2868
+ //#region src/retry-content.ts
2869
+ /**
2870
+ * The editor exposes joined text, while the host stores an ordered block list.
2871
+ * Apply the smallest single text replacement to that list. Unchanged text on
2872
+ * either side of an attachment stays on its own side; attachments never move.
2873
+ * Code points avoid splitting an emoji at a block boundary during replacement.
2874
+ */
2875
+ function editedPromptBlocks(content, text) {
2876
+ const before = content.flatMap((block) => block.type === "text" ? Array.from(block.text) : []);
2877
+ if (text === void 0 || before.join("") === text) return content;
2878
+ const after = Array.from(text);
2879
+ let prefix = 0;
2880
+ while (prefix < Math.min(before.length, after.length) && before[prefix] === after[prefix]) prefix++;
2881
+ let suffix = 0;
2882
+ while (suffix < Math.min(before.length, after.length) - prefix && before[before.length - suffix - 1] === after[after.length - suffix - 1]) suffix++;
2883
+ const end = before.length - suffix;
2884
+ const replacement = after.slice(prefix, after.length - suffix).join("");
2885
+ let offset = 0;
2886
+ let inserted = false;
2887
+ const blocks = content.map((block) => {
2888
+ if (block.type !== "text") return block;
2889
+ const chars = Array.from(block.text);
2890
+ const start = offset;
2891
+ offset += chars.length;
2892
+ const left = chars.slice(0, Math.max(0, prefix - start)).join("");
2893
+ const right = chars.slice(Math.max(0, end - start)).join("");
2894
+ const insertHere = !inserted && offset >= prefix;
2895
+ if (insertHere) inserted = true;
2896
+ return {
2897
+ type: "text",
2898
+ text: left + (insertHere ? replacement : "") + right
2899
+ };
2900
+ });
2901
+ if (!inserted && text !== "") blocks.push({
2902
+ type: "text",
2903
+ text
2904
+ });
2905
+ return blocks;
2906
+ }
2907
+ //#endregion
2908
+ //#region src/retry-service.ts
2909
+ /**
2910
+ * Host-side edit-and-resend engine. A commit never writes to the source log:
2911
+ * it forks the retained conversation prefix, queues the
2912
+ * edited prompt on the fork, mirrors the title/sidebar position/pin, and
2913
+ * archives the source so the branch looks like the same chat.
2914
+ *
2915
+ * Why a fork and not a same-session `surfaceOp` replace: a replace bumps
2916
+ * `surface.contentGeneration`, which starts a new request series and
2917
+ * reshuffles the system/tool projections — the provider's prompt prefix
2918
+ * cache can be disrupted. Retaining the unchanged model-visible prefix gives
2919
+ * the provider the same content to reuse; actual cache hits remain provider-dependent.
2920
+ *
2921
+ * The profile's durable journal records intent before creating a fork and
2922
+ * the child identity before prompting it. Replays resume that same child.
2923
+ * The host cannot create a fork idempotently: an interrupted fork with no
2924
+ * saved child identity is therefore refused, never guessed or repeated.
2925
+ */
2926
+ var RetryError = class extends Error {
2927
+ code;
2928
+ name = "RetryError";
2929
+ constructor(code, message) {
2930
+ super(message);
2931
+ this.code = code;
2932
+ }
2933
+ };
2934
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
2935
+ /** Retry needs a session controller that can fork and prompt; capabilities are honest about it. */
2936
+ function retryCapabilities(services) {
2937
+ const controller = services.sessionController;
2938
+ const available = services.retryJournal !== void 0 && controller !== void 0 && typeof controller.fork === "function" && typeof controller.prompt === "function";
2939
+ return {
2940
+ retry: available,
2941
+ retryModified: available,
2942
+ retryContent: false
2943
+ };
2944
+ }
2945
+ function requireSessionId(value) {
2946
+ if (typeof value !== "string" || value.trim() === "") throw new RetryError("session-manager/invalid-request", "sessionId must be a non-empty string");
2947
+ return value;
2948
+ }
2949
+ function findPrompt(events, messageId) {
2950
+ return events.find((event) => event.type === "user/message" && event.data.id === messageId && event.data.source.kind === "user");
2951
+ }
2952
+ async function observe(services, sessionId, signal) {
2953
+ const observation = await services.sessionQuery.observeSession(SessionId(sessionId), {
2954
+ signal,
2955
+ projectionMode: "none"
2956
+ });
2957
+ const events = activeEventsOf(observation);
2958
+ if (events === void 0) {
2959
+ observation[Symbol.dispose]();
2960
+ throw new RetryError("session-manager/unavailable", "the active conversation branch cannot be inspected");
2961
+ }
2962
+ return {
2963
+ observation,
2964
+ events
2965
+ };
2966
+ }
2967
+ async function previewRetry(services, request, signal) {
2968
+ const sessionId = requireSessionId(request?.sessionId);
2969
+ if (typeof request.messageId !== "string" || request.messageId === "") throw new RetryError("session-manager/invalid-request", "messageId must be a non-empty string");
2970
+ const { observation, events } = await observe(services, sessionId, signal);
2971
+ try {
2972
+ const base = {
2973
+ sessionId,
2974
+ messageId: request.messageId
2975
+ };
2976
+ if (isSubagentSession({ header: observation.header })) return {
2977
+ ...base,
2978
+ promptSeq: -1,
2979
+ branchRevision: 0,
2980
+ inputRevision: "",
2981
+ eligible: false,
2982
+ modified: false,
2983
+ reason: "subagent"
2984
+ };
2985
+ if (services.agents?.get(sessionId)?.status === "running" || hasLiveJobs(events)) return {
2986
+ ...base,
2987
+ promptSeq: -1,
2988
+ branchRevision: 0,
2989
+ inputRevision: "",
2990
+ eligible: false,
2991
+ modified: false,
2992
+ reason: "busy"
2993
+ };
2994
+ const prompt = findPrompt(events, request.messageId);
2995
+ if (prompt === void 0 || !isOnActiveSurface(events, prompt.seq)) return {
2996
+ ...base,
2997
+ promptSeq: -1,
2998
+ branchRevision: 0,
2999
+ inputRevision: "",
3000
+ eligible: false,
3001
+ modified: false,
3002
+ reason: "prompt-unavailable"
3003
+ };
3004
+ const modified = modificationEvidence(events, prompt.seq).length > 0;
3005
+ return {
3006
+ ...base,
3007
+ promptSeq: prompt.seq,
3008
+ branchRevision: events[events.length - 1]?.seq ?? 0,
3009
+ inputRevision: inputRevisionOf(prompt),
3010
+ eligible: !modified,
3011
+ modified,
3012
+ ...modified ? { reason: "modified" } : {}
3013
+ };
3014
+ } finally {
3015
+ observation[Symbol.dispose]();
3016
+ }
3017
+ }
3018
+ /**
3019
+ * Latest `session/title` text at or before `throughSeq`. Title events are
3020
+ * log-only and merge-augmented, so the scan reads plain strings — the same
3021
+ * fold the title projection applies, without importing the title service.
3022
+ */
3023
+ function foldedTitle(events, throughSeq) {
3024
+ let title;
3025
+ for (const event of events) {
3026
+ if (event.seq > throughSeq) break;
3027
+ if (event.type !== "session/title") continue;
3028
+ const value = event.data.title;
3029
+ if (typeof value === "string" && value.trim() !== "") title = value;
3030
+ }
3031
+ return title;
3032
+ }
3033
+ /**
3034
+ * The title the child still needs after the cut. A title event inside the
3035
+ * inherited prefix already lands on the fork; a title beyond that prefix
3036
+ * has to be re-applied — before the prompt, so an explicit
3037
+ * user-source title pins it ahead of any automatic generation.
3038
+ */
3039
+ function titleToApply(events, forkSeq) {
3040
+ const title = foldedTitle(events, Number.MAX_SAFE_INTEGER);
3041
+ if (title === void 0 || foldedTitle(events, forkSeq) === title) return void 0;
3042
+ return title;
3043
+ }
3044
+ /**
3045
+ * Canonical request equality: replay is valid only when every stored field of
3046
+ * the original request matches — same target, same edit text, same preview
3047
+ * tokens. `content` is rejected at validation so it never reaches this point.
3048
+ */
3049
+ function assertSameRequest(entry, request) {
3050
+ if (entry.source !== request.sessionId || entry.messageId !== request.messageId || entry.text !== request.text || entry.expectedRevision !== request.expectedRevision || entry.expectedInputRevision !== request.expectedInputRevision) throw new RetryError("session/retry-conflict", "the same requestId carries a different retry body");
3051
+ }
3052
+ /**
3053
+ * Re-check every preview guarantee on the live log, then record the resume
3054
+ * state a fork commit needs. The source log is never mutated, so this is the
3055
+ * only place the observed head matters: a stale check rejects before any
3056
+ * stage lands.
3057
+ */
3058
+ async function validateCommit(services, request, sessionId, signal) {
3059
+ const { observation, events } = await observe(services, sessionId, signal);
3060
+ try {
3061
+ signal.throwIfAborted();
3062
+ if (isSubagentSession({ header: observation.header })) throw new RetryError("session/retry-blocked", "subagent sessions cannot be edited and resent");
3063
+ if (services.agents?.get(sessionId)?.status === "running" || hasLiveJobs(events)) throw new RetryError("session-manager/busy", "the session is busy");
3064
+ const prompt = findPrompt(events, request.messageId);
3065
+ if (prompt === void 0) throw new RetryError("session/retry-input", "the prompt is no longer in the session");
3066
+ if (!isOnActiveSurface(events, prompt.seq)) throw new RetryError("session/retry-revision", "the prompt is no longer on the active surface");
3067
+ if ((events[events.length - 1]?.seq ?? -1) !== request.expectedRevision) throw new RetryError("session/retry-revision", "the session changed after the preview");
3068
+ if (inputRevisionOf(prompt) !== request.expectedInputRevision) throw new RetryError("session/retry-input", "the prompt changed after the preview");
3069
+ if (request.allowModified !== true) throw new RetryError("session/retry-input", "resend requires explicit confirmation");
3070
+ if (!editedPromptBlocks(prompt.data.content, request.text).some((block) => block.type !== "text" || block.text !== "")) throw new RetryError("session/retry-input", "the resent prompt would be empty");
3071
+ const forkSeq = editForkBoundary(events, prompt.seq);
3072
+ if (forkSeq === void 0) throw new RetryError("session/retry-blocked", "the prompt has no drained conversation prefix to fork from");
3073
+ return {
3074
+ requestId: request.requestId,
3075
+ source: sessionId,
3076
+ messageId: request.messageId,
3077
+ text: request.text,
3078
+ expectedRevision: request.expectedRevision,
3079
+ expectedInputRevision: request.expectedInputRevision,
3080
+ forkSeq,
3081
+ title: titleToApply(events, forkSeq),
3082
+ forkStarted: false,
3083
+ prompted: false
3084
+ };
3085
+ } finally {
3086
+ observation[Symbol.dispose]();
3087
+ }
3088
+ }
3089
+ /**
3090
+ * The prompt the child receives keeps the original block order. Image
3091
+ * bytes are re-read from the source log first; file
3092
+ * receipts are bound to the target agent, so original files are staged
3093
+ * against the child — never the source — after every image resolved.
3094
+ */
3095
+ async function promptContent(services, controller, entry, signal) {
3096
+ const { observation, events } = await observe(services, entry.source, signal);
3097
+ let blocks;
3098
+ try {
3099
+ const prompt = findPrompt(events, entry.messageId);
3100
+ if (!prompt || inputRevisionOf(prompt) !== entry.expectedInputRevision) throw new RetryError("session/retry-input", "the original prompt can no longer be read");
3101
+ blocks = editedPromptBlocks(prompt.data.content, entry.text);
3102
+ } finally {
3103
+ observation[Symbol.dispose]();
3104
+ }
3105
+ const parts = [];
3106
+ const files = [];
3107
+ for (const block of blocks) if (block.type === "text") parts.push(block);
3108
+ else if (block.type === "image") {
3109
+ if (typeof controller.attachment !== "function") throw new RetryError("session-manager/unavailable", "this runtime cannot read the original image");
3110
+ const image = await controller.attachment({
3111
+ sessionId: entry.source,
3112
+ attachmentId: block.attachment.attachmentId
3113
+ });
3114
+ if (typeof image?.attachment?.mediaType !== "string" || typeof image.data !== "string" || image.data === "") throw new RetryError("session/retry-failed", "the original image could not be read");
3115
+ parts.push({
3116
+ type: "image",
3117
+ mediaType: image.attachment.mediaType,
3118
+ data: image.data,
3119
+ ...typeof image.attachment.name === "string" && image.attachment.name !== "" ? { name: image.attachment.name } : {}
3120
+ });
3121
+ } else if (block.type === "file") {
3122
+ files.push({
3123
+ index: parts.length,
3124
+ attachment: block.attachment
3125
+ });
3126
+ parts.push(void 0);
3127
+ }
3128
+ for (const file of files) {
3129
+ if (services.fileUploads === void 0 || services.attachments === void 0) throw new RetryError("session-manager/unavailable", "this runtime cannot copy the original file");
3130
+ const receiptId = (await services.fileUploads.uploadStream({
3131
+ sessionId: entry.childId,
3132
+ name: file.attachment.name,
3133
+ data: services.attachments.readFileStream(file.attachment, signal),
3134
+ signal
3135
+ }))?.receiptId;
3136
+ if (typeof receiptId !== "string" || receiptId === "") throw new RetryError("session/retry-failed", "the original file could not be staged");
3137
+ parts[file.index] = {
3138
+ type: "file",
3139
+ receiptId
3140
+ };
3141
+ }
3142
+ return parts;
3143
+ }
3144
+ /** The source's workspace and pin state, read from the controller's opening baseline. */
3145
+ async function workspacePlacement(workspace, sourceId, signal) {
3146
+ if (typeof workspace.follow !== "function") return { pinned: false };
3147
+ for await (const frame of workspace.follow(signal)) {
3148
+ if (frame?.type !== "baseline") return { pinned: false };
3149
+ const owner = (Array.isArray(frame.value?.items) ? frame.value.items : []).find((item) => Array.isArray(item?.sessionIds) && item.sessionIds.includes(sourceId));
3150
+ const pinned = Array.isArray(frame.value?.pinnedSessionIds) && frame.value.pinnedSessionIds.includes(sourceId);
3151
+ return {
3152
+ ...typeof owner?.workspaceId === "string" && owner.workspaceId !== "" ? { workspaceId: owner.workspaceId } : {},
3153
+ pinned
3154
+ };
3155
+ }
3156
+ return { pinned: false };
3157
+ }
3158
+ /**
3159
+ * Make the fork look like the same chat: the sidebar slot just before the
3160
+ * source, the source's pin, then the source archived. Every step is
3161
+ * best-effort — the conversation itself is already correct without them.
3162
+ */
3163
+ async function finalizeCommit(services, entry, signal) {
3164
+ const workspace = services.workspaceController;
3165
+ let archived = false;
3166
+ if (workspace !== void 0) {
3167
+ let placement = { pinned: false };
3168
+ try {
3169
+ placement = await workspacePlacement(workspace, entry.source, signal);
3170
+ } catch {}
3171
+ if (placement.workspaceId !== void 0 && typeof workspace.insertSessionBefore === "function") try {
3172
+ await workspace.insertSessionBefore({
3173
+ workspaceId: placement.workspaceId,
3174
+ sessionId: entry.childId,
3175
+ beforeSessionId: entry.source
3176
+ });
3177
+ } catch {}
3178
+ if (placement.pinned && typeof workspace.pinSession === "function") try {
3179
+ await workspace.pinSession({ sessionId: entry.childId });
3180
+ } catch {}
3181
+ if (typeof workspace.archiveSession === "function") try {
3182
+ await workspace.archiveSession({ sessionId: entry.source });
3183
+ archived = true;
3184
+ } catch {}
3185
+ }
3186
+ return {
3187
+ sessionId: entry.childId,
3188
+ sourceSessionId: entry.source,
3189
+ archived
3190
+ };
3191
+ }
3192
+ function requireController(services) {
3193
+ const controller = services.sessionController;
3194
+ if (controller === void 0 || typeof controller.fork !== "function" || typeof controller.prompt !== "function") throw new RetryError("session-manager/unavailable", "this runtime cannot fork a session");
3195
+ return controller;
3196
+ }
3197
+ /**
3198
+ * Resume recorded stages. A failed save retains the live child identity;
3199
+ * after restart, an intent with no child fails closed because fork has no
3200
+ * request-id contract. No stage proceeds until its prerequisite is durable.
3201
+ */
3202
+ async function runCommit(services, journal, entry, signal) {
3203
+ try {
3204
+ const controller = requireController(services);
3205
+ if (entry.childId === void 0) {
3206
+ if (entry.forkStarted) throw new RetryError("session/retry-uncertain", "the previous fork outcome is unknown; inspect the session list");
3207
+ entry.forkStarted = true;
3208
+ await journal.put(entry);
3209
+ const childId = (await controller.fork({
3210
+ sessionId: entry.source,
3211
+ atSeq: entry.forkSeq
3212
+ }))?.sessionId;
3213
+ if (typeof childId !== "string" || childId === "") throw new RetryError("session/retry-failed", "the fork returned no session");
3214
+ entry.childId = childId;
3215
+ }
3216
+ await journal.put(entry);
3217
+ const forkedEntry = entry;
3218
+ if (forkedEntry.title !== void 0 && forkedEntry.renamed !== true && typeof controller.rename === "function") {
3219
+ await controller.rename({
3220
+ sessionId: forkedEntry.childId,
3221
+ title: forkedEntry.title
3222
+ });
3223
+ forkedEntry.renamed = true;
3224
+ await journal.put(entry);
3225
+ }
3226
+ if (forkedEntry.prompted !== true) {
3227
+ const content = await promptContent(services, controller, forkedEntry, signal);
3228
+ await controller.prompt({
3229
+ requestId: forkedEntry.requestId,
3230
+ sessionId: forkedEntry.childId,
3231
+ mode: "queue",
3232
+ content
3233
+ }, signal);
3234
+ forkedEntry.prompted = true;
3235
+ await journal.put(entry);
3236
+ }
3237
+ forkedEntry.result = await finalizeCommit(services, forkedEntry, signal);
3238
+ await journal.put(entry);
3239
+ return forkedEntry.result;
3240
+ } catch (error) {
3241
+ if (entry.forkStarted && !entry.childId && journal.get(entry.requestId)) throw new RetryError("session/retry-uncertain", "the previous fork outcome is unknown; inspect the session list");
3242
+ if (entry.childId) throw new RetryError("session/retry-pending", "resend is incomplete; retry the same request");
3243
+ throw commitFailure(error, signal);
3244
+ }
3245
+ }
3246
+ /** Host RemoteError codes worth surfacing verbatim; anything else is a generic commit failure. */
3247
+ function commitFailure(error, signal) {
3248
+ if (error instanceof RetryError || error instanceof RetryJournalError || error instanceof PresenceError) return error;
3249
+ if (signal.aborted) return error;
3250
+ if (error instanceof Error && error.name === "AbortError") return error;
3251
+ const code = error?.code;
3252
+ if (code === "session/not-found") return new RetryError("session-manager/not-found", "the session is not available");
3253
+ if (code === "session/agent-busy" || code === "session/writer-held") return new RetryError("session-manager/busy", "the session is busy");
3254
+ if (code === "session/fork-unavailable" || code === "session/migration-required") return new RetryError("session/retry-blocked", "the session cannot be forked here");
3255
+ return new RetryError("session/retry-failed", "the retry commit failed");
3256
+ }
3257
+ async function commitRetry(services, request, signal) {
3258
+ const sessionId = requireSessionId(request?.sessionId);
3259
+ if (typeof request.messageId !== "string" || request.messageId === "") throw new RetryError("session-manager/invalid-request", "messageId must be a non-empty string");
3260
+ if (!Number.isSafeInteger(request.expectedRevision) || request.expectedRevision < 0) throw new RetryError("session-manager/invalid-request", "expectedRevision must be a non-negative integer");
3261
+ if (typeof request.expectedInputRevision !== "string") throw new RetryError("session-manager/invalid-request", "expectedInputRevision must be a string");
3262
+ if (!UUID.test(request.requestId)) throw new RetryError("session-manager/invalid-request", "requestId must be a UUID");
3263
+ if (request.content !== void 0) throw new RetryError("session/retry-input", "content replacement is not supported; edit the text only");
3264
+ if (request.text !== void 0 && typeof request.text !== "string") throw new RetryError("session-manager/invalid-request", "text must be a string");
3265
+ if (request.allowModified !== true) throw new RetryError("session/retry-input", "resend requires explicit confirmation");
3266
+ requireController(services);
3267
+ const journal = services.retryJournal;
3268
+ if (!journal) throw new RetryError("session-manager/unavailable", "durable retry storage is unavailable");
3269
+ return journal.run(sessionId, request.requestId, async () => {
3270
+ const existing = journal.get(request.requestId);
3271
+ if (existing !== void 0) {
3272
+ assertSameRequest(existing, request);
3273
+ if (existing.result !== void 0) {
3274
+ try {
3275
+ await journal.put(existing);
3276
+ } catch {
3277
+ throw new RetryError("session/retry-pending", "resend completed but its receipt could not be saved");
3278
+ }
3279
+ return existing.result;
3280
+ }
3281
+ }
3282
+ const entry = existing ?? await validateCommit(services, request, sessionId, signal);
3283
+ return runCommit(services, journal, entry, signal);
3284
+ });
3285
+ }
3286
+ //#endregion
2612
3287
  //#region src/rpc.ts
2613
3288
  const PROFILE_ERROR = "Session Manager requires the active profile directory";
2614
3289
  function isAbort(error) {
2615
3290
  return error instanceof Error && error.name === "AbortError";
2616
3291
  }
2617
3292
  function failure(error) {
2618
- if (error instanceof PresenceError) return {
3293
+ if (error instanceof PresenceError || error instanceof RetryError || error instanceof RetryJournalError) return {
2619
3294
  ok: false,
2620
3295
  error: {
2621
3296
  code: error.code,
@@ -2653,6 +3328,21 @@ function registerSessionManagerRpc(root, transport, config, models, modelStore)
2653
3328
  const profileDir = profile.dir;
2654
3329
  const suggestions = createSuggestionCache();
2655
3330
  const lifetime = new AbortController();
3331
+ let journal;
3332
+ const retryJournal = async () => {
3333
+ lifetime.signal.throwIfAborted();
3334
+ const storage = root.get("storageDomain");
3335
+ if (!storage) return void 0;
3336
+ return journal ??= (async () => {
3337
+ const domain = await storage.open(retryDomain);
3338
+ try {
3339
+ return new RetryJournal(domain);
3340
+ } catch (error) {
3341
+ await domain.close();
3342
+ throw error;
3343
+ }
3344
+ })();
3345
+ };
2656
3346
  const unregister = registerHostRpc(transport, TEMP_WORKSPACE_CHANNEL, async (endpoint, payload, signal) => {
2657
3347
  const merged = AbortSignal.any([signal, lifetime.signal]);
2658
3348
  try {
@@ -2679,6 +3369,64 @@ function registerSessionManagerRpc(root, transport, config, models, modelStore)
2679
3369
  value: await suggestContinuation(services, config, models(), suggestions, payload, merged)
2680
3370
  };
2681
3371
  }
3372
+ if (endpoint === "edit.prepare") {
3373
+ if (!payloadKeys(payload, ["sessionId", "promptSeq"])) throw new PresenceError("session-manager/invalid-request", "invalid edit request");
3374
+ return {
3375
+ ok: true,
3376
+ value: await prepareEdit(sessionQuery, payload, merged)
3377
+ };
3378
+ }
3379
+ if (endpoint === "edit.file") {
3380
+ if (!payloadKeys(payload, [
3381
+ "sessionId",
3382
+ "promptSeq",
3383
+ "attachmentId"
3384
+ ])) throw new PresenceError("session-manager/invalid-request", "invalid attachment request");
3385
+ const fileServices = {
3386
+ attachments: root.get("attachments"),
3387
+ fileUploads: root.get("fileUploads")
3388
+ };
3389
+ return {
3390
+ ok: true,
3391
+ value: await stageEditFile(sessionQuery, fileServices, payload, merged)
3392
+ };
3393
+ }
3394
+ if (endpoint === "retry.capabilities") {
3395
+ if (!payloadKeys(payload, [])) throw new PresenceError("session-manager/invalid-request", "invalid retry capabilities request");
3396
+ return {
3397
+ ok: true,
3398
+ value: retryCapabilities({
3399
+ ...services,
3400
+ retryJournal: await retryJournal()
3401
+ })
3402
+ };
3403
+ }
3404
+ if (endpoint === "retry.preview") {
3405
+ if (!payloadKeys(payload, ["sessionId", "messageId"])) throw new PresenceError("session-manager/invalid-request", "invalid retry preview request");
3406
+ return {
3407
+ ok: true,
3408
+ value: await previewRetry(services, payload, merged)
3409
+ };
3410
+ }
3411
+ if (endpoint === "retry.commit") {
3412
+ if (!payloadKeys(payload, [
3413
+ "sessionId",
3414
+ "messageId",
3415
+ "expectedRevision",
3416
+ "expectedInputRevision",
3417
+ "requestId",
3418
+ "text",
3419
+ "content",
3420
+ "allowModified"
3421
+ ])) throw new PresenceError("session-manager/invalid-request", "invalid retry commit request");
3422
+ return {
3423
+ ok: true,
3424
+ value: await commitRetry({
3425
+ ...services,
3426
+ retryJournal: await retryJournal()
3427
+ }, payload, merged)
3428
+ };
3429
+ }
2682
3430
  if (endpoint === "config.read") {
2683
3431
  if (!payloadKeys(payload, [])) throw new PresenceError("session-manager/invalid-request", "invalid config request");
2684
3432
  return {
@@ -2698,10 +3446,11 @@ function registerSessionManagerRpc(root, transport, config, models, modelStore)
2698
3446
  return failure(error);
2699
3447
  }
2700
3448
  }, { requirePayload: true });
2701
- return () => {
3449
+ return async () => {
2702
3450
  lifetime.abort();
2703
3451
  suggestions.clear();
2704
3452
  unregister();
3453
+ await journal?.then((value) => value.close(), () => {});
2705
3454
  };
2706
3455
  }
2707
3456
  //#endregion