@sumicom/quicksave 0.6.0 → 0.6.2

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 (122) hide show
  1. package/dist/ai/asyncQueue.d.ts +7 -6
  2. package/dist/ai/asyncQueue.d.ts.map +1 -1
  3. package/dist/ai/asyncQueue.js +31 -21
  4. package/dist/ai/asyncQueue.js.map +1 -1
  5. package/dist/ai/cardBuilder.d.ts +22 -2
  6. package/dist/ai/cardBuilder.d.ts.map +1 -1
  7. package/dist/ai/cardBuilder.js +275 -30
  8. package/dist/ai/cardBuilder.js.map +1 -1
  9. package/dist/ai/claudeCliProvider.d.ts +21 -0
  10. package/dist/ai/claudeCliProvider.d.ts.map +1 -0
  11. package/dist/ai/claudeCliProvider.js +527 -0
  12. package/dist/ai/claudeCliProvider.js.map +1 -0
  13. package/dist/ai/claudeCodeProvider.d.ts +18 -0
  14. package/dist/ai/claudeCodeProvider.d.ts.map +1 -0
  15. package/dist/ai/claudeCodeProvider.js +30 -0
  16. package/dist/ai/claudeCodeProvider.js.map +1 -0
  17. package/dist/ai/claudeSdkProvider.d.ts +20 -0
  18. package/dist/ai/claudeSdkProvider.d.ts.map +1 -0
  19. package/dist/ai/claudeSdkProvider.js +428 -0
  20. package/dist/ai/claudeSdkProvider.js.map +1 -0
  21. package/dist/ai/codexMcpProvider.d.ts +15 -0
  22. package/dist/ai/codexMcpProvider.d.ts.map +1 -0
  23. package/dist/ai/codexMcpProvider.js +442 -0
  24. package/dist/ai/codexMcpProvider.js.map +1 -0
  25. package/dist/ai/codexSdkProvider.d.ts +35 -0
  26. package/dist/ai/codexSdkProvider.d.ts.map +1 -0
  27. package/dist/ai/codexSdkProvider.js +422 -0
  28. package/dist/ai/codexSdkProvider.js.map +1 -0
  29. package/dist/ai/commitSummary.d.ts +4 -0
  30. package/dist/ai/commitSummary.d.ts.map +1 -1
  31. package/dist/ai/commitSummary.js +80 -40
  32. package/dist/ai/commitSummary.js.map +1 -1
  33. package/dist/ai/debugLogger.d.ts +24 -0
  34. package/dist/ai/debugLogger.d.ts.map +1 -0
  35. package/dist/ai/debugLogger.js +55 -0
  36. package/dist/ai/debugLogger.js.map +1 -0
  37. package/dist/ai/provider.d.ts +57 -0
  38. package/dist/ai/provider.d.ts.map +1 -0
  39. package/dist/ai/provider.js +2 -0
  40. package/dist/ai/provider.js.map +1 -0
  41. package/dist/ai/sandboxMcp.d.ts +12 -0
  42. package/dist/ai/sandboxMcp.d.ts.map +1 -0
  43. package/dist/ai/sandboxMcp.js +12 -0
  44. package/dist/ai/sandboxMcp.js.map +1 -0
  45. package/dist/ai/sandboxMcpStdio.d.ts +16 -0
  46. package/dist/ai/sandboxMcpStdio.d.ts.map +1 -0
  47. package/dist/ai/sandboxMcpStdio.js +127 -0
  48. package/dist/ai/sandboxMcpStdio.js.map +1 -0
  49. package/dist/ai/sessionManager.d.ts +105 -0
  50. package/dist/ai/sessionManager.d.ts.map +1 -0
  51. package/dist/ai/sessionManager.js +716 -0
  52. package/dist/ai/sessionManager.js.map +1 -0
  53. package/dist/ai/sessionRegistry.d.ts +28 -0
  54. package/dist/ai/sessionRegistry.d.ts.map +1 -0
  55. package/dist/ai/sessionRegistry.js +148 -0
  56. package/dist/ai/sessionRegistry.js.map +1 -0
  57. package/dist/config.d.ts +1 -0
  58. package/dist/config.d.ts.map +1 -1
  59. package/dist/config.js +12 -0
  60. package/dist/config.js.map +1 -1
  61. package/dist/connection/connection.d.ts +1 -0
  62. package/dist/connection/connection.d.ts.map +1 -1
  63. package/dist/connection/connection.js +19 -1
  64. package/dist/connection/connection.js.map +1 -1
  65. package/dist/git/operations.d.ts +26 -1
  66. package/dist/git/operations.d.ts.map +1 -1
  67. package/dist/git/operations.js +83 -2
  68. package/dist/git/operations.js.map +1 -1
  69. package/dist/handlers/messageHandler.d.ts +24 -3
  70. package/dist/handlers/messageHandler.d.ts.map +1 -1
  71. package/dist/handlers/messageHandler.js +378 -24
  72. package/dist/handlers/messageHandler.js.map +1 -1
  73. package/dist/index.js +23 -40
  74. package/dist/index.js.map +1 -1
  75. package/dist/service/debugHttpServer.d.ts +27 -0
  76. package/dist/service/debugHttpServer.d.ts.map +1 -0
  77. package/dist/service/debugHttpServer.js +189 -0
  78. package/dist/service/debugHttpServer.js.map +1 -0
  79. package/dist/service/ensureDaemon.d.ts.map +1 -1
  80. package/dist/service/ensureDaemon.js +15 -2
  81. package/dist/service/ensureDaemon.js.map +1 -1
  82. package/dist/service/ipcClient.js +1 -1
  83. package/dist/service/run.d.ts.map +1 -1
  84. package/dist/service/run.js +42 -15
  85. package/dist/service/run.js.map +1 -1
  86. package/dist/service/singleton.d.ts +4 -0
  87. package/dist/service/singleton.d.ts.map +1 -1
  88. package/dist/service/singleton.js +28 -2
  89. package/dist/service/singleton.js.map +1 -1
  90. package/package.json +7 -3
  91. package/dist/ai/asyncQueue.test.d.ts +0 -2
  92. package/dist/ai/asyncQueue.test.d.ts.map +0 -1
  93. package/dist/ai/asyncQueue.test.js +0 -56
  94. package/dist/ai/asyncQueue.test.js.map +0 -1
  95. package/dist/ai/claudeCodeService.d.ts +0 -122
  96. package/dist/ai/claudeCodeService.d.ts.map +0 -1
  97. package/dist/ai/claudeCodeService.js +0 -869
  98. package/dist/ai/claudeCodeService.js.map +0 -1
  99. package/dist/ai/sessionStore.test.d.ts +0 -2
  100. package/dist/ai/sessionStore.test.d.ts.map +0 -1
  101. package/dist/ai/sessionStore.test.js +0 -99
  102. package/dist/ai/sessionStore.test.js.map +0 -1
  103. package/dist/connection/pubsub.test.d.ts +0 -2
  104. package/dist/connection/pubsub.test.d.ts.map +0 -1
  105. package/dist/connection/pubsub.test.js +0 -96
  106. package/dist/connection/pubsub.test.js.map +0 -1
  107. package/dist/git/operations.test.d.ts +0 -2
  108. package/dist/git/operations.test.d.ts.map +0 -1
  109. package/dist/git/operations.test.js +0 -300
  110. package/dist/git/operations.test.js.map +0 -1
  111. package/dist/handlers/messageHandler.test.d.ts +0 -2
  112. package/dist/handlers/messageHandler.test.d.ts.map +0 -1
  113. package/dist/handlers/messageHandler.test.js +0 -361
  114. package/dist/handlers/messageHandler.test.js.map +0 -1
  115. package/dist/service/ipc.test.d.ts +0 -2
  116. package/dist/service/ipc.test.d.ts.map +0 -1
  117. package/dist/service/ipc.test.js +0 -140
  118. package/dist/service/ipc.test.js.map +0 -1
  119. package/dist/service/singleton.test.d.ts +0 -2
  120. package/dist/service/singleton.test.d.ts.map +0 -1
  121. package/dist/service/singleton.test.js +0 -39
  122. package/dist/service/singleton.test.js.map +0 -1
@@ -0,0 +1,716 @@
1
+ import { EventEmitter } from 'events';
2
+ import { resolve, join } from 'path';
3
+ import { readFile, writeFile, mkdir } from 'fs/promises';
4
+ import { DEFAULT_AGENT, DEFAULT_MODEL, matchAllowPattern, } from '@sumicom/quicksave-shared';
5
+ import { StreamCardBuilder, buildCardsFromHistory, loadPersistedCards } from './cardBuilder.js';
6
+ import { SANDBOX_BASH_TOOL, SET_TITLE_TOOL } from './sandboxMcp.js';
7
+ import { getSessionRegistry } from './sessionRegistry.js';
8
+ /**
9
+ * Events emitted:
10
+ * 'card-event' (event: CardEvent)
11
+ * 'card-stream-end' (result: CardStreamEnd)
12
+ * 'user-input-request' (request: ClaudeUserInputRequestPayload)
13
+ * 'user-input-resolved' ({ requestId, sessionId })
14
+ * 'session-updated' ({ sessionId, isActive, isStreaming, hasPendingInput, permissionMode })
15
+ * 'preferences-updated' (prefs: ClaudePreferences)
16
+ * 'session-config-updated' ({ sessionId, config: Record<string, ConfigValue> })
17
+ */
18
+ /** Tools auto-approved at each permission level (no user prompt). */
19
+ const AUTO_APPROVE = {
20
+ bypassPermissions: new Set([
21
+ 'Edit', 'Write', 'NotebookEdit', 'TodoWrite', 'Agent', 'EnterWorktree', 'ExitWorktree',
22
+ 'WebFetch', 'WebSearch', 'Bash',
23
+ 'Skill', 'ToolSearch', 'Config',
24
+ 'CronCreate', 'CronDelete', 'CronList', 'RemoteTrigger',
25
+ 'EnterPlanMode', 'ExitPlanMode',
26
+ 'TaskOutput', 'TaskStop',
27
+ ]),
28
+ acceptEdits: new Set(['Edit', 'Write', 'NotebookEdit', 'TodoWrite', 'Agent', 'EnterWorktree', 'ExitWorktree', 'EnterPlanMode']),
29
+ default: new Set(['TodoWrite', 'EnterWorktree', 'ExitWorktree', 'Agent', 'EnterPlanMode']),
30
+ plan: new Set(['EnterPlanMode']),
31
+ };
32
+ const DEFAULT_SYSTEM_PROMPT = [
33
+ 'For non-destructive shell commands (ls, cat, find, git log, git status, git diff, etc.), prefer SandboxBash over Bash. SandboxBash runs in a sandboxed environment. Use Bash only for commands that modify state.',
34
+ 'Call SetTitle to set a descriptive session title when you start a new task or switch context. Keep titles short (e.g. "Fixing auth middleware", "Adding unit tests for UserService"). Update it when the focus changes.',
35
+ ].join('\n\n');
36
+ function buildSystemPrompt(extra) {
37
+ return extra ? `${DEFAULT_SYSTEM_PROMPT}\n\n${extra}` : DEFAULT_SYSTEM_PROMPT;
38
+ }
39
+ function normalizeAgentId(value) {
40
+ if (value === 'claude-code' || value === 'claude-cli' || value === 'claude-sdk') {
41
+ return 'claude-code';
42
+ }
43
+ if (value === 'codex' || value === 'codex-mcp') {
44
+ return 'codex';
45
+ }
46
+ return undefined;
47
+ }
48
+ export class SessionManager extends EventEmitter {
49
+ sessions = new Map();
50
+ sessionAgents = new Map();
51
+ sessionPermissions = new Map();
52
+ sessionSandboxed = new Map();
53
+ sessionConfigs = new Map();
54
+ pendingInputRequests = new Map();
55
+ requestCounter = 0;
56
+ runtimeAllowPatterns = [];
57
+ preferences = { model: DEFAULT_MODEL };
58
+ providers;
59
+ defaultAgentId;
60
+ /** Guards against concurrent cold resumes. Queues prompts arriving while a spawn is in flight. */
61
+ coldResumeInFlight = new Map();
62
+ constructor(providers, defaultAgentId = DEFAULT_AGENT) {
63
+ super();
64
+ this.providers = new Map(providers.map((provider) => [provider.id, provider]));
65
+ this.defaultAgentId = this.providers.has(defaultAgentId)
66
+ ? defaultAgentId
67
+ : providers[0]?.id ?? DEFAULT_AGENT;
68
+ }
69
+ getProvider(agentId) {
70
+ const resolved = agentId && this.providers.has(agentId)
71
+ ? agentId
72
+ : this.defaultAgentId;
73
+ const provider = this.providers.get(resolved);
74
+ if (!provider) {
75
+ throw new Error(`Unknown agent: ${agentId ?? resolved}`);
76
+ }
77
+ return provider;
78
+ }
79
+ resolveAgentId(sessionId, cwd, requestedAgent) {
80
+ const activeAgent = this.sessions.get(sessionId)?.agentId;
81
+ if (activeAgent)
82
+ return activeAgent;
83
+ const rememberedAgent = this.sessionAgents.get(sessionId);
84
+ if (rememberedAgent)
85
+ return rememberedAgent;
86
+ const rawConfig = this.sessionConfigs.get(sessionId);
87
+ const configAgent = normalizeAgentId(rawConfig?.agent ?? rawConfig?.provider);
88
+ if (configAgent && this.providers.has(configAgent)) {
89
+ return configAgent;
90
+ }
91
+ if (cwd) {
92
+ const registryEntry = getSessionRegistry().getEntry(cwd, sessionId);
93
+ const registryAgent = normalizeAgentId(registryEntry?.agent
94
+ ?? (registryEntry?.provider));
95
+ if (registryAgent && this.providers.has(registryAgent)) {
96
+ return registryAgent;
97
+ }
98
+ }
99
+ const normalizedRequest = normalizeAgentId(requestedAgent);
100
+ if (normalizedRequest && this.providers.has(normalizedRequest)) {
101
+ return normalizedRequest;
102
+ }
103
+ return this.defaultAgentId;
104
+ }
105
+ // ── Preferences ──
106
+ async initPreferences() {
107
+ // Model is tracked from the last session's init event via onModelDetected callback.
108
+ }
109
+ getPreferences() {
110
+ return { ...this.preferences };
111
+ }
112
+ setPreferences(prefs) {
113
+ const next = { ...this.preferences };
114
+ let changed = false;
115
+ if (prefs.model !== undefined && prefs.model !== this.preferences.model) {
116
+ next.model = prefs.model;
117
+ changed = true;
118
+ }
119
+ if (prefs.reasoningEffort !== undefined && prefs.reasoningEffort !== this.preferences.reasoningEffort) {
120
+ next.reasoningEffort = prefs.reasoningEffort;
121
+ changed = true;
122
+ }
123
+ if (changed) {
124
+ this.preferences = next;
125
+ this.emit('preferences-updated', this.preferences);
126
+ }
127
+ return { ...this.preferences };
128
+ }
129
+ // ── Session Config ──
130
+ getSessionConfig(sessionId) {
131
+ return { ...this.sessionConfigs.get(sessionId) };
132
+ }
133
+ setSessionConfig(sessionId, key, value) {
134
+ const prev = this.sessionConfigs.get(sessionId) ?? {};
135
+ const next = { ...prev, [key]: value };
136
+ this.sessionConfigs.set(sessionId, next);
137
+ if (key === 'model' && typeof value === 'string') {
138
+ this.setPreferences({ model: value });
139
+ }
140
+ else if (key === 'reasoningEffort' && (typeof value === 'string' || value === null)) {
141
+ this.setPreferences({ reasoningEffort: value });
142
+ }
143
+ else if (key === 'permissionMode' && typeof value === 'string') {
144
+ this.setPermissionLevel(sessionId, value);
145
+ this.persistRegistryField(sessionId, 'permissionMode', value);
146
+ }
147
+ else if (key === 'sandboxed' && typeof value === 'boolean') {
148
+ this.sessionSandboxed.set(sessionId, value);
149
+ const ps = this.sessions.get(sessionId);
150
+ if (ps)
151
+ ps.sandboxed = value;
152
+ this.persistRegistryField(sessionId, 'sandboxed', value || undefined);
153
+ }
154
+ else if (key === 'agent' || key === 'provider') {
155
+ // Reject agent changes on active sessions — agent type is immutable once spawned
156
+ const ps = this.sessions.get(sessionId);
157
+ if (ps) {
158
+ this.emit('session-config-updated', { sessionId, config: next });
159
+ return next;
160
+ }
161
+ const agentId = normalizeAgentId(value);
162
+ if (!agentId || !this.providers.has(agentId)) {
163
+ this.emit('session-config-updated', { sessionId, config: next });
164
+ return next;
165
+ }
166
+ this.sessionAgents.set(sessionId, agentId);
167
+ }
168
+ this.emit('session-config-updated', { sessionId, config: next });
169
+ return next;
170
+ }
171
+ // ── Session Lifecycle ──
172
+ async startSession(opts) {
173
+ const validModes = ['default', 'acceptEdits', 'bypassPermissions', 'plan'];
174
+ const level = validModes.includes(opts.permissionMode)
175
+ ? opts.permissionMode : 'acceptEdits';
176
+ const sandboxed = !!opts.sandboxed;
177
+ const systemPrompt = buildSystemPrompt(opts.systemPrompt);
178
+ const provider = this.getProvider(opts.agent);
179
+ // Create cardBuilder with 'pending' sessionId — will be updated after provider returns real one
180
+ const cardBuilder = new StreamCardBuilder('pending', opts.streamId, opts.cwd);
181
+ const callbacks = this.makeCallbacks(provider.id);
182
+ const { sessionId, session: providerSession } = await provider.startSession({
183
+ prompt: opts.prompt,
184
+ cwd: opts.cwd,
185
+ streamId: opts.streamId,
186
+ model: opts.model,
187
+ permissionLevel: level,
188
+ sandboxed,
189
+ systemPrompt,
190
+ }, cardBuilder, callbacks);
191
+ // Update cardBuilder with real sessionId — new session has no prior JSONL
192
+ cardBuilder.updateSessionId(sessionId);
193
+ cardBuilder.jsonlCutoff = 0;
194
+ // Update callbacks to use real sessionId (re-bind the closures)
195
+ // The callbacks are already handed to the provider; for the permission handler
196
+ // we capture `sessionId` from the request parameter, so this is safe.
197
+ // Register session
198
+ this.sessionAgents.set(sessionId, provider.id);
199
+ this.sessionPermissions.set(sessionId, level);
200
+ if (sandboxed)
201
+ this.sessionSandboxed.set(sessionId, true);
202
+ const prevConfig = this.sessionConfigs.get(sessionId) ?? {};
203
+ this.sessionConfigs.set(sessionId, {
204
+ ...prevConfig,
205
+ agent: provider.id,
206
+ ...((provider.id !== 'codex' && opts.model)
207
+ ? { model: opts.model }
208
+ : (provider.id !== 'codex' && !prevConfig.model)
209
+ ? { model: DEFAULT_MODEL }
210
+ : {}),
211
+ permissionMode: level,
212
+ sandboxed,
213
+ });
214
+ const managed = {
215
+ sessionId,
216
+ agentId: provider.id,
217
+ providerSession,
218
+ cwd: opts.cwd,
219
+ streaming: true,
220
+ permissionLevel: level,
221
+ sandboxed,
222
+ cardBuilder,
223
+ };
224
+ this.sessions.set(sessionId, managed);
225
+ this.emitSessionUpdate(sessionId);
226
+ return sessionId;
227
+ }
228
+ async resumeSession(opts) {
229
+ const existing = this.sessions.get(opts.sessionId);
230
+ const agentId = this.resolveAgentId(opts.sessionId, opts.cwd, opts.agent);
231
+ // Hot resume: session is streaming and provider session is alive
232
+ if (existing?.streaming && existing.providerSession?.alive) {
233
+ console.log(`[session-manager] hot resume session=${opts.sessionId.slice(0, 8)} streaming=true`);
234
+ // Add user prompt to cardBuilder for getCards on reconnect
235
+ if (existing.cardBuilder) {
236
+ existing.cardBuilder.userMessage(opts.prompt);
237
+ }
238
+ // Queue the streamId so consumeStream picks it up after the current turn's result
239
+ const ps = existing.providerSession;
240
+ if (ps.pendingStreamIds) {
241
+ ps.pendingStreamIds.push(opts.streamId);
242
+ }
243
+ existing.providerSession.sendUserMessage(opts.prompt);
244
+ return opts.sessionId;
245
+ }
246
+ // Cold resume already in flight — queue the prompt instead of spawning again
247
+ const inFlight = this.coldResumeInFlight.get(opts.sessionId);
248
+ if (inFlight) {
249
+ console.log(`[session-manager] cold resume in flight, queuing prompt for session=${opts.sessionId.slice(0, 8)}`);
250
+ inFlight.queuedPrompts.push(opts.prompt);
251
+ return opts.sessionId;
252
+ }
253
+ // Cold resume: mark as in-flight, then delegate to provider
254
+ console.log(`[session-manager] cold resume session=${opts.sessionId.slice(0, 8)}`);
255
+ const flight = { queuedPrompts: [] };
256
+ this.coldResumeInFlight.set(opts.sessionId, flight);
257
+ try {
258
+ // Restore session settings from in-memory maps, falling back to persisted
259
+ // registry (survives daemon restarts).
260
+ const registryEntry = getSessionRegistry().getEntry(opts.cwd, opts.sessionId);
261
+ const validModes = ['default', 'acceptEdits', 'bypassPermissions', 'plan'];
262
+ const restoredLevel = this.sessionPermissions.get(opts.sessionId)
263
+ ?? (validModes.includes(registryEntry?.permissionMode) ? registryEntry.permissionMode : undefined);
264
+ const level = restoredLevel ?? 'acceptEdits';
265
+ const sandboxed = this.sessionSandboxed.get(opts.sessionId) ?? registryEntry?.sandboxed ?? false;
266
+ const provider = this.getProvider(agentId);
267
+ const cardBuilder = existing?.cardBuilder ?? new StreamCardBuilder(opts.sessionId, opts.streamId, opts.cwd);
268
+ await cardBuilder.snapshotCutoff();
269
+ const callbacks = this.makeCallbacks(provider.id);
270
+ const { sessionId, session: providerSession } = await provider.resumeSession({
271
+ sessionId: opts.sessionId,
272
+ prompt: opts.prompt,
273
+ cwd: opts.cwd,
274
+ streamId: opts.streamId,
275
+ permissionLevel: level,
276
+ sandboxed,
277
+ systemPrompt: buildSystemPrompt(),
278
+ }, cardBuilder, callbacks);
279
+ // Update or create session entry
280
+ this.sessionAgents.set(sessionId, provider.id);
281
+ if (existing) {
282
+ existing.agentId = provider.id;
283
+ existing.providerSession = providerSession;
284
+ existing.streaming = true;
285
+ }
286
+ else {
287
+ const managed = {
288
+ sessionId,
289
+ agentId: provider.id,
290
+ providerSession,
291
+ cwd: opts.cwd,
292
+ streaming: true,
293
+ permissionLevel: level,
294
+ sandboxed,
295
+ cardBuilder,
296
+ };
297
+ this.sessions.set(sessionId, managed);
298
+ }
299
+ this.emitSessionUpdate(sessionId);
300
+ // Drain queued prompts that arrived while spawning
301
+ for (const queued of flight.queuedPrompts) {
302
+ console.log(`[session-manager] draining queued prompt for session=${sessionId.slice(0, 8)}`);
303
+ providerSession.sendUserMessage(queued);
304
+ }
305
+ return sessionId;
306
+ }
307
+ finally {
308
+ this.coldResumeInFlight.delete(opts.sessionId);
309
+ }
310
+ }
311
+ async cancelSession(sessionId) {
312
+ const ps = this.sessions.get(sessionId);
313
+ if (!ps?.providerSession)
314
+ return false;
315
+ console.log(`[session-manager] cancel session=${sessionId.slice(0, 8)}`);
316
+ ps.providerSession.interrupt();
317
+ return true;
318
+ }
319
+ closeSession(sessionId) {
320
+ const ps = this.sessions.get(sessionId);
321
+ if (!ps)
322
+ return false;
323
+ if (ps.providerSession) {
324
+ ps.providerSession.kill();
325
+ ps.providerSession = null;
326
+ }
327
+ this.sessions.delete(sessionId);
328
+ this.emitSessionUpdate(sessionId);
329
+ return true;
330
+ }
331
+ // ── Permission ──
332
+ setPermissionLevel(sessionId, level) {
333
+ const ps = this.sessions.get(sessionId);
334
+ if (ps)
335
+ ps.permissionLevel = level;
336
+ this.sessionPermissions.set(sessionId, level);
337
+ this.emit('session-updated', {
338
+ sessionId,
339
+ isActive: !!ps,
340
+ isStreaming: ps?.streaming ?? false,
341
+ hasPendingInput: ps ? Array.from(this.pendingInputRequests.values()).some(p => p.request.sessionId === sessionId) : false,
342
+ permissionMode: level,
343
+ });
344
+ return true;
345
+ }
346
+ getPermissionLevel(sessionId) {
347
+ return this.sessions.get(sessionId)?.permissionLevel ?? 'acceptEdits';
348
+ }
349
+ resolveUserInput(response) {
350
+ const pending = this.pendingInputRequests.get(response.requestId);
351
+ if (!pending)
352
+ return false;
353
+ this.pendingInputRequests.delete(response.requestId);
354
+ pending.resolve(response);
355
+ // Clear pending input on the card
356
+ const ps = this.sessions.get(pending.request.sessionId);
357
+ if (ps?.cardBuilder) {
358
+ const cardEvt = ps.cardBuilder.clearPendingInput(response.requestId);
359
+ if (cardEvt)
360
+ this.emit('card-event', cardEvt);
361
+ }
362
+ // Persist allow pattern to settings.local.json if provided
363
+ if (response.allowPattern && ps?.cwd) {
364
+ this.persistAllowPattern(ps.cwd, response.allowPattern).catch((err) => {
365
+ console.error('[persistAllowPattern] failed:', err);
366
+ });
367
+ }
368
+ this.emit('user-input-resolved', { requestId: response.requestId, sessionId: pending.request.sessionId });
369
+ this.emitSessionUpdate(pending.request.sessionId);
370
+ return true;
371
+ }
372
+ // ── Query ──
373
+ getActiveSessions() {
374
+ const pendingSessionIds = new Set(Array.from(this.pendingInputRequests.values()).map(p => p.request.sessionId));
375
+ return Array.from(this.sessions.entries()).map(([id, ps]) => ({
376
+ sessionId: id,
377
+ cwd: ps.cwd,
378
+ agent: ps.agentId,
379
+ isStreaming: ps.streaming,
380
+ hasPendingInput: pendingSessionIds.has(id),
381
+ permissionMode: ps.permissionLevel,
382
+ sandboxed: ps.sandboxed || undefined,
383
+ }));
384
+ }
385
+ isStreaming(sessionId) {
386
+ return this.sessions.get(sessionId)?.streaming ?? false;
387
+ }
388
+ isOpen(sessionId) {
389
+ return this.sessions.has(sessionId);
390
+ }
391
+ getSessionCwd(sessionId) {
392
+ return this.sessions.get(sessionId)?.cwd;
393
+ }
394
+ getSessionAgent(sessionId, cwd) {
395
+ return this.resolveAgentId(sessionId, cwd);
396
+ }
397
+ getActiveSessionCount() {
398
+ return this.sessions.size;
399
+ }
400
+ getCardBuilder(sessionId) {
401
+ return this.sessions.get(sessionId)?.cardBuilder ?? null;
402
+ }
403
+ async listAvailableSessions(cwd) {
404
+ const registry = getSessionRegistry();
405
+ const registryEntries = registry.getEntriesForProject(cwd);
406
+ const pendingSessionIds = new Set(Array.from(this.pendingInputRequests.values()).map(p => p.request.sessionId));
407
+ return registryEntries.map(entry => ({
408
+ sessionId: entry.sessionId,
409
+ summary: entry.title ?? entry.firstPrompt ?? entry.sessionId.slice(0, 8),
410
+ lastModified: entry.lastAccessedAt,
411
+ createdAt: entry.createdAt,
412
+ cwd: entry.cwd,
413
+ agent: this.sessions.get(entry.sessionId)?.agentId
414
+ ?? this.sessionAgents.get(entry.sessionId)
415
+ ?? entry.agent
416
+ ?? (entry.provider === 'codex-mcp' ? 'codex' : entry.provider ? 'claude-code' : undefined),
417
+ gitBranch: entry.gitBranch,
418
+ messageCount: entry.messageCount,
419
+ isActive: this.sessions.has(entry.sessionId),
420
+ isStreaming: this.sessions.get(entry.sessionId)?.streaming ?? false,
421
+ hasPendingInput: pendingSessionIds.has(entry.sessionId),
422
+ permissionMode: this.sessions.get(entry.sessionId)?.permissionLevel
423
+ ?? this.sessionPermissions.get(entry.sessionId),
424
+ }));
425
+ }
426
+ async getCards(sessionId, cwd, offset = 0, limit = 50) {
427
+ const ps = this.sessions.get(sessionId);
428
+ const provider = this.getProvider(this.resolveAgentId(sessionId, cwd));
429
+ const cutoff = ps?.cardBuilder?.jsonlCutoff ?? undefined;
430
+ let result;
431
+ if (provider.historyMode === 'claude-jsonl') {
432
+ // Read JSONL history up to the cutoff (excludes the active turn's messages).
433
+ result = await buildCardsFromHistory(sessionId, cwd, offset, limit, cutoff);
434
+ }
435
+ else {
436
+ // Memory-mode: use in-memory cards from active turn, falling back to persisted history
437
+ const streamCards = ps?.cardBuilder?.getCards() ?? [];
438
+ const persisted = await loadPersistedCards(sessionId);
439
+ const cards = [...persisted, ...streamCards];
440
+ const total = cards.length;
441
+ const start = Math.max(0, total - offset - limit);
442
+ const end = Math.max(0, total - offset);
443
+ result = {
444
+ cards: cards.slice(start, end),
445
+ total,
446
+ hasMore: start > 0,
447
+ };
448
+ }
449
+ // Append in-memory cards for the active turn (initial load only — pagination
450
+ // already has these cards in the PWA's array, so appending them again would duplicate).
451
+ if (offset === 0 && provider.historyMode === 'claude-jsonl' && ps?.cardBuilder) {
452
+ const streamingCards = ps.cardBuilder.getCards();
453
+ if (streamingCards.length > 0) {
454
+ result.cards.push(...streamingCards);
455
+ result.total += streamingCards.length;
456
+ }
457
+ }
458
+ // Overlay pending inputs from pendingInputRequests onto matching cards.
459
+ const pendingByToolUseId = new Map();
460
+ for (const [, pending] of this.pendingInputRequests) {
461
+ if (pending.request.sessionId === sessionId && pending.request.toolUseId) {
462
+ pendingByToolUseId.set(pending.request.toolUseId, {
463
+ sessionId: pending.request.sessionId,
464
+ requestId: pending.request.requestId,
465
+ inputType: pending.request.inputType,
466
+ title: pending.request.title,
467
+ message: pending.request.message,
468
+ options: pending.request.options,
469
+ });
470
+ }
471
+ }
472
+ if (pendingByToolUseId.size > 0) {
473
+ for (const card of result.cards) {
474
+ if (card.type === 'tool_call' && card.toolUseId) {
475
+ const pending = pendingByToolUseId.get(card.toolUseId);
476
+ if (pending && !card.pendingInput) {
477
+ card.pendingInput = pending;
478
+ }
479
+ }
480
+ }
481
+ }
482
+ // Attach session title from config or registry
483
+ const configTitle = this.sessionConfigs.get(sessionId)?.title;
484
+ if (configTitle) {
485
+ result.title = configTitle;
486
+ }
487
+ else {
488
+ const registry = getSessionRegistry();
489
+ const entry = registry.getEntry(cwd, sessionId);
490
+ if (entry?.title)
491
+ result.title = entry.title;
492
+ }
493
+ return result;
494
+ }
495
+ getPendingInputRequests() {
496
+ return Array.from(this.pendingInputRequests.values()).map(p => p.request);
497
+ }
498
+ getDebugState() {
499
+ const pendingInputs = Array.from(this.pendingInputRequests.values()).map(p => ({
500
+ requestId: p.request.requestId,
501
+ sessionId: p.request.sessionId,
502
+ toolName: p.request.toolName,
503
+ agentId: p.request.agentId,
504
+ inputType: p.request.inputType,
505
+ }));
506
+ return { pendingInputs, activeSessions: this.getActiveSessions() };
507
+ }
508
+ cleanup() {
509
+ for (const [requestId, pending] of this.pendingInputRequests) {
510
+ pending.resolve({ sessionId: '', requestId, action: 'allow' });
511
+ }
512
+ this.pendingInputRequests.clear();
513
+ for (const [, ps] of this.sessions) {
514
+ if (ps.providerSession) {
515
+ try {
516
+ ps.providerSession.kill();
517
+ }
518
+ catch { }
519
+ ps.providerSession = null;
520
+ }
521
+ }
522
+ this.sessions.clear();
523
+ }
524
+ // ── Private: Callbacks Factory ──
525
+ makeCallbacks(agentId) {
526
+ return {
527
+ emitCardEvent: (event) => {
528
+ this.emit('card-event', event);
529
+ },
530
+ emitStreamEnd: (result) => {
531
+ this.emit('card-stream-end', result);
532
+ // Update session streaming state
533
+ const ps = this.sessions.get(result.sessionId);
534
+ if (ps) {
535
+ ps.streaming = false;
536
+ this.emitSessionUpdate(result.sessionId);
537
+ }
538
+ },
539
+ handlePermissionRequest: async (sessionId, req) => {
540
+ return this.handlePermissionRequest(sessionId, req);
541
+ },
542
+ onModelDetected: (model) => {
543
+ if (agentId === 'claude-code') {
544
+ this.preferences.model = model;
545
+ }
546
+ },
547
+ };
548
+ }
549
+ // ── Private: Permission Handling ──
550
+ async handlePermissionRequest(sessionId, req) {
551
+ const { toolName, toolInput, toolUseId } = req;
552
+ // Check auto-approve rules first
553
+ if (this.shouldAutoApprove(sessionId, toolName, toolInput)) {
554
+ return { action: 'allow' };
555
+ }
556
+ // Check runtime allow patterns
557
+ if (this.runtimeAllowPatterns.some(p => matchAllowPattern(p, toolName, toolInput))) {
558
+ return { action: 'allow' };
559
+ }
560
+ // Forward to PWA for user decision
561
+ const requestId = `perm-${++this.requestCounter}`;
562
+ const isQuestion = toolName === 'AskUserQuestion';
563
+ const questions = isQuestion ? toolInput.questions : undefined;
564
+ const request = {
565
+ sessionId,
566
+ requestId,
567
+ inputType: isQuestion ? 'question' : 'permission',
568
+ title: isQuestion
569
+ ? questions?.[0]?.question ?? 'Question from Claude'
570
+ : `Allow ${toolName}?`,
571
+ message: isQuestion ? undefined : JSON.stringify(toolInput).slice(0, 500),
572
+ toolName,
573
+ toolInput,
574
+ toolUseId,
575
+ ...(isQuestion && questions ? {
576
+ options: questions.flatMap((q) => (q.options ?? []).map((opt) => ({
577
+ key: opt.label,
578
+ label: opt.label,
579
+ description: opt.description,
580
+ }))),
581
+ } : {}),
582
+ };
583
+ // Create card with pending input
584
+ const ps = this.sessions.get(sessionId);
585
+ const cb = ps?.cardBuilder;
586
+ if (cb) {
587
+ const pendingAttachment = {
588
+ sessionId,
589
+ requestId,
590
+ inputType: request.inputType,
591
+ title: request.title,
592
+ message: request.message,
593
+ options: request.options,
594
+ };
595
+ const cardEvt = cb.toolCallFromPermission(toolName, toolInput, toolUseId, pendingAttachment);
596
+ this.emit('card-event', cardEvt);
597
+ }
598
+ this.emit('user-input-request', request);
599
+ // Wait for user response
600
+ const response = await this.waitForUserInput(requestId, request);
601
+ if (response.action === 'deny') {
602
+ return { action: 'deny', response: response.response || 'User denied permission' };
603
+ }
604
+ // For AskUserQuestion, inject answer
605
+ if (isQuestion && response.response) {
606
+ const answers = {};
607
+ if (questions?.[0]?.question) {
608
+ answers[questions[0].question] = response.response;
609
+ }
610
+ return { action: 'allow', updatedInput: { ...toolInput, answers } };
611
+ }
612
+ return { action: 'allow' };
613
+ }
614
+ shouldAutoApprove(sessionId, toolName, input) {
615
+ // SetTitle — always approve
616
+ if (toolName === SET_TITLE_TOOL) {
617
+ if (input.title)
618
+ this.updateSessionTitle(sessionId, input.title);
619
+ return true;
620
+ }
621
+ // SandboxBash: auto-approve when sandboxed, otherwise check as Bash
622
+ if (toolName === SANDBOX_BASH_TOOL) {
623
+ if (this.sessions.get(sessionId)?.sandboxed)
624
+ return true;
625
+ toolName = 'Bash'; // fall through to permission check as Bash
626
+ }
627
+ // Check permission level auto-approve set
628
+ const level = this.sessions.get(sessionId)?.permissionLevel
629
+ ?? this.sessionPermissions.get(sessionId) ?? 'acceptEdits';
630
+ if (!AUTO_APPROVE[level].has(toolName))
631
+ return false;
632
+ // For file-writing tools, restrict to project cwd
633
+ const FILE_WRITE_TOOLS = new Set(['Write', 'Edit', 'NotebookEdit']);
634
+ if (FILE_WRITE_TOOLS.has(toolName)) {
635
+ const cwd = this.sessions.get(sessionId)?.cwd;
636
+ const filePath = (input.file_path ?? input.path);
637
+ if (filePath && cwd) {
638
+ const resolvedFile = resolve(filePath);
639
+ const resolvedCwd = resolve(cwd);
640
+ return resolvedFile === resolvedCwd || resolvedFile.startsWith(resolvedCwd + '/');
641
+ }
642
+ }
643
+ return true;
644
+ }
645
+ /** Update session title (triggered by SetTitle MCP tool). */
646
+ updateSessionTitle(sessionId, title) {
647
+ const config = this.sessionConfigs.get(sessionId) ?? {};
648
+ this.sessionConfigs.set(sessionId, { ...config, title });
649
+ this.emit('session-config-updated', { sessionId, config: this.sessionConfigs.get(sessionId) });
650
+ // Update session registry so it persists across restarts
651
+ const ps = this.sessions.get(sessionId);
652
+ if (ps?.cwd) {
653
+ const registry = getSessionRegistry();
654
+ const entry = registry.getEntry(ps.cwd, sessionId);
655
+ if (entry) {
656
+ registry.upsertEntry({ ...entry, title, lastAccessedAt: Date.now() });
657
+ }
658
+ }
659
+ }
660
+ emitSessionUpdate(sessionId) {
661
+ const ps = this.sessions.get(sessionId);
662
+ const hasPendingInput = Array.from(this.pendingInputRequests.values())
663
+ .some(p => p.request.sessionId === sessionId);
664
+ this.emit('session-updated', {
665
+ sessionId,
666
+ isActive: !!ps,
667
+ agent: ps?.agentId ?? this.sessionAgents.get(sessionId),
668
+ isStreaming: ps?.streaming ?? false,
669
+ hasPendingInput,
670
+ permissionMode: ps?.permissionLevel ?? this.sessionPermissions.get(sessionId),
671
+ sandboxed: ps?.sandboxed ?? this.sessionSandboxed.get(sessionId) ?? false,
672
+ });
673
+ }
674
+ persistRegistryField(sessionId, key, value) {
675
+ const registry = getSessionRegistry();
676
+ // Active session — cwd is in memory
677
+ const cwd = this.sessions.get(sessionId)?.cwd;
678
+ if (cwd) {
679
+ registry.updateEntry(cwd, sessionId, { [key]: value });
680
+ return;
681
+ }
682
+ // Inactive session — scan registry for the entry
683
+ for (const entry of registry.getEntriesForProject()) {
684
+ if (entry.sessionId === sessionId) {
685
+ registry.updateEntry(entry.cwd, sessionId, { [key]: value });
686
+ return;
687
+ }
688
+ }
689
+ }
690
+ async persistAllowPattern(cwd, pattern) {
691
+ if (!this.runtimeAllowPatterns.includes(pattern)) {
692
+ this.runtimeAllowPatterns.push(pattern);
693
+ }
694
+ const settingsPath = join(cwd, '.claude', 'settings.local.json');
695
+ let settings = {};
696
+ try {
697
+ settings = JSON.parse(await readFile(settingsPath, 'utf-8'));
698
+ }
699
+ catch { /* file doesn't exist yet */ }
700
+ const permissions = settings.permissions ?? {};
701
+ const allow = Array.isArray(permissions.allow) ? [...permissions.allow] : [];
702
+ if (!allow.includes(pattern)) {
703
+ allow.push(pattern);
704
+ settings.permissions = { ...permissions, allow };
705
+ await mkdir(join(cwd, '.claude'), { recursive: true });
706
+ await writeFile(settingsPath, JSON.stringify(settings, null, 2) + '\n');
707
+ console.log(`[persistAllowPattern] saved ${pattern} to ${settingsPath}`);
708
+ }
709
+ }
710
+ waitForUserInput(requestId, request) {
711
+ return new Promise((resolve) => {
712
+ this.pendingInputRequests.set(requestId, { resolve, request });
713
+ });
714
+ }
715
+ }
716
+ //# sourceMappingURL=sessionManager.js.map