@co0ontty/wand 3.1.1 → 4.0.0

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 (62) hide show
  1. package/dist/auth.d.ts +19 -5
  2. package/dist/auth.js +83 -45
  3. package/dist/build-info.json +3 -3
  4. package/dist/cert.d.ts +1 -1
  5. package/dist/cert.js +124 -74
  6. package/dist/config.js +25 -8
  7. package/dist/express-async.d.ts +6 -0
  8. package/dist/express-async.js +28 -0
  9. package/dist/git-quick-commit.d.ts +2 -0
  10. package/dist/git-quick-commit.js +215 -76
  11. package/dist/git-utils.d.ts +4 -0
  12. package/dist/git-utils.js +60 -11
  13. package/dist/git-worktree.d.ts +8 -1
  14. package/dist/git-worktree.js +406 -41
  15. package/dist/models.d.ts +34 -4
  16. package/dist/models.js +334 -48
  17. package/dist/process-manager.d.ts +22 -30
  18. package/dist/process-manager.js +374 -441
  19. package/dist/provider-history-scanner.d.ts +54 -0
  20. package/dist/provider-history-scanner.js +354 -0
  21. package/dist/request-limits.d.ts +1 -0
  22. package/dist/request-limits.js +8 -0
  23. package/dist/resume-policy.d.ts +2 -0
  24. package/dist/resume-policy.js +5 -0
  25. package/dist/runtime-config.d.ts +16 -0
  26. package/dist/runtime-config.js +49 -0
  27. package/dist/server-file-routes.d.ts +17 -0
  28. package/dist/server-file-routes.js +653 -0
  29. package/dist/server-session-routes.d.ts +16 -3
  30. package/dist/server-session-routes.js +170 -149
  31. package/dist/server-settings-routes.d.ts +43 -0
  32. package/dist/server-settings-routes.js +225 -0
  33. package/dist/server-update-routes.d.ts +61 -0
  34. package/dist/server-update-routes.js +215 -0
  35. package/dist/server.d.ts +6 -4
  36. package/dist/server.js +350 -1313
  37. package/dist/session-logger.d.ts +32 -2
  38. package/dist/session-logger.js +145 -15
  39. package/dist/session-registry.d.ts +27 -0
  40. package/dist/session-registry.js +153 -0
  41. package/dist/session-transport.d.ts +31 -0
  42. package/dist/session-transport.js +82 -0
  43. package/dist/storage.d.ts +24 -6
  44. package/dist/storage.js +291 -44
  45. package/dist/structured-claude-adapter.d.ts +19 -0
  46. package/dist/structured-claude-adapter.js +117 -0
  47. package/dist/structured-codex-adapter.d.ts +3 -0
  48. package/dist/structured-codex-adapter.js +29 -0
  49. package/dist/structured-opencode-adapter.d.ts +11 -0
  50. package/dist/structured-opencode-adapter.js +115 -0
  51. package/dist/structured-provider-common.d.ts +11 -0
  52. package/dist/structured-provider-common.js +77 -0
  53. package/dist/structured-session-manager.d.ts +32 -35
  54. package/dist/structured-session-manager.js +551 -605
  55. package/dist/types.d.ts +10 -0
  56. package/dist/update-helper.js +5 -1
  57. package/dist/web-ui/content/scripts.js +32 -32
  58. package/dist/web-ui/embedded-assets.d.ts +1 -1
  59. package/dist/web-ui/embedded-assets.js +2 -2
  60. package/dist/ws-broadcast.d.ts +16 -1
  61. package/dist/ws-broadcast.js +124 -58
  62. package/package.json +2 -1
@@ -20,6 +20,15 @@ export interface ShortcutLogContext {
20
20
  /** Whether the auto-approve was a false positive */
21
21
  falsePositive?: boolean;
22
22
  }
23
+ /** Optional tuning and I/O injection used by focused logger tests. */
24
+ export interface SessionLoggerOptions {
25
+ flushIntervalMs?: number;
26
+ perFileBufferMaxBytes?: number;
27
+ totalBufferMaxBytes?: number;
28
+ ptyLogMaxBytes?: number;
29
+ ptyLogMaxRotations?: number;
30
+ appendFile?: (filePath: string, data: string) => void;
31
+ }
23
32
  /**
24
33
  * SessionLogger saves raw session content to local files for debugging and analysis.
25
34
  *
@@ -35,10 +44,20 @@ export interface ShortcutLogContext {
35
44
  export declare class SessionLogger {
36
45
  private readonly baseDir;
37
46
  private readonly dirs;
38
- /** Cached on-disk size of hot-path log files so we can rotate without stat'ing on every chunk. */
47
+ /** Cached logical size (disk + pending buffer) so rotation does not stat every chunk. */
39
48
  private readonly logSizes;
40
49
  private readonly shortcutLogMaxBytes;
41
- constructor(configDir: string, shortcutLogMaxBytes?: number);
50
+ private readonly flushIntervalMs;
51
+ private readonly perFileBufferMaxBytes;
52
+ private readonly totalBufferMaxBytes;
53
+ private readonly ptyLogMaxBytes;
54
+ private readonly ptyLogMaxRotations;
55
+ private readonly appendFile;
56
+ private readonly appendBuffers;
57
+ private totalBufferedBytes;
58
+ private flushTimer;
59
+ private disposed;
60
+ constructor(configDir: string, shortcutLogMaxBytes?: number, options?: SessionLoggerOptions);
42
61
  private ensureDir;
43
62
  /**
44
63
  * Rotate PTY log files if the current one exceeds the size limit.
@@ -71,4 +90,15 @@ export declare class SessionLogger {
71
90
  appendShortcutLog(sessionId: string, shortcutKey: string, tailLines: string, ctx?: ShortcutLogContext): void;
72
91
  /** Truncate shortcut log by keeping only the most recent half of entries. Returns the new on-disk size. */
73
92
  private truncateShortcutLog;
93
+ /** Synchronously persist all pending append logs owned by one session. */
94
+ flushSession(sessionId: string): void;
95
+ /** Synchronously persist every pending append log owned by this logger. */
96
+ flushAll(): void;
97
+ /** Stop the coalescing timer and synchronously persist the final batch. */
98
+ dispose(): void;
99
+ private enqueueAppend;
100
+ private flushFile;
101
+ private scheduleFlush;
102
+ private clearFlushTimerIfIdle;
103
+ private clearFlushTimer;
74
104
  }
@@ -8,6 +8,12 @@ const PTY_LOG_MAX_SIZE = 50 * 1024 * 1024;
8
8
  const PTY_LOG_MAX_ROTATIONS = 3;
9
9
  /** Default max size for shortcut interaction logs per session (10 MB) */
10
10
  const DEFAULT_SHORTCUT_LOG_MAX_BYTES = 10 * 1024 * 1024;
11
+ /** Delay used to coalesce hot-path append calls without adding noticeable log latency. */
12
+ const DEFAULT_FLUSH_INTERVAL_MS = 40;
13
+ /** Bound memory retained by one hot log file. */
14
+ const DEFAULT_PER_FILE_BUFFER_MAX_BYTES = 256 * 1024;
15
+ /** Bound memory retained across all sessions owned by one logger instance. */
16
+ const DEFAULT_TOTAL_BUFFER_MAX_BYTES = 4 * 1024 * 1024;
11
17
  /**
12
18
  * SessionLogger saves raw session content to local files for debugging and analysis.
13
19
  *
@@ -23,12 +29,28 @@ const DEFAULT_SHORTCUT_LOG_MAX_BYTES = 10 * 1024 * 1024;
23
29
  export class SessionLogger {
24
30
  baseDir;
25
31
  dirs = new Map();
26
- /** Cached on-disk size of hot-path log files so we can rotate without stat'ing on every chunk. */
32
+ /** Cached logical size (disk + pending buffer) so rotation does not stat every chunk. */
27
33
  logSizes = new Map();
28
34
  shortcutLogMaxBytes;
29
- constructor(configDir, shortcutLogMaxBytes) {
35
+ flushIntervalMs;
36
+ perFileBufferMaxBytes;
37
+ totalBufferMaxBytes;
38
+ ptyLogMaxBytes;
39
+ ptyLogMaxRotations;
40
+ appendFile;
41
+ appendBuffers = new Map();
42
+ totalBufferedBytes = 0;
43
+ flushTimer = null;
44
+ disposed = false;
45
+ constructor(configDir, shortcutLogMaxBytes, options = {}) {
30
46
  this.baseDir = path.join(configDir, "sessions");
31
47
  this.shortcutLogMaxBytes = shortcutLogMaxBytes ?? DEFAULT_SHORTCUT_LOG_MAX_BYTES;
48
+ this.flushIntervalMs = positiveInteger(options.flushIntervalMs, DEFAULT_FLUSH_INTERVAL_MS);
49
+ this.perFileBufferMaxBytes = positiveInteger(options.perFileBufferMaxBytes, DEFAULT_PER_FILE_BUFFER_MAX_BYTES);
50
+ this.totalBufferMaxBytes = positiveInteger(options.totalBufferMaxBytes, DEFAULT_TOTAL_BUFFER_MAX_BYTES);
51
+ this.ptyLogMaxBytes = positiveInteger(options.ptyLogMaxBytes, PTY_LOG_MAX_SIZE);
52
+ this.ptyLogMaxRotations = positiveInteger(options.ptyLogMaxRotations, PTY_LOG_MAX_ROTATIONS);
53
+ this.appendFile = options.appendFile ?? ((filePath, data) => appendFileSync(filePath, data));
32
54
  try {
33
55
  mkdirSync(this.baseDir, { recursive: true });
34
56
  }
@@ -62,12 +84,12 @@ export class SessionLogger {
62
84
  */
63
85
  rotatePtyLog(dir) {
64
86
  // Delete oldest if it exists (beyond max rotations)
65
- const oldest = path.join(dir, `pty-output.log.${PTY_LOG_MAX_ROTATIONS}`);
87
+ const oldest = path.join(dir, `pty-output.log.${this.ptyLogMaxRotations}`);
66
88
  if (existsSync(oldest)) {
67
89
  unlinkSync(oldest);
68
90
  }
69
91
  // Shift existing rotations up by one
70
- for (let i = PTY_LOG_MAX_ROTATIONS - 1; i >= 1; i--) {
92
+ for (let i = this.ptyLogMaxRotations - 1; i >= 1; i--) {
71
93
  const src = path.join(dir, `pty-output.log.${i}`);
72
94
  const dst = path.join(dir, `pty-output.log.${i + 1}`);
73
95
  if (existsSync(src)) {
@@ -82,16 +104,21 @@ export class SessionLogger {
82
104
  }
83
105
  /** Append raw PTY output chunk */
84
106
  appendPtyOutput(sessionId, chunk) {
107
+ if (this.disposed || chunk.length === 0)
108
+ return;
85
109
  try {
86
110
  const dir = this.ensureDir(sessionId);
87
111
  const sizes = this.logSizes.get(sessionId);
88
- if (sizes.pty >= PTY_LOG_MAX_SIZE) {
112
+ const logPath = path.join(dir, "pty-output.log");
113
+ if (sizes.pty >= this.ptyLogMaxBytes) {
114
+ // Pending bytes must reach the current file before it is renamed.
115
+ this.flushFile(logPath);
89
116
  this.rotatePtyLog(dir);
90
117
  sizes.pty = 0;
91
118
  }
92
- const logPath = path.join(dir, "pty-output.log");
93
- appendFileSync(logPath, chunk);
94
- sizes.pty += Buffer.byteLength(chunk);
119
+ const chunkBytes = Buffer.byteLength(chunk);
120
+ sizes.pty += chunkBytes;
121
+ this.enqueueAppend(sessionId, logPath, chunk, chunkBytes, "pty");
95
122
  }
96
123
  catch {
97
124
  // Non-critical — don't let logging failures affect main flow
@@ -100,9 +127,10 @@ export class SessionLogger {
100
127
  /** Read the full PTY transcript including rotated logs, oldest first. */
101
128
  readPtyOutput(sessionId) {
102
129
  try {
130
+ this.flushSession(sessionId);
103
131
  const dir = this.ensureDir(sessionId);
104
132
  const parts = [];
105
- for (let index = PTY_LOG_MAX_ROTATIONS; index >= 1; index -= 1) {
133
+ for (let index = this.ptyLogMaxRotations; index >= 1; index -= 1) {
106
134
  const rotatedPath = path.join(dir, `pty-output.log.${index}`);
107
135
  if (existsSync(rotatedPath)) {
108
136
  parts.push(readFileSync(rotatedPath, "utf8"));
@@ -122,9 +150,11 @@ export class SessionLogger {
122
150
  }
123
151
  /** Append a native mode NDJSON event */
124
152
  appendStreamEvent(sessionId, event) {
153
+ if (this.disposed)
154
+ return;
125
155
  try {
126
156
  const dir = this.ensureDir(sessionId);
127
- appendFileSync(path.join(dir, "stream-events.jsonl"), JSON.stringify(event) + "\n");
157
+ this.enqueueAppend(sessionId, path.join(dir, "stream-events.jsonl"), JSON.stringify(event) + "\n");
128
158
  }
129
159
  catch {
130
160
  // Non-critical
@@ -132,9 +162,11 @@ export class SessionLogger {
132
162
  }
133
163
  /** Append raw stdout chunk from a structured-mode child process. */
134
164
  appendStructuredStdout(sessionId, chunk) {
165
+ if (this.disposed || chunk.length === 0)
166
+ return;
135
167
  try {
136
168
  const dir = this.ensureDir(sessionId);
137
- appendFileSync(path.join(dir, "structured-stdout.log"), chunk);
169
+ this.enqueueAppend(sessionId, path.join(dir, "structured-stdout.log"), chunk);
138
170
  }
139
171
  catch {
140
172
  // Non-critical
@@ -142,9 +174,11 @@ export class SessionLogger {
142
174
  }
143
175
  /** Append raw stderr chunk from a structured-mode child process. */
144
176
  appendStructuredStderr(sessionId, chunk) {
177
+ if (this.disposed || chunk.length === 0)
178
+ return;
145
179
  try {
146
180
  const dir = this.ensureDir(sessionId);
147
- appendFileSync(path.join(dir, "structured-stderr.log"), chunk);
181
+ this.enqueueAppend(sessionId, path.join(dir, "structured-stderr.log"), chunk);
148
182
  }
149
183
  catch {
150
184
  // Non-critical
@@ -152,10 +186,12 @@ export class SessionLogger {
152
186
  }
153
187
  /** Append a spawn metadata record (args, pid, cwd, exit, errors, …) for a structured run. */
154
188
  appendStructuredSpawn(sessionId, meta) {
189
+ if (this.disposed)
190
+ return;
155
191
  try {
156
192
  const dir = this.ensureDir(sessionId);
157
193
  const entry = JSON.stringify({ ts: new Date().toISOString(), ...meta }) + "\n";
158
- appendFileSync(path.join(dir, "structured-spawns.jsonl"), entry);
194
+ this.enqueueAppend(sessionId, path.join(dir, "structured-spawns.jsonl"), entry);
159
195
  }
160
196
  catch {
161
197
  // Non-critical
@@ -164,6 +200,7 @@ export class SessionLogger {
164
200
  /** Read recent stderr tail (for surfacing in failure messages). */
165
201
  readStructuredStderrTail(sessionId, maxBytes = 4096) {
166
202
  try {
203
+ this.flushSession(sessionId);
167
204
  const dir = this.ensureDir(sessionId);
168
205
  const filePath = path.join(dir, "structured-stderr.log");
169
206
  if (!existsSync(filePath))
@@ -177,6 +214,8 @@ export class SessionLogger {
177
214
  }
178
215
  /** Save the current structured messages snapshot */
179
216
  saveMessages(sessionId, messages) {
217
+ if (this.disposed)
218
+ return;
180
219
  try {
181
220
  const dir = this.ensureDir(sessionId);
182
221
  writeFileSync(path.join(dir, "messages.json"), JSON.stringify(messages, null, 2) + "\n");
@@ -187,6 +226,8 @@ export class SessionLogger {
187
226
  }
188
227
  /** Save session metadata */
189
228
  saveMetadata(sessionId, meta) {
229
+ if (this.disposed)
230
+ return;
190
231
  try {
191
232
  const dir = this.ensureDir(sessionId);
192
233
  writeFileSync(path.join(dir, "metadata.json"), JSON.stringify(meta, null, 2) + "\n");
@@ -197,6 +238,9 @@ export class SessionLogger {
197
238
  }
198
239
  /** Delete all log files for a session */
199
240
  deleteSession(sessionId) {
241
+ // Flush and remove every pending entry before deleting the directory so a
242
+ // later timer cannot recreate files for a deleted session.
243
+ this.flushSession(sessionId);
200
244
  const dir = path.join(this.baseDir, sessionId);
201
245
  try {
202
246
  rmSync(dir, { recursive: true, force: true });
@@ -209,7 +253,7 @@ export class SessionLogger {
209
253
  }
210
254
  /** Append a shortcut key interaction log entry (for analyzing auto-confirm gaps) */
211
255
  appendShortcutLog(sessionId, shortcutKey, tailLines, ctx) {
212
- if (this.shortcutLogMaxBytes <= 0)
256
+ if (this.disposed || this.shortcutLogMaxBytes <= 0)
213
257
  return;
214
258
  try {
215
259
  const dir = this.ensureDir(sessionId);
@@ -226,10 +270,11 @@ export class SessionLogger {
226
270
  }) + "\n";
227
271
  const entryBytes = Buffer.byteLength(entry);
228
272
  if (sizes.shortcut + entryBytes > this.shortcutLogMaxBytes) {
273
+ this.flushFile(logPath);
229
274
  sizes.shortcut = this.truncateShortcutLog(logPath);
230
275
  }
231
- appendFileSync(logPath, entry);
232
276
  sizes.shortcut += entryBytes;
277
+ this.enqueueAppend(sessionId, logPath, entry, entryBytes, "shortcut");
233
278
  }
234
279
  catch {
235
280
  // Non-critical
@@ -253,6 +298,86 @@ export class SessionLogger {
253
298
  return 0;
254
299
  }
255
300
  }
301
+ /** Synchronously persist all pending append logs owned by one session. */
302
+ flushSession(sessionId) {
303
+ for (const [filePath, pending] of Array.from(this.appendBuffers.entries())) {
304
+ if (pending.sessionId === sessionId)
305
+ this.flushFile(filePath);
306
+ }
307
+ this.clearFlushTimerIfIdle();
308
+ }
309
+ /** Synchronously persist every pending append log owned by this logger. */
310
+ flushAll() {
311
+ this.clearFlushTimer();
312
+ for (const filePath of Array.from(this.appendBuffers.keys())) {
313
+ this.flushFile(filePath);
314
+ }
315
+ }
316
+ /** Stop the coalescing timer and synchronously persist the final batch. */
317
+ dispose() {
318
+ if (this.disposed)
319
+ return;
320
+ this.disposed = true;
321
+ this.flushAll();
322
+ }
323
+ enqueueAppend(sessionId, filePath, chunk, byteLength = Buffer.byteLength(chunk), sizeKind) {
324
+ let pending = this.appendBuffers.get(filePath);
325
+ if (!pending) {
326
+ pending = { sessionId, chunks: [], byteLength: 0, sizeKind };
327
+ this.appendBuffers.set(filePath, pending);
328
+ }
329
+ pending.chunks.push(chunk);
330
+ pending.byteLength += byteLength;
331
+ this.totalBufferedBytes += byteLength;
332
+ if (pending.byteLength >= this.perFileBufferMaxBytes) {
333
+ this.flushFile(filePath);
334
+ }
335
+ if (this.totalBufferedBytes >= this.totalBufferMaxBytes) {
336
+ this.flushAll();
337
+ }
338
+ else if (this.totalBufferedBytes > 0) {
339
+ this.scheduleFlush();
340
+ }
341
+ }
342
+ flushFile(filePath) {
343
+ const pending = this.appendBuffers.get(filePath);
344
+ if (!pending)
345
+ return;
346
+ this.appendBuffers.delete(filePath);
347
+ this.totalBufferedBytes = Math.max(0, this.totalBufferedBytes - pending.byteLength);
348
+ try {
349
+ this.appendFile(filePath, pending.chunks.join(""));
350
+ }
351
+ catch {
352
+ // Match the previous best-effort behavior. Do not retain a failing batch
353
+ // indefinitely, but remove its bytes from logical rotation accounting.
354
+ if (pending.sizeKind) {
355
+ const sizes = this.logSizes.get(pending.sessionId);
356
+ if (sizes)
357
+ sizes[pending.sizeKind] = Math.max(0, sizes[pending.sizeKind] - pending.byteLength);
358
+ }
359
+ }
360
+ this.clearFlushTimerIfIdle();
361
+ }
362
+ scheduleFlush() {
363
+ if (this.flushTimer || this.disposed)
364
+ return;
365
+ this.flushTimer = setTimeout(() => {
366
+ this.flushTimer = null;
367
+ this.flushAll();
368
+ }, this.flushIntervalMs);
369
+ this.flushTimer.unref?.();
370
+ }
371
+ clearFlushTimerIfIdle() {
372
+ if (this.totalBufferedBytes === 0)
373
+ this.clearFlushTimer();
374
+ }
375
+ clearFlushTimer() {
376
+ if (!this.flushTimer)
377
+ return;
378
+ clearTimeout(this.flushTimer);
379
+ this.flushTimer = null;
380
+ }
256
381
  }
257
382
  function tryStatSize(filePath) {
258
383
  try {
@@ -262,3 +387,8 @@ function tryStatSize(filePath) {
262
387
  return 0;
263
388
  }
264
389
  }
390
+ function positiveInteger(value, fallback) {
391
+ return value !== undefined && Number.isFinite(value) && value > 0
392
+ ? Math.floor(value)
393
+ : fallback;
394
+ }
@@ -0,0 +1,27 @@
1
+ import type { ProcessManager } from "./process-manager.js";
2
+ import type { StructuredSessionManager } from "./structured-session-manager.js";
3
+ import type { WandStorage } from "./storage.js";
4
+ import type { ExecutionMode, SessionSnapshot } from "./types.js";
5
+ export type SessionOwner = "structured" | "pty" | "storage";
6
+ /**
7
+ * Coordinates session ownership without merging the two runner managers.
8
+ * Live structured state wins over PTY, and both win over a durable fallback.
9
+ */
10
+ export declare class SessionRegistry {
11
+ private readonly processes;
12
+ private readonly structured;
13
+ private readonly storage;
14
+ constructor(processes: ProcessManager, structured: StructuredSessionManager, storage: WandStorage);
15
+ ownerOf(id: string): SessionOwner | null;
16
+ get(id: string): SessionSnapshot | null;
17
+ getLatest(id: string): SessionSnapshot | null;
18
+ listSlim(): SessionSnapshot[];
19
+ setSessionModel(id: string, model: string | null): SessionSnapshot | null;
20
+ setSessionThinkingEffort(id: string, effort: SessionSnapshot["thinkingEffort"]): SessionSnapshot | null;
21
+ setSessionMode(id: string, mode: ExecutionMode): SessionSnapshot | null;
22
+ setSessionTopic(id: string, title: string, description: string): SessionSnapshot | null;
23
+ updateWorktreeState(id: string, status: SessionSnapshot["worktreeMergeStatus"], info: SessionSnapshot["worktreeMergeInfo"]): SessionSnapshot | null;
24
+ delete(id: string): SessionSnapshot | null;
25
+ deleteWithProviderHistory(id: string): SessionSnapshot | null;
26
+ private updateStored;
27
+ }
@@ -0,0 +1,153 @@
1
+ function slimSnapshot(snapshot) {
2
+ const { output: _output, messages: _messages, ...slim } = snapshot;
3
+ return { ...slim, output: "" };
4
+ }
5
+ function addHiddenProviderSessionId(storage, id) {
6
+ const raw = storage.getConfigValue("hidden_claude_session_ids");
7
+ let hidden;
8
+ try {
9
+ const parsed = raw ? JSON.parse(raw) : [];
10
+ hidden = new Set(Array.isArray(parsed) ? parsed.filter((value) => typeof value === "string") : []);
11
+ }
12
+ catch {
13
+ hidden = new Set();
14
+ }
15
+ if (hidden.has(id))
16
+ return;
17
+ hidden.add(id);
18
+ storage.setConfigValue("hidden_claude_session_ids", JSON.stringify(Array.from(hidden)));
19
+ }
20
+ /**
21
+ * Coordinates session ownership without merging the two runner managers.
22
+ * Live structured state wins over PTY, and both win over a durable fallback.
23
+ */
24
+ export class SessionRegistry {
25
+ processes;
26
+ structured;
27
+ storage;
28
+ constructor(processes, structured, storage) {
29
+ this.processes = processes;
30
+ this.structured = structured;
31
+ this.storage = storage;
32
+ }
33
+ ownerOf(id) {
34
+ if (this.structured.get(id))
35
+ return "structured";
36
+ if (this.processes.getOwned(id))
37
+ return "pty";
38
+ return this.storage.getSession(id) ? "storage" : null;
39
+ }
40
+ get(id) {
41
+ return this.structured.get(id) ?? this.processes.getOwned(id) ?? this.storage.getSession(id);
42
+ }
43
+ getLatest(id) {
44
+ return this.get(id);
45
+ }
46
+ listSlim() {
47
+ const byId = new Map();
48
+ for (const snapshot of this.structured.listSlim())
49
+ byId.set(snapshot.id, snapshot);
50
+ for (const snapshot of this.processes.listSlim()) {
51
+ if (!byId.has(snapshot.id))
52
+ byId.set(snapshot.id, snapshot);
53
+ }
54
+ for (const snapshot of this.storage.loadSessions()) {
55
+ if (!byId.has(snapshot.id))
56
+ byId.set(snapshot.id, slimSnapshot(snapshot));
57
+ }
58
+ return Array.from(byId.values()).sort((a, b) => b.startedAt.localeCompare(a.startedAt));
59
+ }
60
+ setSessionModel(id, model) {
61
+ const owner = this.ownerOf(id);
62
+ if (owner === "structured")
63
+ return this.structured.setSessionModel(id, model);
64
+ if (owner === "pty")
65
+ return this.processes.setSessionModel(id, model);
66
+ return this.updateStored(id, (snapshot) => ({
67
+ ...snapshot,
68
+ selectedModel: model?.trim() || null,
69
+ structuredState: (snapshot.sessionKind ?? "pty") === "structured"
70
+ ? { ...snapshot.structuredState, model: model?.trim() || undefined }
71
+ : snapshot.structuredState,
72
+ }));
73
+ }
74
+ setSessionThinkingEffort(id, effort) {
75
+ const owner = this.ownerOf(id);
76
+ if (owner === "structured")
77
+ return this.structured.setSessionThinkingEffort(id, effort);
78
+ if (owner === "pty")
79
+ return this.processes.setSessionThinkingEffort(id, effort);
80
+ return this.updateStored(id, (snapshot) => ({ ...snapshot, thinkingEffort: effort }));
81
+ }
82
+ setSessionMode(id, mode) {
83
+ const owner = this.ownerOf(id);
84
+ if (owner === "structured")
85
+ return this.structured.setSessionMode(id, mode);
86
+ if (owner === "pty")
87
+ return this.processes.setSessionMode(id, mode);
88
+ return this.updateStored(id, (snapshot) => ({ ...snapshot, mode }));
89
+ }
90
+ setSessionTopic(id, title, description) {
91
+ const owner = this.ownerOf(id);
92
+ if (owner === "structured")
93
+ return this.structured.setSessionTopic(id, title, description);
94
+ if (owner === "pty")
95
+ return this.processes.setSessionTopic(id, title, description);
96
+ return this.updateStored(id, (snapshot) => ({ ...snapshot, title, description, summary: description }));
97
+ }
98
+ updateWorktreeState(id, status, info) {
99
+ const owner = this.ownerOf(id);
100
+ if (owner === "structured")
101
+ return this.structured.setWorktreeMergeState(id, status, info);
102
+ if (owner === "pty")
103
+ return this.processes.setWorktreeMergeState(id, status, info);
104
+ return this.updateStored(id, (snapshot) => ({
105
+ ...snapshot,
106
+ worktreeMergeStatus: status,
107
+ worktreeMergeInfo: info ?? null,
108
+ }));
109
+ }
110
+ delete(id) {
111
+ const snapshot = this.get(id);
112
+ if (!snapshot)
113
+ return null;
114
+ const owner = this.ownerOf(id);
115
+ if (owner === "structured")
116
+ this.structured.delete(id);
117
+ else if (owner === "pty")
118
+ this.processes.delete(id);
119
+ else
120
+ this.storage.deleteSession(id);
121
+ return snapshot;
122
+ }
123
+ deleteWithProviderHistory(id) {
124
+ const snapshot = this.delete(id);
125
+ const providerSessionId = snapshot?.claudeSessionId?.trim();
126
+ if (!snapshot || !providerSessionId)
127
+ return snapshot;
128
+ const provider = snapshot.provider
129
+ ?? snapshot.structuredState?.provider
130
+ ?? (/^codex\b/i.test(snapshot.command.trim())
131
+ ? "codex"
132
+ : /^opencode\b/i.test(snapshot.command.trim()) ? "opencode" : "claude");
133
+ if (provider === "claude") {
134
+ this.processes.deleteClaudeHistoryFiles([{ claudeSessionId: providerSessionId, cwd: snapshot.cwd }]);
135
+ }
136
+ else if (provider === "codex") {
137
+ this.processes.deleteCodexHistoryFiles([providerSessionId]);
138
+ }
139
+ else {
140
+ return snapshot;
141
+ }
142
+ addHiddenProviderSessionId(this.storage, providerSessionId);
143
+ return snapshot;
144
+ }
145
+ updateStored(id, update) {
146
+ const current = this.storage.getSession(id);
147
+ if (!current)
148
+ return null;
149
+ const next = update(current);
150
+ this.storage.updateSessionRuntimeMetadata(next);
151
+ return next;
152
+ }
153
+ }
@@ -0,0 +1,31 @@
1
+ import type { ConversationTurn, SessionSnapshot } from "./types.js";
2
+ export declare const SESSION_TRANSPORT_OUTPUT_LIMIT = 200000;
3
+ export type SessionBaseDTO = Omit<SessionSnapshot, "output" | "messages">;
4
+ export interface SessionListItemDTO extends SessionBaseDTO {
5
+ /** Kept for compatibility with clients that initialize terminal state from the list. */
6
+ output: "";
7
+ }
8
+ export interface SessionDetailDTO extends SessionBaseDTO {
9
+ output: string;
10
+ outputOffset: number;
11
+ outputTotal: number;
12
+ outputTruncated: boolean;
13
+ messages?: ConversationTurn[];
14
+ messageOffset?: number;
15
+ messageTotal?: number;
16
+ leadingBlockOffset?: number;
17
+ leadingBlockTotal?: number;
18
+ }
19
+ export declare function toSessionListItemDTO(snapshot: SessionSnapshot): SessionListItemDTO;
20
+ export interface SessionDetailDTOOptions {
21
+ output?: string;
22
+ messages?: ConversationTurn[];
23
+ messageOffset?: number;
24
+ messageTotal?: number;
25
+ leadingBlockOffset?: number;
26
+ leadingBlockTotal?: number;
27
+ outputLimit?: number;
28
+ }
29
+ export declare function toSessionDetailDTO(snapshot: SessionSnapshot, options?: SessionDetailDTOOptions): SessionDetailDTO;
30
+ /** Bound snapshot-like event payloads before they enter per-client WS queues. */
31
+ export declare function boundSessionEventData(data: unknown, outputLimit?: number): unknown;
@@ -0,0 +1,82 @@
1
+ export const SESSION_TRANSPORT_OUTPUT_LIMIT = 200_000;
2
+ /** Explicit allow-list separating the server's session object from its wire DTO. */
3
+ function sessionBase(snapshot) {
4
+ return {
5
+ id: snapshot.id,
6
+ sessionSource: snapshot.sessionSource,
7
+ automationId: snapshot.automationId,
8
+ sessionKind: snapshot.sessionKind,
9
+ provider: snapshot.provider,
10
+ runner: snapshot.runner,
11
+ command: snapshot.command,
12
+ cwd: snapshot.cwd,
13
+ mode: snapshot.mode,
14
+ worktreeEnabled: snapshot.worktreeEnabled,
15
+ worktree: snapshot.worktree,
16
+ worktreeMergeStatus: snapshot.worktreeMergeStatus,
17
+ worktreeMergeInfo: snapshot.worktreeMergeInfo,
18
+ autonomyPolicy: snapshot.autonomyPolicy,
19
+ approvalPolicy: snapshot.approvalPolicy,
20
+ allowedScopes: snapshot.allowedScopes,
21
+ status: snapshot.status,
22
+ exitCode: snapshot.exitCode,
23
+ startedAt: snapshot.startedAt,
24
+ endedAt: snapshot.endedAt,
25
+ archived: snapshot.archived,
26
+ archivedAt: snapshot.archivedAt,
27
+ permissionBlocked: snapshot.permissionBlocked,
28
+ pendingEscalation: snapshot.pendingEscalation,
29
+ lastEscalationResult: snapshot.lastEscalationResult,
30
+ claudeSessionId: snapshot.claudeSessionId,
31
+ queuedMessages: snapshot.queuedMessages,
32
+ structuredState: snapshot.structuredState,
33
+ resumedFromSessionId: snapshot.resumedFromSessionId,
34
+ autoRecovered: snapshot.autoRecovered,
35
+ autoApprovePermissions: snapshot.autoApprovePermissions,
36
+ approvalStats: snapshot.approvalStats,
37
+ summary: snapshot.summary,
38
+ title: snapshot.title,
39
+ description: snapshot.description,
40
+ currentTaskTitle: snapshot.currentTaskTitle,
41
+ selectedModel: snapshot.selectedModel,
42
+ thinkingEffort: snapshot.thinkingEffort,
43
+ ptyCols: snapshot.ptyCols,
44
+ ptyRows: snapshot.ptyRows,
45
+ };
46
+ }
47
+ export function toSessionListItemDTO(snapshot) {
48
+ return { ...sessionBase(snapshot), output: "" };
49
+ }
50
+ export function toSessionDetailDTO(snapshot, options = {}) {
51
+ const rawOutput = options.output ?? snapshot.output;
52
+ const outputLimit = Math.max(1, options.outputLimit ?? SESSION_TRANSPORT_OUTPUT_LIMIT);
53
+ const outputOffset = Math.max(0, rawOutput.length - outputLimit);
54
+ return {
55
+ ...sessionBase(snapshot),
56
+ output: outputOffset > 0 ? rawOutput.slice(outputOffset) : rawOutput,
57
+ outputOffset,
58
+ outputTotal: rawOutput.length,
59
+ outputTruncated: outputOffset > 0,
60
+ ...(options.messages !== undefined ? { messages: options.messages } : {}),
61
+ ...(options.messageOffset !== undefined ? { messageOffset: options.messageOffset } : {}),
62
+ ...(options.messageTotal !== undefined ? { messageTotal: options.messageTotal } : {}),
63
+ ...(options.leadingBlockOffset !== undefined ? { leadingBlockOffset: options.leadingBlockOffset } : {}),
64
+ ...(options.leadingBlockTotal !== undefined ? { leadingBlockTotal: options.leadingBlockTotal } : {}),
65
+ };
66
+ }
67
+ /** Bound snapshot-like event payloads before they enter per-client WS queues. */
68
+ export function boundSessionEventData(data, outputLimit = SESSION_TRANSPORT_OUTPUT_LIMIT) {
69
+ if (!data || typeof data !== "object" || Array.isArray(data))
70
+ return data;
71
+ const record = data;
72
+ if (typeof record.output !== "string" || record.output.length <= outputLimit)
73
+ return data;
74
+ const outputOffset = record.output.length - outputLimit;
75
+ return {
76
+ ...record,
77
+ output: record.output.slice(outputOffset),
78
+ outputOffset,
79
+ outputTotal: record.output.length,
80
+ outputTruncated: true,
81
+ };
82
+ }