@adhdev/daemon-core 0.7.45 → 0.8.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 (54) hide show
  1. package/dist/cli-adapters/provider-cli-adapter.d.ts +34 -0
  2. package/dist/cli-adapters/pty-transport.d.ts +1 -0
  3. package/dist/cli-adapters/session-host-transport.d.ts +1 -0
  4. package/dist/commands/cli-manager.d.ts +11 -2
  5. package/dist/config/chat-history.d.ts +32 -2
  6. package/dist/config/config.d.ts +5 -1
  7. package/dist/config/recent-activity.d.ts +3 -1
  8. package/dist/config/saved-sessions.d.ts +22 -0
  9. package/dist/daemon/dev-auto-implement.d.ts +18 -2
  10. package/dist/daemon/dev-cli-debug.d.ts +82 -0
  11. package/dist/daemon/dev-server.d.ts +7 -0
  12. package/dist/index.d.ts +2 -0
  13. package/dist/index.js +6122 -4038
  14. package/dist/index.js.map +1 -1
  15. package/dist/index.mjs +6114 -4032
  16. package/dist/index.mjs.map +1 -1
  17. package/dist/providers/cli-provider-instance.d.ts +29 -1
  18. package/dist/providers/contracts.d.ts +11 -0
  19. package/dist/providers/provider-instance.d.ts +1 -0
  20. package/dist/shared-types.d.ts +2 -0
  21. package/node_modules/@adhdev/session-host-core/dist/index.d.mts +12 -1
  22. package/node_modules/@adhdev/session-host-core/dist/index.d.ts +12 -1
  23. package/node_modules/@adhdev/session-host-core/dist/index.js +9 -0
  24. package/node_modules/@adhdev/session-host-core/dist/index.js.map +1 -1
  25. package/node_modules/@adhdev/session-host-core/dist/index.mjs +9 -0
  26. package/node_modules/@adhdev/session-host-core/dist/index.mjs.map +1 -1
  27. package/node_modules/@adhdev/session-host-core/package.json +1 -1
  28. package/package.json +1 -1
  29. package/src/boot/daemon-lifecycle.ts +19 -15
  30. package/src/cli-adapters/provider-cli-adapter.ts +424 -7
  31. package/src/cli-adapters/pty-transport.ts +1 -0
  32. package/src/cli-adapters/session-host-transport.ts +32 -1
  33. package/src/commands/chat-commands.ts +36 -8
  34. package/src/commands/cli-manager.ts +259 -22
  35. package/src/commands/router.ts +52 -1
  36. package/src/config/chat-history.ts +197 -10
  37. package/src/config/config.d.ts +4 -0
  38. package/src/config/config.ts +8 -2
  39. package/src/config/recent-activity.ts +13 -2
  40. package/src/config/saved-sessions.ts +73 -0
  41. package/src/daemon/dev-auto-implement.ts +394 -43
  42. package/src/daemon/dev-cli-debug.ts +839 -0
  43. package/src/daemon/dev-server.ts +51 -5
  44. package/src/index.ts +2 -0
  45. package/src/providers/cli-provider-instance.ts +283 -4
  46. package/src/providers/contracts.ts +11 -0
  47. package/src/providers/provider-instance.d.ts +1 -0
  48. package/src/providers/provider-instance.ts +1 -0
  49. package/src/providers/provider-loader.ts +39 -0
  50. package/src/session-host/runtime-support.ts +1 -0
  51. package/src/shared-types.d.ts +2 -0
  52. package/src/shared-types.ts +2 -0
  53. package/src/status/builders.ts +1 -0
  54. package/src/status/snapshot.ts +1 -0
@@ -13,12 +13,15 @@ import { DaemonCdpManager } from '../cdp/manager.js';
13
13
  import { registerExtensionProviders } from '../cdp/setup.js';
14
14
  import { DaemonCommandHandler } from './handler.js';
15
15
  import { DaemonCliManager } from './cli-manager.js';
16
+ import { supportsExplicitSessionResume } from './cli-manager.js';
16
17
  import type { ProviderLoader } from '../providers/provider-loader.js';
17
18
  import type { ProviderInstanceManager } from '../providers/provider-instance-manager.js';
18
19
  import { launchWithCdp, killIdeProcess, isIdeRunning } from '../launch.js';
19
20
  import { loadConfig, saveConfig, updateConfig } from '../config/config.js';
20
21
  import { resolveIdeLaunchWorkspace } from '../config/workspaces.js';
21
- import { appendRecentActivity, markSessionSeen } from '../config/recent-activity.js';
22
+ import { appendRecentActivity, getRecentActivity, markSessionSeen } from '../config/recent-activity.js';
23
+ import { getSavedProviderSessions } from '../config/saved-sessions.js';
24
+ import { listSavedHistorySessions } from '../config/chat-history.js';
22
25
  import { detectIDEs } from '../detection/ide-detector.js';
23
26
  import { SessionRegistry } from '../sessions/registry.js';
24
27
  import { LOG } from '../logging/logger.js';
@@ -155,6 +158,54 @@ export class DaemonCommandRouter {
155
158
  }
156
159
  }
157
160
 
161
+ case 'list_saved_sessions': {
162
+ const providerType = typeof args?.providerType === 'string'
163
+ ? args.providerType.trim()
164
+ : typeof args?.agentType === 'string'
165
+ ? args.agentType.trim()
166
+ : '';
167
+ const kind = args?.kind === 'acp' ? 'acp' : 'cli';
168
+ if (!providerType) {
169
+ return { success: false, error: 'providerType required' };
170
+ }
171
+
172
+ const offset = Math.max(0, Number(args?.offset) || 0);
173
+ const limit = Math.max(1, Math.min(100, Number(args?.limit) || 30));
174
+ const { sessions: historySessions, hasMore } = listSavedHistorySessions(providerType, { offset, limit });
175
+ const config = loadConfig();
176
+ const savedSessions = getSavedProviderSessions(config, { providerType, kind });
177
+ const recentSessions = getRecentActivity(config, 200)
178
+ .filter(entry => entry.providerType === providerType && entry.kind === kind && entry.providerSessionId);
179
+ const savedSessionById = new Map(savedSessions.map(entry => [entry.providerSessionId, entry]));
180
+ const recentSessionById = new Map(recentSessions.map(entry => [entry.providerSessionId!, entry]));
181
+ const providerMeta = this.deps.providerLoader.getMeta(providerType);
182
+ const canResumeById = supportsExplicitSessionResume(providerMeta?.resume);
183
+
184
+ return {
185
+ success: true,
186
+ sessions: historySessions.map(session => {
187
+ const saved = savedSessionById.get(session.historySessionId);
188
+ const recent = recentSessionById.get(session.historySessionId);
189
+ return {
190
+ id: session.historySessionId,
191
+ providerSessionId: session.historySessionId,
192
+ providerType,
193
+ providerName: saved?.providerName || recent?.providerName || providerType,
194
+ kind: saved?.kind || recent?.kind || kind,
195
+ title: saved?.title || recent?.title || session.sessionTitle || session.preview || providerType,
196
+ workspace: saved?.workspace || recent?.workspace,
197
+ currentModel: saved?.currentModel || recent?.currentModel,
198
+ preview: session.preview,
199
+ messageCount: session.messageCount,
200
+ firstMessageAt: session.firstMessageAt,
201
+ lastMessageAt: session.lastMessageAt,
202
+ canResume: !!(saved?.workspace || recent?.workspace) && canResumeById,
203
+ };
204
+ }),
205
+ hasMore,
206
+ };
207
+ }
208
+
158
209
  // ─── restart_session: IDE / CLI / ACP unified ───
159
210
  case 'restart_session': {
160
211
  const targetType = args?.cliType || args?.agentType || args?.ideType;
@@ -21,11 +21,23 @@ interface HistoryMessage {
21
21
  receivedAt: number; // epoch ms
22
22
  role: 'user' | 'assistant' | 'system';
23
23
  content: string;
24
+ kind?: string;
25
+ senderName?: string;
24
26
  agent: string; // e.g. 'antigravity', 'cursor', 'gemini-cli'
25
27
  instanceId?: string; // IDE instance UUID (distinguishes windows of the same agent type)
28
+ historySessionId?: string; // Persistent provider-side conversation/session key
26
29
  sessionTitle?: string;
27
30
  }
28
31
 
32
+ export interface SavedHistorySessionSummary {
33
+ historySessionId: string;
34
+ sessionTitle?: string;
35
+ messageCount: number;
36
+ firstMessageAt: number;
37
+ lastMessageAt: number;
38
+ preview?: string;
39
+ }
40
+
29
41
  export class ChatHistoryWriter {
30
42
  /** Last seen message count per agent (deduplication) */
31
43
  private lastSeenCounts = new Map<string, number>();
@@ -43,15 +55,17 @@ export class ChatHistoryWriter {
43
55
  */
44
56
  appendNewMessages(
45
57
  agentType: string,
46
- messages: Array<{ role: string; content: string; receivedAt?: number }>,
58
+ messages: Array<{ role: string; content: string; receivedAt?: number; kind?: string; senderName?: string; historyDedupKey?: string }>,
47
59
  sessionTitle?: string,
48
60
  instanceId?: string,
61
+ historySessionId?: string,
49
62
  ): void {
50
63
  if (!messages || messages.length === 0) return;
51
64
 
52
65
  try {
53
- // dedup key: agentType + instanceId
54
- const dedupKey = instanceId ? `${agentType}:${instanceId}` : agentType;
66
+ // dedup key: agentType + persistent history key (fallback: runtime instanceId)
67
+ const effectiveHistoryKey = historySessionId || instanceId;
68
+ const dedupKey = effectiveHistoryKey ? `${agentType}:${effectiveHistoryKey}` : agentType;
55
69
  let seenHashes = this.lastSeenHashes.get(dedupKey);
56
70
  if (!seenHashes) {
57
71
  seenHashes = new Set<string>();
@@ -61,7 +75,7 @@ export class ChatHistoryWriter {
61
75
  // Filter new messages
62
76
  const newMessages: HistoryMessage[] = [];
63
77
  for (const msg of messages) {
64
- const hash = `${msg.role}:${(msg.content || '').slice(0, 50)}`;
78
+ const hash = msg.historyDedupKey || `${msg.kind || 'standard'}:${msg.role}:${(msg.content || '').slice(0, 50)}`;
65
79
  if (seenHashes.has(hash)) continue;
66
80
  seenHashes.add(hash);
67
81
  newMessages.push({
@@ -69,20 +83,23 @@ export class ChatHistoryWriter {
69
83
  receivedAt: msg.receivedAt || Date.now(),
70
84
  role: msg.role as 'user' | 'assistant' | 'system',
71
85
  content: msg.content || '',
86
+ kind: typeof msg.kind === 'string' ? msg.kind : undefined,
87
+ senderName: typeof msg.senderName === 'string' ? msg.senderName : undefined,
72
88
  agent: agentType,
73
89
  instanceId,
90
+ historySessionId: effectiveHistoryKey,
74
91
  sessionTitle,
75
92
  });
76
93
  }
77
94
 
78
95
  if (newMessages.length === 0) return;
79
96
 
80
- // Append to file — separate file if instanceId exists
97
+ // Append to file — keyed by persistent history session when available
81
98
  const dir = path.join(HISTORY_DIR, this.sanitize(agentType));
82
99
  fs.mkdirSync(dir, { recursive: true });
83
100
 
84
101
  const date = new Date().toISOString().slice(0, 10); // YYYY-MM-DD
85
- const filePrefix = instanceId ? `${this.sanitize(instanceId)}_` : '';
102
+ const filePrefix = effectiveHistoryKey ? `${this.sanitize(effectiveHistoryKey)}_` : '';
86
103
  const filePath = path.join(dir, `${filePrefix}${date}.jsonl`);
87
104
  const lines = newMessages.map(m => JSON.stringify(m)).join('\n') + '\n';
88
105
  fs.appendFileSync(filePath, lines, 'utf-8');
@@ -92,7 +109,7 @@ export class ChatHistoryWriter {
92
109
  if (messages.length < prevCount * 0.5 && prevCount > 3) {
93
110
  seenHashes.clear();
94
111
  for (const msg of messages) {
95
- seenHashes.add(`${msg.role}:${(msg.content || '').slice(0, 50)}`);
112
+ seenHashes.add(msg.historyDedupKey || `${msg.kind || 'standard'}:${msg.role}:${(msg.content || '').slice(0, 50)}`);
96
113
  }
97
114
  }
98
115
  this.lastSeenCounts.set(dedupKey, messages.length);
@@ -107,6 +124,103 @@ export class ChatHistoryWriter {
107
124
  }
108
125
  }
109
126
 
127
+ appendSystemMarker(
128
+ agentType: string,
129
+ content: string,
130
+ options: {
131
+ sessionTitle?: string;
132
+ instanceId?: string;
133
+ historySessionId?: string;
134
+ dedupKey?: string;
135
+ receivedAt?: number;
136
+ senderName?: string;
137
+ } = {},
138
+ ): void {
139
+ this.appendNewMessages(
140
+ agentType,
141
+ [{
142
+ role: 'system',
143
+ kind: 'system',
144
+ content,
145
+ receivedAt: options.receivedAt,
146
+ senderName: options.senderName,
147
+ historyDedupKey: options.dedupKey,
148
+ }],
149
+ options.sessionTitle,
150
+ options.instanceId,
151
+ options.historySessionId,
152
+ );
153
+ }
154
+
155
+ promoteHistorySession(
156
+ agentType: string,
157
+ previousHistorySessionId: string,
158
+ nextHistorySessionId: string,
159
+ ): void {
160
+ const fromId = String(previousHistorySessionId || '').trim();
161
+ const toId = String(nextHistorySessionId || '').trim();
162
+ if (!fromId || !toId || fromId === toId) return;
163
+
164
+ try {
165
+ const fromDedupKey = `${agentType}:${fromId}`;
166
+ const toDedupKey = `${agentType}:${toId}`;
167
+ const fromHashes = this.lastSeenHashes.get(fromDedupKey);
168
+ if (fromHashes?.size) {
169
+ const nextHashes = this.lastSeenHashes.get(toDedupKey) || new Set<string>();
170
+ for (const hash of fromHashes) nextHashes.add(hash);
171
+ this.lastSeenHashes.set(toDedupKey, nextHashes);
172
+ this.lastSeenHashes.delete(fromDedupKey);
173
+ }
174
+ const fromCount = this.lastSeenCounts.get(fromDedupKey);
175
+ if (typeof fromCount === 'number') {
176
+ this.lastSeenCounts.set(toDedupKey, Math.max(fromCount, this.lastSeenCounts.get(toDedupKey) || 0));
177
+ this.lastSeenCounts.delete(fromDedupKey);
178
+ }
179
+
180
+ const dir = path.join(HISTORY_DIR, this.sanitize(agentType));
181
+ if (!fs.existsSync(dir)) return;
182
+
183
+ const fromPrefix = `${this.sanitize(fromId)}_`;
184
+ const toPrefix = `${this.sanitize(toId)}_`;
185
+ const files = fs.readdirSync(dir).filter((file) => file.startsWith(fromPrefix) && file.endsWith('.jsonl'));
186
+
187
+ for (const file of files) {
188
+ const sourcePath = path.join(dir, file);
189
+ const targetPath = path.join(dir, `${toPrefix}${file.slice(fromPrefix.length)}`);
190
+ const sourceLines = fs.readFileSync(sourcePath, 'utf-8').split('\n').filter(Boolean);
191
+ const rewritten = sourceLines
192
+ .map((line) => {
193
+ try {
194
+ const parsed = JSON.parse(line) as HistoryMessage;
195
+ if (parsed.historySessionId !== fromId) return null;
196
+ return JSON.stringify({
197
+ ...parsed,
198
+ historySessionId: toId,
199
+ });
200
+ } catch {
201
+ return null;
202
+ }
203
+ })
204
+ .filter((line): line is string => !!line);
205
+ if (rewritten.length === 0) {
206
+ fs.unlinkSync(sourcePath);
207
+ continue;
208
+ }
209
+
210
+ const existing = fs.existsSync(targetPath)
211
+ ? new Set(fs.readFileSync(targetPath, 'utf-8').split('\n').filter(Boolean))
212
+ : new Set<string>();
213
+ const nextLines = rewritten.filter((line) => !existing.has(line));
214
+ if (nextLines.length > 0) {
215
+ fs.appendFileSync(targetPath, `${nextLines.join('\n')}\n`, 'utf-8');
216
+ }
217
+ fs.unlinkSync(sourcePath);
218
+ }
219
+ } catch {
220
+ // Ignore promotion failure; future messages will still write to the new session key.
221
+ }
222
+ }
223
+
110
224
  /** Called when agent session is explicitly changed */
111
225
  onSessionChange(agentType: string): void {
112
226
  this.lastSeenHashes.delete(agentType);
@@ -157,15 +271,15 @@ export function readChatHistory(
157
271
  agentType: string,
158
272
  offset: number = 0,
159
273
  limit: number = 30,
160
- instanceId?: string,
274
+ historySessionId?: string,
161
275
  ): { messages: HistoryMessage[]; hasMore: boolean } {
162
276
  try {
163
277
  const sanitized = agentType.replace(/[^a-zA-Z0-9_-]/g, '_');
164
278
  const dir = path.join(HISTORY_DIR, sanitized);
165
279
  if (!fs.existsSync(dir)) return { messages: [], hasMore: false };
166
280
 
167
- // JSONL file list — filter by instanceId prefix if specified
168
- const sanitizedInstance = instanceId?.replace(/[^a-zA-Z0-9_-]/g, '_');
281
+ // JSONL file list — filter by persistent history key when specified
282
+ const sanitizedInstance = historySessionId?.replace(/[^a-zA-Z0-9_-]/g, '_');
169
283
  const files = fs.readdirSync(dir)
170
284
  .filter(f => {
171
285
  if (!f.endsWith('.jsonl')) return false;
@@ -210,3 +324,76 @@ export function readChatHistory(
210
324
  return { messages: [], hasMore: false };
211
325
  }
212
326
  }
327
+
328
+ export function listSavedHistorySessions(
329
+ agentType: string,
330
+ options: { offset?: number; limit?: number } = {},
331
+ ): { sessions: SavedHistorySessionSummary[]; hasMore: boolean } {
332
+ try {
333
+ const sanitized = agentType.replace(/[^a-zA-Z0-9_-]/g, '_');
334
+ const dir = path.join(HISTORY_DIR, sanitized);
335
+ if (!fs.existsSync(dir)) return { sessions: [], hasMore: false };
336
+
337
+ const groupedFiles = new Map<string, string[]>();
338
+ const filePattern = /^([A-Za-z0-9_-]+)_\d{4}-\d{2}-\d{2}\.jsonl$/;
339
+ for (const file of fs.readdirSync(dir)) {
340
+ if (!file.endsWith('.jsonl')) continue;
341
+ const match = file.match(filePattern);
342
+ if (!match?.[1]) continue;
343
+ const historySessionId = match[1];
344
+ const files = groupedFiles.get(historySessionId) || [];
345
+ files.push(file);
346
+ groupedFiles.set(historySessionId, files);
347
+ }
348
+
349
+ const summaries: SavedHistorySessionSummary[] = [];
350
+ for (const [historySessionId, files] of groupedFiles.entries()) {
351
+ let messageCount = 0;
352
+ let firstMessageAt = 0;
353
+ let lastMessageAt = 0;
354
+ let sessionTitle = '';
355
+ let preview = '';
356
+
357
+ for (const file of files.sort()) {
358
+ const filePath = path.join(dir, file);
359
+ const content = fs.readFileSync(filePath, 'utf-8');
360
+ const lines = content.split('\n').filter(Boolean);
361
+ for (const line of lines) {
362
+ let parsed: HistoryMessage | null = null;
363
+ try {
364
+ parsed = JSON.parse(line) as HistoryMessage;
365
+ } catch {
366
+ parsed = null;
367
+ }
368
+ if (!parsed || parsed.historySessionId !== historySessionId) continue;
369
+ messageCount += 1;
370
+ if (!firstMessageAt || parsed.receivedAt < firstMessageAt) firstMessageAt = parsed.receivedAt;
371
+ if (!lastMessageAt || parsed.receivedAt > lastMessageAt) lastMessageAt = parsed.receivedAt;
372
+ if (parsed.sessionTitle) sessionTitle = parsed.sessionTitle;
373
+ if (parsed.role !== 'system' && parsed.content.trim()) preview = parsed.content.trim();
374
+ }
375
+ }
376
+
377
+ if (messageCount === 0 || !lastMessageAt) continue;
378
+ summaries.push({
379
+ historySessionId,
380
+ sessionTitle: sessionTitle || undefined,
381
+ messageCount,
382
+ firstMessageAt,
383
+ lastMessageAt,
384
+ preview: preview || undefined,
385
+ });
386
+ }
387
+
388
+ summaries.sort((a, b) => b.lastMessageAt - a.lastMessageAt);
389
+ const offset = Math.max(0, options.offset || 0);
390
+ const limit = Math.max(1, options.limit || 30);
391
+ const sliced = summaries.slice(offset, offset + limit);
392
+ return {
393
+ sessions: sliced,
394
+ hasMore: summaries.length > offset + limit,
395
+ };
396
+ } catch {
397
+ return { sessions: [], hasMore: false };
398
+ }
399
+ }
@@ -5,8 +5,10 @@
5
5
  */
6
6
  import type { WorkspaceEntry } from './workspaces.js';
7
7
  import type { RecentActivityEntry } from './recent-activity.js';
8
+ import type { SavedProviderSessionEntry } from './saved-sessions.js';
8
9
  export type { WorkspaceEntry } from './workspaces.js';
9
10
  export type { RecentActivityEntry } from './recent-activity.js';
11
+ export type { SavedProviderSessionEntry } from './saved-sessions.js';
10
12
  export interface ADHDevConfig {
11
13
  serverUrl: string;
12
14
  selectedIde: string | null;
@@ -23,6 +25,8 @@ export interface ADHDevConfig {
23
25
  defaultWorkspaceId?: string | null;
24
26
  /** Unified recent activity across IDE / CLI / ACP launch flows */
25
27
  recentActivity?: RecentActivityEntry[];
28
+ /** Persistent resume-capable provider sessions keyed by providerSessionId */
29
+ savedProviderSessions?: SavedProviderSessionEntry[];
26
30
  /** Last seen timestamps for live sessions, keyed by sessionId */
27
31
  sessionReads?: Record<string, number>;
28
32
  /** Last seen completion marker for live sessions, keyed by sessionId */
@@ -10,8 +10,10 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync } from 'f
10
10
  import { randomUUID } from 'crypto';
11
11
  import type { WorkspaceEntry } from './workspaces.js';
12
12
  import type { RecentActivityEntry } from './recent-activity.js';
13
+ import type { SavedProviderSessionEntry } from './saved-sessions.js';
13
14
  export type { WorkspaceEntry } from './workspaces.js';
14
15
  export type { RecentActivityEntry } from './recent-activity.js';
16
+ export type { SavedProviderSessionEntry } from './saved-sessions.js';
15
17
 
16
18
  export interface ADHDevConfig {
17
19
  // Server connection
@@ -44,6 +46,8 @@ export interface ADHDevConfig {
44
46
 
45
47
  /** Unified recent activity across IDE / CLI / ACP launch flows */
46
48
  recentActivity?: RecentActivityEntry[];
49
+ /** Persistent resume-capable provider sessions keyed by providerSessionId */
50
+ savedProviderSessions?: SavedProviderSessionEntry[];
47
51
  /** Last seen timestamps for live sessions, keyed by sessionId */
48
52
  sessionReads?: Record<string, number>;
49
53
  /** Last seen completion marker for live sessions, keyed by sessionId */
@@ -65,7 +69,7 @@ export interface ADHDevConfig {
65
69
  * Server-side D1 `machines.id` — the row ID assigned when daemon registers via
66
70
  * `POST /cli/complete`. Used as fallback for machine lookup on re-auth.
67
71
  *
68
- * @deprecated Legacy bridge field — will be removed after 2026-04-06.
72
+ * @deprecated Legacy bridge field — will be removed after 2026-05-01.
69
73
  * Modern auth flow uses `machineSecret` (adm_) to identify machines.
70
74
  */
71
75
  registeredMachineId?: string;
@@ -99,6 +103,7 @@ const DEFAULT_CONFIG: ADHDevConfig = {
99
103
  workspaces: [],
100
104
  defaultWorkspaceId: null,
101
105
  recentActivity: [],
106
+ savedProviderSessions: [],
102
107
  sessionReads: {},
103
108
  sessionReadMarkers: {},
104
109
  machineNickname: null,
@@ -161,6 +166,7 @@ function normalizeConfig(raw: unknown): ADHDevConfig & { activeWorkspaceId?: str
161
166
  workspaces: Array.isArray(parsed.workspaces) ? parsed.workspaces as WorkspaceEntry[] : [],
162
167
  defaultWorkspaceId: asNullableString(parsed.defaultWorkspaceId) ?? asNullableString(parsed.activeWorkspaceId),
163
168
  recentActivity: Array.isArray(parsed.recentActivity) ? parsed.recentActivity as RecentActivityEntry[] : [],
169
+ savedProviderSessions: Array.isArray(parsed.savedProviderSessions) ? parsed.savedProviderSessions as SavedProviderSessionEntry[] : [],
164
170
  sessionReads: mergedSessionReads,
165
171
  sessionReadMarkers,
166
172
  machineNickname: asNullableString(parsed.machineNickname),
@@ -187,7 +193,7 @@ function ensureMachineId(config: ADHDevConfig): { config: ADHDevConfig; changed:
187
193
  return { config, changed: false };
188
194
  }
189
195
 
190
- // TODO(2026-04-06): Remove this legacy bridge after cloud clients have had
196
+ // TODO(2026-05-01): Remove this legacy bridge after cloud clients have had
191
197
  // time to persist registeredMachineId from the upgraded setup/login flow.
192
198
  const legacyRegisteredMachineId = (!config.registeredMachineId && config.machineSecret && config.machineId)
193
199
  ? config.machineId
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Unlike live session state, this is launch oriented:
5
5
  * - one normalized row shape for IDE / CLI / ACP
6
- * - deduped by kind + providerType + workspace
6
+ * - deduped by provider session when available, else by kind + providerType + workspace
7
7
  * - used only for quick-launch shortcuts
8
8
  */
9
9
 
@@ -16,6 +16,7 @@ export interface RecentActivityEntry {
16
16
  kind: 'ide' | 'cli' | 'acp';
17
17
  providerType: string;
18
18
  providerName: string;
19
+ providerSessionId?: string;
19
20
  workspace?: string | null;
20
21
  currentModel?: string;
21
22
  title?: string;
@@ -37,6 +38,16 @@ export function buildRecentActivityKey(entry: Pick<RecentActivityEntry, 'kind' |
37
38
  return `${entry.kind}:${entry.providerType}:${normalizeWorkspace(entry.workspace)}`;
38
39
  }
39
40
 
41
+ export function buildRecentActivityKeyForEntry(
42
+ entry: Pick<RecentActivityEntry, 'kind' | 'providerType' | 'workspace' | 'providerSessionId'>,
43
+ ) {
44
+ const providerSessionId = typeof entry.providerSessionId === 'string' ? entry.providerSessionId.trim() : '';
45
+ if (providerSessionId) {
46
+ return `${entry.kind}:${entry.providerType}:session:${providerSessionId}`;
47
+ }
48
+ return buildRecentActivityKey(entry);
49
+ }
50
+
40
51
  export function appendRecentActivity(
41
52
  config: ADHDevConfig,
42
53
  entry: Omit<RecentActivityEntry, 'id' | 'lastUsedAt'> & { lastUsedAt?: number },
@@ -44,7 +55,7 @@ export function appendRecentActivity(
44
55
  const nextEntry: RecentActivityEntry = {
45
56
  ...entry,
46
57
  workspace: entry.workspace ? normalizeWorkspace(entry.workspace) : undefined,
47
- id: buildRecentActivityKey(entry),
58
+ id: buildRecentActivityKeyForEntry(entry),
48
59
  lastUsedAt: entry.lastUsedAt || Date.now(),
49
60
  };
50
61
 
@@ -0,0 +1,73 @@
1
+ import * as path from 'path';
2
+ import type { ADHDevConfig } from './config.js';
3
+ import { expandPath } from './workspaces.js';
4
+
5
+ export interface SavedProviderSessionEntry {
6
+ id: string;
7
+ kind: 'cli' | 'acp';
8
+ providerType: string;
9
+ providerName: string;
10
+ providerSessionId: string;
11
+ workspace?: string | null;
12
+ currentModel?: string;
13
+ title?: string;
14
+ createdAt: number;
15
+ lastUsedAt: number;
16
+ }
17
+
18
+ const MAX_SAVED_SESSIONS = 500;
19
+
20
+ function normalizeWorkspace(workspace?: string | null) {
21
+ if (!workspace) return '';
22
+ try {
23
+ return path.resolve(expandPath(workspace));
24
+ } catch {
25
+ return path.resolve(workspace);
26
+ }
27
+ }
28
+
29
+ export function buildSavedProviderSessionKey(providerSessionId: string) {
30
+ return `saved:${providerSessionId.trim()}`;
31
+ }
32
+
33
+ export function upsertSavedProviderSession(
34
+ config: ADHDevConfig,
35
+ entry: Omit<SavedProviderSessionEntry, 'id' | 'createdAt' | 'lastUsedAt'> & { createdAt?: number; lastUsedAt?: number },
36
+ ): ADHDevConfig {
37
+ const providerSessionId = typeof entry.providerSessionId === 'string' ? entry.providerSessionId.trim() : '';
38
+ if (!providerSessionId) return config;
39
+
40
+ const id = buildSavedProviderSessionKey(providerSessionId);
41
+ const existing = (config.savedProviderSessions || []).find(item => item.id === id);
42
+ const nextEntry: SavedProviderSessionEntry = {
43
+ id,
44
+ kind: entry.kind,
45
+ providerType: entry.providerType,
46
+ providerName: entry.providerName,
47
+ providerSessionId,
48
+ workspace: entry.workspace ? normalizeWorkspace(entry.workspace) : undefined,
49
+ currentModel: entry.currentModel,
50
+ title: entry.title,
51
+ createdAt: existing?.createdAt || entry.createdAt || Date.now(),
52
+ lastUsedAt: entry.lastUsedAt || Date.now(),
53
+ };
54
+
55
+ const filtered = (config.savedProviderSessions || []).filter(item => item.id !== id);
56
+ return {
57
+ ...config,
58
+ savedProviderSessions: [nextEntry, ...filtered].slice(0, MAX_SAVED_SESSIONS),
59
+ };
60
+ }
61
+
62
+ export function getSavedProviderSessions(
63
+ config: ADHDevConfig,
64
+ filters?: { providerType?: string; kind?: SavedProviderSessionEntry['kind'] },
65
+ ): SavedProviderSessionEntry[] {
66
+ return [...(config.savedProviderSessions || [])]
67
+ .filter(entry => {
68
+ if (filters?.providerType && entry.providerType !== filters.providerType) return false;
69
+ if (filters?.kind && entry.kind !== filters.kind) return false;
70
+ return true;
71
+ })
72
+ .sort((a, b) => b.lastUsedAt - a.lastUsedAt);
73
+ }