@gajae-code/coding-agent 0.12.19 → 0.12.21

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 (100) hide show
  1. package/CHANGELOG.md +27 -9
  2. package/dist/types/commands/gc.d.ts +4 -0
  3. package/dist/types/commands/sdk.d.ts +66 -0
  4. package/dist/types/config/settings-schema.d.ts +34 -0
  5. package/dist/types/gjc-runtime/gc-runtime.d.ts +97 -1
  6. package/dist/types/modes/acp/acp-agent.d.ts +33 -1
  7. package/dist/types/modes/components/bash-execution.d.ts +7 -0
  8. package/dist/types/modes/components/eval-execution.d.ts +7 -0
  9. package/dist/types/modes/utils/ui-helpers.d.ts +10 -1
  10. package/dist/types/sdk/acp/adapter.d.ts +9 -17
  11. package/dist/types/sdk/acp/index.d.ts +1 -1
  12. package/dist/types/sdk/broker/broker.d.ts +93 -1
  13. package/dist/types/sdk/broker/ensure.d.ts +17 -0
  14. package/dist/types/sdk/broker/index.d.ts +1 -0
  15. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +10 -1
  16. package/dist/types/sdk/broker/lifecycle.d.ts +81 -2
  17. package/dist/types/sdk/broker/startup-budget.d.ts +44 -0
  18. package/dist/types/sdk/bus/chat-daemon-control.d.ts +7 -1
  19. package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +11 -1
  20. package/dist/types/sdk/bus/discord-daemon.d.ts +1 -0
  21. package/dist/types/sdk/bus/index.d.ts +20 -9
  22. package/dist/types/sdk/bus/prompt-reconciliation.d.ts +2 -6
  23. package/dist/types/sdk/bus/slack-daemon.d.ts +2 -2
  24. package/dist/types/sdk/host/query/revision-store.d.ts +5 -0
  25. package/dist/types/sdk/host/session-runtime.d.ts +4 -0
  26. package/dist/types/sdk/lifecycle-session.d.ts +13 -2
  27. package/dist/types/sdk/prompt-failure.d.ts +13 -0
  28. package/dist/types/sdk/prompt-watchdog.d.ts +82 -0
  29. package/dist/types/sdk/session-reconnect.d.ts +20 -0
  30. package/dist/types/sdk/startup-capability.d.ts +1 -1
  31. package/dist/types/session/agent-session.d.ts +14 -4
  32. package/dist/types/session/blob-store.d.ts +32 -0
  33. package/dist/types/session/internal/managed-session-storage.d.ts +40 -2
  34. package/dist/types/session/internal/session-memory-sidecar.d.ts +881 -0
  35. package/dist/types/session/session-manager.d.ts +302 -27
  36. package/dist/types/session/session-storage.d.ts +170 -0
  37. package/dist/types/task/executor.d.ts +2 -1
  38. package/dist/types/tools/bash.d.ts +1 -0
  39. package/dist/types/tools/todo-contract.d.ts +10 -1
  40. package/dist/types/tui/output-block.d.ts +6 -0
  41. package/package.json +8 -8
  42. package/src/cli/args.ts +75 -34
  43. package/src/commands/acp.ts +7 -1
  44. package/src/commands/gc.ts +31 -2
  45. package/src/commands/sdk.ts +141 -7
  46. package/src/config/settings-schema.ts +43 -0
  47. package/src/coordinator-mcp/server.ts +6 -1
  48. package/src/export/html/index.ts +83 -39
  49. package/src/gjc-runtime/gc-runtime.ts +1283 -4
  50. package/src/internal-urls/docs-index.generated.ts +1 -1
  51. package/src/internal-urls/local-protocol.ts +14 -0
  52. package/src/main.ts +40 -9
  53. package/src/modes/acp/acp-agent.ts +564 -37
  54. package/src/modes/acp/terminal-auth.ts +2 -2
  55. package/src/modes/components/bash-execution.ts +14 -0
  56. package/src/modes/components/eval-execution.ts +14 -0
  57. package/src/modes/controllers/command-controller.ts +63 -8
  58. package/src/modes/utils/ui-helpers.ts +81 -11
  59. package/src/sdk/acp/adapter.ts +46 -45
  60. package/src/sdk/acp/index.ts +1 -1
  61. package/src/sdk/broker/broker.ts +392 -11
  62. package/src/sdk/broker/ensure.ts +131 -61
  63. package/src/sdk/broker/index.ts +1 -0
  64. package/src/sdk/broker/lifecycle-ledger.ts +59 -5
  65. package/src/sdk/broker/lifecycle.ts +278 -63
  66. package/src/sdk/broker/startup-budget.ts +103 -0
  67. package/src/sdk/bus/chat-daemon-control.ts +9 -3
  68. package/src/sdk/bus/chat-daemon-runtime.ts +565 -29
  69. package/src/sdk/bus/conversation-store.ts +1 -1
  70. package/src/sdk/bus/discord-daemon.ts +2 -1
  71. package/src/sdk/bus/discord-live-provider.ts +34 -6
  72. package/src/sdk/bus/index.ts +118 -20
  73. package/src/sdk/bus/kind-aware-reconciliation.ts +2 -1
  74. package/src/sdk/bus/prompt-reconciliation.ts +4 -9
  75. package/src/sdk/bus/slack-daemon.ts +91 -18
  76. package/src/sdk/cli/session-cli.ts +8 -1
  77. package/src/sdk/host/query/handlers.ts +1 -1
  78. package/src/sdk/host/query/revision-store.ts +48 -12
  79. package/src/sdk/host/session-runtime.test.ts +50 -0
  80. package/src/sdk/host/session-runtime.ts +12 -2
  81. package/src/sdk/lifecycle-session.ts +26 -3
  82. package/src/sdk/mcp/server.ts +6 -1
  83. package/src/sdk/prompt-failure.ts +42 -0
  84. package/src/sdk/prompt-watchdog.ts +162 -0
  85. package/src/sdk/session-reconnect.ts +43 -0
  86. package/src/sdk/startup-capability.ts +13 -4
  87. package/src/session/agent-session.ts +541 -226
  88. package/src/session/blob-store.ts +89 -0
  89. package/src/session/internal/managed-session-scope.ts +36 -26
  90. package/src/session/internal/managed-session-storage.ts +303 -32
  91. package/src/session/internal/session-memory-sidecar.ts +2160 -0
  92. package/src/session/session-manager.ts +7057 -399
  93. package/src/session/session-storage.ts +1134 -11
  94. package/src/task/executor.ts +17 -4
  95. package/src/tools/bash.ts +109 -7
  96. package/src/tools/descriptors.test.ts +51 -0
  97. package/src/tools/find.ts +55 -26
  98. package/src/tools/todo-contract.ts +82 -13
  99. package/src/tools/todo-write.ts +2 -5
  100. package/src/tui/output-block.ts +11 -1
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
2
2
  import * as fs from "node:fs/promises";
3
3
  import * as os from "node:os";
4
4
  import * as path from "node:path";
5
+ import { logger } from "@gajae-code/utils";
5
6
  import { Args, CliParseError, Command, Flags, renderCommandHelp } from "@gajae-code/utils/cli";
6
7
  import type { Args as ParsedArgs } from "../cli/args";
7
8
  import { Settings } from "../config/settings";
@@ -16,6 +17,9 @@ import {
16
17
  readSessionLifecycleLaunchRequest,
17
18
  type SessionLifecycleLaunchRequest,
18
19
  type SessionLifecycleTranscriptIdentity,
20
+ sessionHostAttachedClients,
21
+ sessionHostWorkInFlight,
22
+ startBrokerDeadRegistrationSweep,
19
23
  writeSessionLifecycleFailure,
20
24
  writeSessionLifecycleReady,
21
25
  } from "../sdk/broker/lifecycle";
@@ -107,6 +111,96 @@ export async function watchSessionHostBrokerLiveness(deps: {
107
111
  }
108
112
  }
109
113
 
114
+ /**
115
+ * How long a session host that has served at least one client tolerates having
116
+ * no client attached before treating itself as abandoned.
117
+ *
118
+ * This cannot fire during healthy work. "Attached" is the host's own live
119
+ * socket-subscription count, so a client that is merely idle — an editor
120
+ * sitting on an open ACP session, a long agent turn with nobody typing — still
121
+ * holds a socket and resets the window on every poll. Only a client that is
122
+ * actually gone opens it, and 30 minutes is far longer than any client
123
+ * reconnect budget (ACP's is seconds), so a crashed-and-restarted client
124
+ * reattaches long before the window closes.
125
+ */
126
+ export const SESSION_HOST_DETACHED_IDLE_GRACE_MS = 30 * 60_000;
127
+
128
+ /**
129
+ * How long a freshly spawned session host waits for its very first client
130
+ * before treating itself as abandoned.
131
+ *
132
+ * A host is ready before its client has finished dialing, so a host that has
133
+ * never seen an attachment must not be judged by the detached window above. One
134
+ * hour is orders of magnitude beyond the slowest observed cold start (worktree
135
+ * preparation, MCP server launch, model-profile application) and beyond any
136
+ * lifecycle readiness deadline the broker will wait on, so it can only elapse
137
+ * for a host nobody ever came for.
138
+ */
139
+ export const SESSION_HOST_FIRST_ATTACH_GRACE_MS = 60 * 60_000;
140
+
141
+ const SESSION_HOST_ATTACHMENT_POLL_MS = 30_000;
142
+
143
+ /**
144
+ * Resolves once this host is provably abandoned: either it has been detached
145
+ * from every client for the full idle grace, or nobody has come for it at all
146
+ * for the full first-attach grace.
147
+ *
148
+ * `readAttachedClients` reports the host's own live client/socket subscription
149
+ * count; `undefined` means the SDK endpoint publishes no such evidence yet and
150
+ * is deliberately *not* treated as detachment, so a host can never be reaped on
151
+ * missing evidence — only on observed absence of clients.
152
+ *
153
+ * `readWorkInFlight` reports whether the host is running agent work right now.
154
+ * Work is positive proof that a client did come for this host: a prompt can
155
+ * only arrive over the endpoint a client dialed. So for a host that has never
156
+ * been *observed* attached — a client that connected, prompted and dropped
157
+ * between two 30s polls, or a client count that is momentarily unreadable — the
158
+ * first-attach window is measured from the last moment work was in flight
159
+ * rather than from process start. That still bounds a host whose SDK runtime
160
+ * never came up: with no transport it can never receive a prompt, so it never
161
+ * reports work and its window keeps running from start. And any real turn ends,
162
+ * after which the window resumes; live work defers the bound, never removes it.
163
+ */
164
+ export async function watchSessionHostClientAttachment(deps: {
165
+ readAttachedClients: () => number | undefined;
166
+ readWorkInFlight?: () => boolean;
167
+ now?: () => number;
168
+ sleep?: (ms: number) => Promise<void>;
169
+ idleGraceMs?: number;
170
+ firstAttachGraceMs?: number;
171
+ pollMs?: number;
172
+ }): Promise<void> {
173
+ const now = deps.now ?? Date.now;
174
+ const sleep = deps.sleep ?? (async ms => await Bun.sleep(ms));
175
+ const readWorkInFlight = deps.readWorkInFlight ?? (() => false);
176
+ const idleGraceMs = deps.idleGraceMs ?? SESSION_HOST_DETACHED_IDLE_GRACE_MS;
177
+ const firstAttachGraceMs = deps.firstAttachGraceMs ?? SESSION_HOST_FIRST_ATTACH_GRACE_MS;
178
+ const pollMs = deps.pollMs ?? SESSION_HOST_ATTACHMENT_POLL_MS;
179
+ let everAttached = false;
180
+ let detachedSince: number | null = null;
181
+ /** Start of the current window in which nobody has been shown to want this host. */
182
+ let unattendedSince = now();
183
+ for (;;) {
184
+ const attached = deps.readAttachedClients();
185
+ if (readWorkInFlight()) unattendedSince = now();
186
+ if (attached === undefined) {
187
+ // No endpoint evidence at all is ambiguity, not abandonment; it still
188
+ // accrues against the first-attach bound so a host whose SDK runtime
189
+ // never came up cannot outlive that bound either.
190
+ if (!everAttached && now() - unattendedSince >= firstAttachGraceMs) return;
191
+ } else if (attached > 0) {
192
+ everAttached = true;
193
+ detachedSince = null;
194
+ } else if (everAttached) {
195
+ detachedSince ??= now();
196
+ if (now() - detachedSince >= idleGraceMs) return;
197
+ } else if (now() - unattendedSince >= firstAttachGraceMs) {
198
+ return;
199
+ }
200
+ await sleep(pollMs);
201
+ }
202
+ }
203
+
110
204
  type LifecycleTranscriptSource = {
111
205
  cwd: string;
112
206
  path: string;
@@ -222,6 +316,7 @@ export async function openLifecycleSessionManager(
222
316
  SessionManager.managedDestination(cwd, agentDir),
223
317
  undefined,
224
318
  migrationPolicy,
319
+ lifecycleSettings.get("sessionMemory.mode"),
225
320
  );
226
321
  if (opened.kind === "error")
227
322
  throw new Error("Lifecycle saved session authority changed while the session host opened it.");
@@ -238,6 +333,7 @@ export async function openLifecycleSessionManager(
238
333
  cwd,
239
334
  SessionManager.managedDestination(cwd, agentDir),
240
335
  migrationPolicy,
336
+ lifecycleSettings.get("sessionMemory.mode"),
241
337
  );
242
338
  if (forked.kind === "error")
243
339
  throw new Error("Lifecycle saved session authority changed while the session host forked it.");
@@ -246,6 +342,22 @@ export async function openLifecycleSessionManager(
246
342
  return { parsed, sessionManager };
247
343
  }
248
344
 
345
+ /**
346
+ * Starts the memory backend a lifecycle session deferred past its readiness
347
+ * window.
348
+ *
349
+ * Deliberately not awaited: the local backend summarises every queued rollout
350
+ * through the model, so its duration scales with the backlog and would eat the
351
+ * broker's readiness budget. The session is already published as ready here, so
352
+ * a failure is a degraded-memory condition, not a startup failure — it is
353
+ * logged and swallowed instead of surfacing as an unhandled rejection.
354
+ */
355
+ export function startMemoryBackendAfterReadiness(start: () => Promise<void>): void {
356
+ void start().catch(error => {
357
+ logger.warn("Deferred memory backend startup failed after readiness", { error: String(error) });
358
+ });
359
+ }
360
+
249
361
  /** Runs the same persisted AgentSession bootstrap used by the production CLI. */
250
362
  export async function runSessionHost(
251
363
  timing: {
@@ -438,7 +550,7 @@ export async function runSessionHost(
438
550
 
439
551
  throw created.failure;
440
552
  }
441
- const { session, capability, rollback } = created;
553
+ const { session, capability, rollback, startDeferredMemoryBackend } = created;
442
554
  let sessionDisposal: Promise<void> | undefined;
443
555
  const disposeSession = (): Promise<void> => {
444
556
  sessionDisposal ??= session.dispose().catch(() => {});
@@ -553,12 +665,24 @@ export async function runSessionHost(
553
665
  await failAfterRollback(durableFailure);
554
666
  throw error;
555
667
  }
668
+ // Readiness is published; the session is live. Memory startup issues one LLM
669
+ // request per queued rollout, so it must run outside the readiness window and
670
+ // its failure must never take the session down.
671
+ startMemoryBackendAfterReadiness(startDeferredMemoryBackend);
556
672
  process.once("SIGTERM", stop);
557
673
  process.once("SIGINT", stop);
558
- // A detached host whose broker is gone for good would otherwise live (and
559
- // hold its session's memory) forever; reap it through the same graceful
560
- // teardown a SIGTERM would take once the bounded absence grace elapses.
561
- await watchSessionHostBrokerLiveness({ agentDir });
674
+ // Two independent bounds, either of which reaps this detached host through
675
+ // the same graceful teardown a SIGTERM would take. The first covers a broker
676
+ // that is gone for good; the second covers the opposite case, a perfectly
677
+ // healthy broker whose host nobody is attached to any more and for which no
678
+ // `session.close` will ever arrive.
679
+ await Promise.race([
680
+ watchSessionHostBrokerLiveness({ agentDir }),
681
+ watchSessionHostClientAttachment({
682
+ readAttachedClients: sessionHostAttachedClients,
683
+ readWorkInFlight: sessionHostWorkInFlight,
684
+ }),
685
+ ]);
562
686
  stop();
563
687
  await new Promise<void>(() => {});
564
688
  }
@@ -617,9 +741,19 @@ export default class Sdk extends Command {
617
741
  });
618
742
  await broker.start();
619
743
  if (!broker.ownsDiscovery) return;
620
- const stop = () => void broker.stop();
744
+ // A live broker must not keep advertising sessions whose host process is
745
+ // gone; the sweep is the broker-side half of the host reaping bound.
746
+ const stopSweep = startBrokerDeadRegistrationSweep(broker);
747
+ const stop = () => {
748
+ stopSweep();
749
+ void broker.stop();
750
+ };
621
751
  process.once("SIGTERM", stop);
622
752
  process.once("SIGINT", stop);
623
- await completeBrokerProcess(broker);
753
+ try {
754
+ await completeBrokerProcess(broker);
755
+ } finally {
756
+ stopSweep();
757
+ }
624
758
  }
625
759
  }
@@ -2079,6 +2079,20 @@ export const SETTINGS_SCHEMA = {
2079
2079
  },
2080
2080
  },
2081
2081
 
2082
+ // Bounded-memory cold-session offloading (rollout knobs; budgets are fixed
2083
+ // implementation constants in the sidecar primitives, not user-tunable
2084
+ // fields). "shadow" (default) measures and offloads without changing
2085
+ // observable behavior; canary/default-on are release-channel states, not
2086
+ // user-facing enum values.
2087
+ "sessionMemory.mode": {
2088
+ type: "enum",
2089
+ values: ["off", "shadow", "enabled"] as const,
2090
+ default: "shadow",
2091
+ },
2092
+ // Independent runtime switch for AgentSession's async compact-once overflow
2093
+ // recovery. The synchronous bounded context preflight stays always on.
2094
+ "sessionMemory.contextOverflowRecovery": { type: "boolean", default: true },
2095
+
2082
2096
  // ────────────────────────────────────────────────────────────────────────
2083
2097
  // Editing
2084
2098
  // ────────────────────────────────────────────────────────────────────────
@@ -2810,6 +2824,29 @@ export const SETTINGS_SCHEMA = {
2810
2824
  description: "Parent-process RSS (MB) above which idle tabs are opportunistically evicted LRU.",
2811
2825
  },
2812
2826
  },
2827
+ // `gjc gc --disk` retention policy. Report-only unless `--prune` is also passed;
2828
+ // these knobs never run implicitly and never affect the PID-liveness axis.
2829
+ "gc.sessions.maxAgeDays": {
2830
+ type: "number",
2831
+ default: 60,
2832
+ validate: (value: number) => Number.isFinite(value) && value >= 0,
2833
+ },
2834
+ // 0 disables the size axis; only the age axis retires transcripts then.
2835
+ "gc.sessions.maxTotalBytes": {
2836
+ type: "number",
2837
+ default: 0,
2838
+ validate: (value: number) => Number.isFinite(value) && value >= 0,
2839
+ },
2840
+ "gc.natives.keepVersions": {
2841
+ type: "number",
2842
+ default: 2,
2843
+ validate: (value: number) => Number.isInteger(value) && value >= 0,
2844
+ },
2845
+ "gc.backups.maxAgeDays": {
2846
+ type: "number",
2847
+ default: 30,
2848
+ validate: (value: number) => Number.isFinite(value) && value >= 0,
2849
+ },
2813
2850
  "resourceGc.sweepIntervalMs": {
2814
2851
  type: "number",
2815
2852
  default: 30_000,
@@ -4136,6 +4173,11 @@ export interface ShellMinimizerSettings {
4136
4173
  maxCaptureBytes: number;
4137
4174
  }
4138
4175
 
4176
+ export interface SessionMemorySettings {
4177
+ mode: "off" | "shadow" | "enabled";
4178
+ contextOverflowRecovery: boolean;
4179
+ }
4180
+
4139
4181
  export interface MemoryGuardSettings {
4140
4182
  enabled: boolean;
4141
4183
  checkIntervalMs: number;
@@ -4211,6 +4253,7 @@ export interface GroupTypeMap {
4211
4253
  thinkingBudgets: ThinkingBudgetsSettings;
4212
4254
  stt: SttSettings;
4213
4255
  memoryGuard: MemoryGuardSettings;
4256
+ sessionMemory: SessionMemorySettings;
4214
4257
  modelRoles: Record<string, ModelSelectorValue>;
4215
4258
  modelTags: ModelTagsSettings;
4216
4259
  cycleOrder: string[];
@@ -16,6 +16,7 @@ import { listMcpDelegateHostContexts } from "../hooks/mcp-delegate-host-context"
16
16
  import type { WorkflowGate, WorkflowGateQueryRecord } from "../modes/shared/agent-wire/workflow-gate-types";
17
17
  import type { BrokerDiscovery } from "../sdk/broker/discovery";
18
18
  import { type EnsureBrokerSettings, ensureBroker } from "../sdk/broker/ensure";
19
+ import { lifecycleRequestTimeoutMs } from "../sdk/broker/startup-budget";
19
20
  import { UnsupportedStateVersionError } from "../sdk/broker/state-version";
20
21
  import { SdkClient, SdkClientError } from "../sdk/client/client";
21
22
  import { readSdkBrokerDiscovery } from "../sdk/client/discovery";
@@ -3183,7 +3184,11 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
3183
3184
  let requestError: SdkClientError | undefined;
3184
3185
  let result: unknown;
3185
3186
  try {
3186
- result = await client.global(operation, input, { ...(idempotencyKey ? { idempotencyKey } : {}) });
3187
+ const timeoutMs = lifecycleRequestTimeoutMs(operation, input);
3188
+ result = await client.global(operation, input, {
3189
+ ...(idempotencyKey ? { idempotencyKey } : {}),
3190
+ ...(timeoutMs === undefined ? {} : { timeoutMs }),
3191
+ });
3187
3192
  } catch (error) {
3188
3193
  requestError = toCoordinatorBrokerError("request", error);
3189
3194
  }
@@ -1,8 +1,9 @@
1
+ import * as fs from "node:fs/promises";
1
2
  import * as path from "node:path";
2
3
  import type { AgentState } from "@gajae-code/agent-core";
3
4
  import { APP_NAME, isEnoent } from "@gajae-code/utils";
4
5
  import { getResolvedThemeColors, getThemeExportColors } from "../../modes/theme/theme";
5
- import { type SessionEntry, type SessionHeader, SessionManager } from "../../session/session-manager";
6
+ import { SessionManager } from "../../session/session-manager";
6
7
  // Pre-generated template (created by scripts/generate-template.ts at publish time)
7
8
  import { TEMPLATE } from "./template.generated";
8
9
 
@@ -90,26 +91,72 @@ async function generateThemeVars(themeName?: string): Promise<string> {
90
91
  return lines.join(" ");
91
92
  }
92
93
 
93
- interface SessionData {
94
- header: SessionHeader | null;
95
- entries: SessionEntry[];
96
- leafId: string | null;
97
- systemPrompt?: string;
98
- tools?: { name: string; description: string }[];
94
+ class Base64StreamEncoder {
95
+ #carry = Buffer.alloc(0);
96
+
97
+ write(value: string): string {
98
+ const incoming = Buffer.from(value, "utf8");
99
+ const bytes = this.#carry.byteLength === 0 ? incoming : Buffer.concat([this.#carry, incoming]);
100
+ const completeLength = bytes.byteLength - (bytes.byteLength % 3);
101
+ this.#carry = bytes.subarray(completeLength);
102
+ return completeLength === 0 ? "" : bytes.subarray(0, completeLength).toString("base64");
103
+ }
104
+
105
+ finish(): string {
106
+ const final = this.#carry.toString("base64");
107
+ this.#carry = Buffer.alloc(0);
108
+ return final;
109
+ }
99
110
  }
100
111
 
101
- /** Generate HTML from bundled template with runtime substitutions. */
102
- async function generateHtml(sessionData: SessionData, themeName?: string): Promise<string> {
112
+ async function writeSessionHtml(
113
+ sm: SessionManager,
114
+ state: AgentState | undefined,
115
+ outputPath: string,
116
+ themeName?: string,
117
+ ): Promise<void> {
103
118
  const themeVars = await generateThemeVars(themeName);
104
- const sessionDataBase64 = Buffer.from(JSON.stringify(sessionData)).toBase64();
105
-
106
- // Use function replacements so `$'`, `$&`, `$$`, `$n`, etc. in the
107
- // substituted CSS/base64 are not interpreted as substitution patterns
108
- // (see https://mdn.io/String.replace).
109
- return TEMPLATE.replace("<theme-vars/>", () => `<style>:root { ${themeVars} }</style>`).replace(
110
- "{{SESSION_DATA}}",
111
- () => sessionDataBase64,
112
- );
119
+ const themedTemplate = TEMPLATE.replace("<theme-vars/>", () => `<style>:root { ${themeVars} }</style>`);
120
+ const marker = "{{SESSION_DATA}}";
121
+ const markerOffset = themedTemplate.indexOf(marker);
122
+ if (markerOffset < 0) throw new Error("HTML export template is missing the session-data marker");
123
+ const sourcePath = sm.getSessionFile();
124
+ if (sourcePath) {
125
+ const sourceIdentity = await fs.realpath(sourcePath).catch(() => path.resolve(sourcePath));
126
+ const outputIdentity = await fs.realpath(outputPath).catch(() => path.resolve(outputPath));
127
+ if (sourceIdentity === outputIdentity)
128
+ throw new Error("HTML export output must not overwrite the source transcript");
129
+ }
130
+ const sink = Bun.file(outputPath).writer();
131
+ const encoder = new Base64StreamEncoder();
132
+ const writeEncoded = (value: string): void => {
133
+ const encoded = encoder.write(value);
134
+ if (encoded) sink.write(encoded);
135
+ };
136
+ try {
137
+ sink.write(themedTemplate.slice(0, markerOffset));
138
+ writeEncoded(`{"header":${JSON.stringify(sm.getHeader())},"entries":[`);
139
+ let first = true;
140
+ sm.visitEntriesForExport(entry => {
141
+ writeEncoded(`${first ? "" : ","}${JSON.stringify(entry)}`);
142
+ first = false;
143
+ });
144
+ writeEncoded(`],"leafId":${JSON.stringify(sm.getLeafId())}`);
145
+ if (state?.systemPrompt !== undefined)
146
+ writeEncoded(`,"systemPrompt":${JSON.stringify(state.systemPrompt.join("\n\n"))}`);
147
+ if (state?.tools !== undefined)
148
+ writeEncoded(
149
+ `,"tools":${JSON.stringify(state.tools.map(tool => ({ name: tool.name, description: tool.description })))}`,
150
+ );
151
+ writeEncoded("}");
152
+ const final = encoder.finish();
153
+ if (final) sink.write(final);
154
+ sink.write(themedTemplate.slice(markerOffset + marker.length));
155
+ await sink.end();
156
+ } catch (error) {
157
+ await sink.end();
158
+ throw error;
159
+ }
113
160
  }
114
161
 
115
162
  /** Export session to HTML using SessionManager and AgentState. */
@@ -123,18 +170,8 @@ export async function exportSessionToHtml(
123
170
  const sessionFile = sm.getSessionFile();
124
171
  if (!sessionFile) throw new Error("Cannot export in-memory session to HTML");
125
172
 
126
- const sessionData: SessionData = {
127
- header: sm.getHeader(),
128
- entries: sm.getEntriesForExport(),
129
- leafId: sm.getLeafId(),
130
- systemPrompt: state?.systemPrompt.join("\n\n"),
131
- tools: state?.tools?.map(t => ({ name: t.name, description: t.description })),
132
- };
133
-
134
- const html = await generateHtml(sessionData, opts.themeName);
135
173
  const outputPath = opts.outputPath || `${APP_NAME}-session-${path.basename(sessionFile, ".jsonl")}.html`;
136
-
137
- await Bun.write(outputPath, html);
174
+ await writeSessionHtml(sm, state, outputPath, opts.themeName);
138
175
  return outputPath;
139
176
  }
140
177
 
@@ -143,24 +180,31 @@ export async function exportFromFile(inputPath: string, options?: ExportOptions
143
180
  const opts: ExportOptions = typeof options === "string" ? { outputPath: options } : options || {};
144
181
 
145
182
  let sm: SessionManager;
183
+ const artifactRoot = inputPath.endsWith(".jsonl") ? inputPath.slice(0, -6) : inputPath;
184
+ const hasExistingSidecars = (
185
+ await Promise.all(
186
+ ["idx", "tail", "commit"].map(kind =>
187
+ Bun.file(path.join(artifactRoot, `.session-memory.spill.${kind}`)).exists(),
188
+ ),
189
+ )
190
+ ).some(Boolean);
191
+ if (!(await Bun.file(inputPath).exists())) throw new Error(`File not found: ${inputPath}`);
146
192
  try {
147
- sm = await SessionManager.open(inputPath);
193
+ sm = await SessionManager.open(
194
+ inputPath,
195
+ undefined,
196
+ undefined,
197
+ "copy-retain",
198
+ hasExistingSidecars ? "off" : "enabled",
199
+ );
148
200
  } catch (err) {
149
201
  if (isEnoent(err)) throw new Error(`File not found: ${inputPath}`);
150
202
  throw err;
151
203
  }
152
204
 
153
205
  try {
154
- const sessionData: SessionData = {
155
- header: sm.getHeader(),
156
- entries: sm.getEntriesForExport(),
157
- leafId: sm.getLeafId(),
158
- };
159
-
160
- const html = await generateHtml(sessionData, opts.themeName);
161
206
  const outputPath = opts.outputPath || `${APP_NAME}-session-${path.basename(inputPath, ".jsonl")}.html`;
162
-
163
- await Bun.write(outputPath, html);
207
+ await writeSessionHtml(sm, undefined, outputPath, opts.themeName);
164
208
  return outputPath;
165
209
  } finally {
166
210
  await sm.close();