@muyichengshayu/promptx 0.1.45 → 0.1.47

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 (28) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/apps/runner/src/runManager.js +38 -8
  3. package/apps/server/src/agentSessionDiscovery.js +604 -0
  4. package/apps/server/src/agents/claudeCodeRunner.js +4 -0
  5. package/apps/server/src/agents/codexRunner.js +8 -1
  6. package/apps/server/src/agents/index.js +8 -0
  7. package/apps/server/src/agents/openCodeRunner.js +4 -0
  8. package/apps/server/src/codex.js +49 -21
  9. package/apps/server/src/codexRoutes.js +8 -0
  10. package/apps/server/src/index.js +2 -1
  11. package/apps/web/dist/assets/CodexSessionManagerDialog-DZk_ClXW.js +3 -0
  12. package/apps/web/dist/assets/TaskDiffReviewDialog-B28M4Rwo.js +3 -0
  13. package/apps/web/dist/assets/TaskDiffReviewDialog-CeGj0idf.css +1 -0
  14. package/apps/web/dist/assets/{WorkbenchSettingsDialog-Dp35iSLy.js → WorkbenchSettingsDialog-DrXiTtO_.js} +1 -1
  15. package/apps/web/dist/assets/WorkbenchView-BdPC47JX.js +53 -0
  16. package/apps/web/dist/assets/WorkbenchView-BdpDI-HR.css +1 -0
  17. package/apps/web/dist/assets/index-Bx48HpZF.js +2 -0
  18. package/apps/web/dist/assets/index-DaELubyR.css +1 -0
  19. package/apps/web/dist/index.html +2 -2
  20. package/package.json +1 -1
  21. package/packages/shared/src/index.js +16 -0
  22. package/apps/web/dist/assets/CodexSessionManagerDialog-Da3ExvJ6.js +0 -1
  23. package/apps/web/dist/assets/TaskDiffReviewDialog-DjUU465b.js +0 -2
  24. package/apps/web/dist/assets/TaskDiffReviewDialog-Np_9C-g7.css +0 -1
  25. package/apps/web/dist/assets/WorkbenchView-CICtaY88.js +0 -47
  26. package/apps/web/dist/assets/WorkbenchView-CVt59HvL.css +0 -1
  27. package/apps/web/dist/assets/index-CWazC5a3.css +0 -1
  28. package/apps/web/dist/assets/index-d30njJCo.js +0 -2
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.47
4
+
5
+ - 工作台新增“选中插入到编辑区”能力:在模型回复、项目源码查看与代码变更里都可直接拖选内容并一键插入右侧编辑区;其中模型回复按普通文本插入,更适合二次处理,源码与 diff 继续按代码上下文块插入。
6
+ - 统一选区插入按钮的交互与层级:三处场景改为共用同一套按钮组件,按钮从全局浮层收回到各自容器内,并在滚动、缩放与移动端视口变化时跟随选区重新定位,避免遮挡弹层头部或滚动后飘离内容区。
7
+ - 回复卡片代码块补齐复制入口,并继续收敛右侧导入文件阅读体验:代码块头部支持一键复制,导入文件正文从 `14px` 调整到 `12px`,同时把“已插入代码上下文到右侧编辑区”统一收口为更中性的插入成功提示。
8
+
9
+ ## 0.1.46
10
+
11
+ - 项目管理新增本地会话发现与候选选择:`Codex / Claude Code / OpenCode` 都支持读取本机已有 session,创建或编辑项目时可直接按当前工作目录筛选并选择已有会话,不再只能手填 session ID。
12
+ - 补齐三种引擎的会话发现链路与回写一致性:`Codex` 改为直接读取带 WAL 的本地 SQLite,避免最新线程漏读;`OpenCode` 优先读取官方本地数据库,并在仅于最终结果返回 `threadId` 时也能正确回写到项目绑定。
13
+ - 收敛项目管理弹窗交互细节:目录比较逻辑抽到共享模块统一处理,候选会话列表增加短时缓存,减少同目录下反复打开弹窗时的重复请求与闪烁。
14
+
3
15
  ## 0.1.45
4
16
 
5
17
  - 新增工作台“通用设置”:可配置 `Enter 发送 / Shift+Enter 发送 / 仅按钮发送` 三种发送行为,设置保存在本地,刷新后仍可延续;同时修复回车发送时编辑器会先插入一行空白再发送的问题。
@@ -48,6 +48,34 @@ function normalizeSession(payload = {}) {
48
48
  }
49
49
  }
50
50
 
51
+ function applyThreadIdentity(session = {}, threadId = '') {
52
+ const value = String(threadId || '').trim()
53
+ if (!value) {
54
+ return session
55
+ }
56
+
57
+ const engine = String(session?.engine || '').trim() || 'codex'
58
+ if (engine === 'codex') {
59
+ return {
60
+ ...session,
61
+ codexThreadId: value,
62
+ engineThreadId: value,
63
+ running: true,
64
+ started: true,
65
+ updatedAt: nowIso(),
66
+ }
67
+ }
68
+
69
+ return {
70
+ ...session,
71
+ engineSessionId: value,
72
+ engineThreadId: value,
73
+ running: true,
74
+ started: true,
75
+ updatedAt: nowIso(),
76
+ }
77
+ }
78
+
51
79
  function createRunSnapshot(context = {}) {
52
80
  const stopControl = getChildStopDiagnostics(context.child)
53
81
  return {
@@ -418,6 +446,15 @@ export function createRunManager(options = {}) {
418
446
  }
419
447
 
420
448
  async function handleStreamCompletion(context, result = {}) {
449
+ const completedThreadId = String(result?.threadId || '').trim()
450
+ if (completedThreadId) {
451
+ const nextSession = applyThreadIdentity(context.session, completedThreadId)
452
+ if (nextSession !== context.session) {
453
+ context.session = nextSession
454
+ queueEvent(context, createSessionUpdatedEnvelopeEvent(context.session))
455
+ }
456
+ }
457
+
421
458
  if (context.stopRequestedAt) {
422
459
  await finalizeRun(context, 'stopped', {
423
460
  responseMessage: String(result?.message || '').trim(),
@@ -476,14 +513,7 @@ export function createRunManager(options = {}) {
476
513
  return
477
514
  }
478
515
 
479
- context.session = {
480
- ...context.session,
481
- codexThreadId: value,
482
- engineThreadId: value,
483
- running: true,
484
- started: true,
485
- updatedAt: nowIso(),
486
- }
516
+ context.session = applyThreadIdentity(context.session, value)
487
517
  queueEvent(context, createSessionUpdatedEnvelopeEvent(context.session))
488
518
  },
489
519
  })
@@ -0,0 +1,604 @@
1
+ import fs from 'node:fs'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+ import Database from 'better-sqlite3'
5
+ import { AGENT_ENGINES, normalizeComparablePath } from '../../../packages/shared/src/index.js'
6
+
7
+ const DEFAULT_LIMIT = 80
8
+ const MAX_SCAN_FILES = 800
9
+ const MAX_DAT_FILE_SIZE = 8 * 1024 * 1024
10
+ const MAX_PREVIEW_LENGTH = 80
11
+
12
+ function normalizeLimit(value, fallback = DEFAULT_LIMIT) {
13
+ const limit = Math.max(1, Number(value) || fallback)
14
+ return Math.min(200, limit)
15
+ }
16
+
17
+ function normalizeText(value = '') {
18
+ return String(value || '').trim()
19
+ }
20
+
21
+ function safeStat(filePath = '') {
22
+ try {
23
+ return fs.statSync(filePath)
24
+ } catch {
25
+ return null
26
+ }
27
+ }
28
+
29
+ function safeReadFile(filePath = '', maxBytes = MAX_DAT_FILE_SIZE) {
30
+ const stat = safeStat(filePath)
31
+ if (!stat?.isFile() || stat.size > maxBytes) {
32
+ return ''
33
+ }
34
+
35
+ try {
36
+ return fs.readFileSync(filePath, 'utf8')
37
+ } catch {
38
+ return ''
39
+ }
40
+ }
41
+
42
+ function parseJson(value) {
43
+ const text = normalizeText(value)
44
+ if (!text) {
45
+ return null
46
+ }
47
+
48
+ try {
49
+ return JSON.parse(text)
50
+ } catch {
51
+ return null
52
+ }
53
+ }
54
+
55
+ function parseMaybeJson(value) {
56
+ if (!value) {
57
+ return null
58
+ }
59
+
60
+ if (typeof value === 'object') {
61
+ return value
62
+ }
63
+
64
+ return parseJson(value)
65
+ }
66
+
67
+ function extractOpenCodePromptText(value) {
68
+ const parsed = parseMaybeJson(value)
69
+ if (!parsed || typeof parsed !== 'object') {
70
+ return normalizeText(value)
71
+ }
72
+
73
+ if (Array.isArray(parsed.prompt)) {
74
+ const parts = parsed.prompt
75
+ .map((item) => {
76
+ if (!item || typeof item !== 'object') {
77
+ return ''
78
+ }
79
+ return normalizeText(item.content || item.text || '')
80
+ })
81
+ .filter(Boolean)
82
+ return parts.join(' ').trim()
83
+ }
84
+
85
+ return extractMessageText(parsed)
86
+ }
87
+
88
+ function sanitizeOpenCodeSessionLabel(value = '', cwd = '', sessionId = '') {
89
+ const text = normalizeText(value).replace(/\s+/g, ' ').trim()
90
+ if (text.length >= 2) {
91
+ return text
92
+ }
93
+
94
+ return path.basename(normalizeText(cwd)) || normalizeText(sessionId)
95
+ }
96
+
97
+ function toIsoDate(value) {
98
+ if (value === null || value === undefined || value === '') {
99
+ return ''
100
+ }
101
+
102
+ if (value instanceof Date && Number.isFinite(value.getTime())) {
103
+ return value.toISOString()
104
+ }
105
+
106
+ if (typeof value === 'number' && Number.isFinite(value)) {
107
+ const timestamp = value > 1e12 ? value : value * 1000
108
+ const date = new Date(timestamp)
109
+ return Number.isFinite(date.getTime()) ? date.toISOString() : ''
110
+ }
111
+
112
+ const text = normalizeText(value)
113
+ if (!text) {
114
+ return ''
115
+ }
116
+
117
+ if (/^\d+$/.test(text)) {
118
+ return toIsoDate(Number(text))
119
+ }
120
+
121
+ const date = new Date(text)
122
+ return Number.isFinite(date.getTime()) ? date.toISOString() : ''
123
+ }
124
+
125
+ function getSortTime(value = '') {
126
+ const time = Date.parse(normalizeText(value))
127
+ return Number.isFinite(time) ? time : 0
128
+ }
129
+
130
+ function createSessionCandidate(input = {}) {
131
+ const id = normalizeText(input.id)
132
+ if (!id) {
133
+ return null
134
+ }
135
+
136
+ const cwd = normalizeText(input.cwd)
137
+ const label = normalizeText(input.label) || path.basename(cwd) || id
138
+ return {
139
+ id,
140
+ engine: input.engine,
141
+ label: label.length > MAX_PREVIEW_LENGTH ? `${label.slice(0, MAX_PREVIEW_LENGTH - 1)}…` : label,
142
+ cwd,
143
+ updatedAt: toIsoDate(input.updatedAt),
144
+ updatedAtSource: normalizeText(input.updatedAtSource),
145
+ source: normalizeText(input.source),
146
+ summary: normalizeText(input.summary),
147
+ }
148
+ }
149
+
150
+ function getUpdatedAtPriority(value = '') {
151
+ return value === 'explicit' ? 1 : 0
152
+ }
153
+
154
+ function sortAndLimitCandidates(items = [], options = {}) {
155
+ const limit = normalizeLimit(options.limit)
156
+ const targetCwd = normalizeComparablePath(options.cwd)
157
+ const deduped = new Map()
158
+
159
+ items.forEach((item) => {
160
+ const candidate = createSessionCandidate(item)
161
+ if (!candidate) {
162
+ return
163
+ }
164
+
165
+ const key = `${candidate.engine}:${candidate.id}`
166
+ const current = deduped.get(key)
167
+ if (!current) {
168
+ deduped.set(key, candidate)
169
+ return
170
+ }
171
+
172
+ const nextScore = Number(Boolean(candidate.cwd)) + Number(Boolean(candidate.label && candidate.label !== candidate.id))
173
+ const currentScore = Number(Boolean(current.cwd)) + Number(Boolean(current.label && current.label !== current.id))
174
+ const nextTimePriority = getUpdatedAtPriority(candidate.updatedAtSource)
175
+ const currentTimePriority = getUpdatedAtPriority(current.updatedAtSource)
176
+ if (
177
+ nextTimePriority > currentTimePriority
178
+ || (nextTimePriority === currentTimePriority && getSortTime(candidate.updatedAt) > getSortTime(current.updatedAt))
179
+ || nextScore > currentScore
180
+ ) {
181
+ deduped.set(key, {
182
+ ...current,
183
+ ...candidate,
184
+ cwd: candidate.cwd || current.cwd,
185
+ label: candidate.label || current.label,
186
+ summary: candidate.summary || current.summary,
187
+ updatedAt: candidate.updatedAt || current.updatedAt,
188
+ updatedAtSource: candidate.updatedAtSource || current.updatedAtSource,
189
+ })
190
+ }
191
+ })
192
+
193
+ return [...deduped.values()]
194
+ .map((item) => ({
195
+ ...item,
196
+ matchedCwd: Boolean(targetCwd && normalizeComparablePath(item.cwd) === targetCwd),
197
+ }))
198
+ .sort((left, right) => (
199
+ Number(right.matchedCwd) - Number(left.matchedCwd)
200
+ || getUpdatedAtPriority(right.updatedAtSource) - getUpdatedAtPriority(left.updatedAtSource)
201
+ || getSortTime(right.updatedAt) - getSortTime(left.updatedAt)
202
+ || String(left.label || left.id).localeCompare(String(right.label || right.id), 'zh-CN')
203
+ ))
204
+ .slice(0, limit)
205
+ }
206
+
207
+ function collectFiles(rootDir = '', options = {}) {
208
+ const root = normalizeText(rootDir)
209
+ if (!root || !safeStat(root)?.isDirectory()) {
210
+ return []
211
+ }
212
+
213
+ const maxDepth = Math.max(0, Number(options.maxDepth) || 0)
214
+ const maxFiles = Math.max(1, Number(options.maxFiles) || MAX_SCAN_FILES)
215
+ const match = typeof options.match === 'function' ? options.match : () => true
216
+ const files = []
217
+
218
+ function visit(dir, depth) {
219
+ if (files.length >= maxFiles) {
220
+ return
221
+ }
222
+
223
+ let entries = []
224
+ try {
225
+ entries = fs.readdirSync(dir, { withFileTypes: true })
226
+ } catch {
227
+ return
228
+ }
229
+
230
+ for (const entry of entries) {
231
+ if (files.length >= maxFiles || entry.name.startsWith('.')) {
232
+ continue
233
+ }
234
+
235
+ const entryPath = path.join(dir, entry.name)
236
+ if (entry.isDirectory()) {
237
+ if (depth < maxDepth) {
238
+ visit(entryPath, depth + 1)
239
+ }
240
+ continue
241
+ }
242
+
243
+ if (entry.isFile() && match(entryPath, entry.name)) {
244
+ files.push(entryPath)
245
+ }
246
+ }
247
+ }
248
+
249
+ visit(root, 0)
250
+ return files
251
+ }
252
+
253
+ function extractMessageText(value, depth = 0) {
254
+ if (!value || depth > 4) {
255
+ return ''
256
+ }
257
+
258
+ if (typeof value === 'string') {
259
+ return normalizeText(value)
260
+ }
261
+
262
+ if (Array.isArray(value)) {
263
+ for (const item of value) {
264
+ const text = extractMessageText(item, depth + 1)
265
+ if (text) {
266
+ return text
267
+ }
268
+ }
269
+ return ''
270
+ }
271
+
272
+ if (typeof value !== 'object') {
273
+ return ''
274
+ }
275
+
276
+ for (const key of ['text', 'content', 'message', 'prompt', 'summary']) {
277
+ if (!Object.prototype.hasOwnProperty.call(value, key)) {
278
+ continue
279
+ }
280
+
281
+ const text = extractMessageText(value[key], depth + 1)
282
+ if (text) {
283
+ return text
284
+ }
285
+ }
286
+
287
+ return ''
288
+ }
289
+
290
+ function readJsonlPreview(filePath = '') {
291
+ const content = safeReadFile(filePath, 256 * 1024)
292
+ if (!content) {
293
+ return ''
294
+ }
295
+
296
+ const lines = content.replace(/\r\n/g, '\n').split('\n').slice(0, 30)
297
+ for (const line of lines) {
298
+ const event = parseJson(line)
299
+ if (!event) {
300
+ continue
301
+ }
302
+
303
+ const type = normalizeText(event.type).toLowerCase()
304
+ if (type && type !== 'user') {
305
+ continue
306
+ }
307
+
308
+ const text = extractMessageText(event)
309
+ if (text) {
310
+ return text.replace(/\s+/g, ' ').slice(0, MAX_PREVIEW_LENGTH)
311
+ }
312
+ }
313
+
314
+ return ''
315
+ }
316
+
317
+ export function decodeClaudeProjectPath(projectKey = '') {
318
+ const key = normalizeText(projectKey)
319
+ if (!key) {
320
+ return ''
321
+ }
322
+
323
+ if (key.startsWith('-')) {
324
+ return key.replace(/-/g, '/')
325
+ }
326
+
327
+ const driveMatch = key.match(/^([A-Za-z])-+/)
328
+ if (driveMatch) {
329
+ return `${driveMatch[1]}:${key.slice(driveMatch[0].length - 1).replace(/-/g, '\\')}`
330
+ }
331
+
332
+ return ''
333
+ }
334
+
335
+ export function listKnownClaudeCodeSessions(options = {}) {
336
+ const claudeHome = normalizeText(options.claudeHome || process.env.CLAUDE_HOME)
337
+ || path.join(os.homedir(), '.claude')
338
+ const transcriptDir = path.join(claudeHome, 'transcripts')
339
+ const projectsDir = path.join(claudeHome, 'projects')
340
+ const items = []
341
+
342
+ collectFiles(transcriptDir, {
343
+ maxDepth: 0,
344
+ maxFiles: MAX_SCAN_FILES,
345
+ match: (filePath) => filePath.endsWith('.jsonl'),
346
+ }).forEach((filePath) => {
347
+ const stat = safeStat(filePath)
348
+ const id = path.basename(filePath, '.jsonl')
349
+ items.push({
350
+ id,
351
+ engine: AGENT_ENGINES.CLAUDE_CODE,
352
+ label: readJsonlPreview(filePath) || id,
353
+ updatedAt: stat?.mtime,
354
+ source: 'claude_transcripts',
355
+ })
356
+ })
357
+
358
+ collectFiles(projectsDir, {
359
+ maxDepth: 3,
360
+ maxFiles: MAX_SCAN_FILES,
361
+ match: (filePath) => filePath.endsWith('.jsonl'),
362
+ }).forEach((filePath) => {
363
+ const stat = safeStat(filePath)
364
+ const relativeParts = path.relative(projectsDir, filePath).split(path.sep).filter(Boolean)
365
+ const projectKey = relativeParts[0] || ''
366
+ const cwd = decodeClaudeProjectPath(projectKey)
367
+ const id = path.basename(filePath, '.jsonl')
368
+ items.push({
369
+ id,
370
+ engine: AGENT_ENGINES.CLAUDE_CODE,
371
+ label: readJsonlPreview(filePath) || path.basename(cwd) || id,
372
+ cwd,
373
+ updatedAt: stat?.mtime,
374
+ source: 'claude_projects',
375
+ })
376
+ })
377
+
378
+ return sortAndLimitCandidates(items, options)
379
+ }
380
+
381
+ function getOpenCodeDataDirs(options = {}) {
382
+ if (options.openCodeDataDir) {
383
+ return [options.openCodeDataDir]
384
+ }
385
+
386
+ if (process.env.OPENCODE_DATA_DIR) {
387
+ return [process.env.OPENCODE_DATA_DIR]
388
+ }
389
+
390
+ const dirs = []
391
+ const home = os.homedir()
392
+ if (process.platform === 'darwin') {
393
+ dirs.push(path.join(home, 'Library', 'Application Support', 'ai.opencode.desktop'))
394
+ } else if (process.platform === 'win32') {
395
+ const appData = process.env.APPDATA || path.join(home, 'AppData', 'Roaming')
396
+ dirs.push(path.join(appData, 'ai.opencode.desktop'))
397
+ } else {
398
+ dirs.push(path.join(home, '.config', 'ai.opencode.desktop'))
399
+ }
400
+ dirs.push(path.join(home, '.opencode'))
401
+ return dirs
402
+ }
403
+
404
+ function getOpenCodeDbPaths(options = {}) {
405
+ const home = os.homedir()
406
+
407
+ if (options.openCodeDbPath) {
408
+ return [normalizeText(options.openCodeDbPath)].filter(Boolean)
409
+ }
410
+
411
+ const paths = []
412
+ if (process.env.OPENCODE_DB_PATH) {
413
+ paths.push(process.env.OPENCODE_DB_PATH)
414
+ }
415
+
416
+ if (process.platform === 'win32') {
417
+ const localAppData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local')
418
+ paths.push(path.join(localAppData, 'opencode', 'opencode.db'))
419
+ } else {
420
+ paths.push(path.join(home, '.local', 'share', 'opencode', 'opencode.db'))
421
+ }
422
+
423
+ paths.push(path.join(home, '.opencode', 'opencode.db'))
424
+
425
+ return [...new Set(paths.map((item) => normalizeText(item)).filter(Boolean))]
426
+ }
427
+
428
+ function decodeOpenCodeWorkspacePath(fileName = '') {
429
+ const name = normalizeText(fileName)
430
+ const match = name.match(/^opencode\.workspace\.([^.]+)(?:\..*)?\.dat$/)
431
+ if (!match) {
432
+ return ''
433
+ }
434
+
435
+ const token = match[1]
436
+ if (token.startsWith('/') || /^[a-z]:[\\/]/i.test(token)) {
437
+ return token
438
+ }
439
+
440
+ try {
441
+ const decoded = Buffer.from(token, 'base64').toString('utf8')
442
+ if (
443
+ (decoded.startsWith('/') && decoded.split('/').filter(Boolean).length >= 3)
444
+ || /^[a-z]:[\\/]/i.test(decoded)
445
+ ) {
446
+ return decoded
447
+ }
448
+ } catch {
449
+ return ''
450
+ }
451
+
452
+ return ''
453
+ }
454
+
455
+ function readOpenCodeDat(filePath = '') {
456
+ return parseJson(safeReadFile(filePath)) || {}
457
+ }
458
+
459
+ function loadOpenCodeSessionsFromDb(options = {}) {
460
+ const dbPath = getOpenCodeDbPaths(options).find((candidate) => safeStat(candidate)?.isFile())
461
+ if (!dbPath) {
462
+ return []
463
+ }
464
+
465
+ let db
466
+ try {
467
+ db = new Database(dbPath, { readonly: true, fileMustExist: true })
468
+ const rows = db.prepare(`
469
+ select
470
+ id,
471
+ title,
472
+ directory,
473
+ time_updated,
474
+ time_created,
475
+ time_archived
476
+ from session
477
+ where time_archived is null
478
+ order by time_updated desc
479
+ limit ?
480
+ `).all(Math.max(50, normalizeLimit(options.limit) * 4))
481
+
482
+ return rows.map((row) => ({
483
+ id: normalizeText(row.id),
484
+ engine: AGENT_ENGINES.OPENCODE,
485
+ label: sanitizeOpenCodeSessionLabel(row.title, row.directory, row.id),
486
+ cwd: normalizeText(row.directory),
487
+ updatedAt: row.time_updated || row.time_created,
488
+ source: 'opencode_db',
489
+ summary: '',
490
+ }))
491
+ } catch {
492
+ return []
493
+ } finally {
494
+ try {
495
+ db?.close()
496
+ } catch {
497
+ // ignore close errors
498
+ }
499
+ }
500
+ }
501
+
502
+ function addOpenCodeLayoutSessions(dat = {}, sourceFile = '', items = [], idToCwd = new Map()) {
503
+ const layout = parseMaybeJson(dat['layout.page'])
504
+ if (!layout || typeof layout !== 'object') {
505
+ return
506
+ }
507
+
508
+ const lastProjectSession = layout.lastProjectSession && typeof layout.lastProjectSession === 'object'
509
+ ? layout.lastProjectSession
510
+ : {}
511
+ Object.entries(lastProjectSession).forEach(([projectPath, value]) => {
512
+ const item = value && typeof value === 'object' ? value : {}
513
+ const id = normalizeText(item.id)
514
+ const cwd = normalizeText(item.directory || projectPath)
515
+ if (!id) {
516
+ return
517
+ }
518
+ idToCwd.set(id, cwd)
519
+ items.push({
520
+ id,
521
+ engine: AGENT_ENGINES.OPENCODE,
522
+ label: path.basename(cwd) || id,
523
+ cwd,
524
+ updatedAt: item.at,
525
+ updatedAtSource: item.at ? 'explicit' : 'inferred',
526
+ source: 'opencode_desktop',
527
+ })
528
+ })
529
+
530
+ const lastSession = layout.lastSession && typeof layout.lastSession === 'object'
531
+ ? layout.lastSession
532
+ : {}
533
+ Object.entries(lastSession).forEach(([projectPath, idValue]) => {
534
+ const id = normalizeText(idValue)
535
+ const cwd = normalizeText(projectPath)
536
+ if (!id) {
537
+ return
538
+ }
539
+ idToCwd.set(id, cwd)
540
+ items.push({
541
+ id,
542
+ engine: AGENT_ENGINES.OPENCODE,
543
+ label: path.basename(cwd) || id,
544
+ cwd,
545
+ updatedAt: safeStat(sourceFile)?.mtime,
546
+ updatedAtSource: 'inferred',
547
+ source: 'opencode_desktop',
548
+ })
549
+ })
550
+ }
551
+
552
+ export function listKnownOpenCodeSessions(options = {}) {
553
+ const sqliteItems = loadOpenCodeSessionsFromDb(options)
554
+ if (sqliteItems.length) {
555
+ return sortAndLimitCandidates(sqliteItems, options)
556
+ }
557
+
558
+ const items = []
559
+ const idToCwd = new Map()
560
+
561
+ getOpenCodeDataDirs(options).forEach((dataDir) => {
562
+ collectFiles(dataDir, {
563
+ maxDepth: 0,
564
+ maxFiles: MAX_SCAN_FILES,
565
+ match: (filePath) => filePath.endsWith('.dat'),
566
+ }).forEach((filePath) => {
567
+ const stat = safeStat(filePath)
568
+ const dat = readOpenCodeDat(filePath)
569
+ const fileCwd = decodeOpenCodeWorkspacePath(path.basename(filePath))
570
+
571
+ addOpenCodeLayoutSessions(dat, filePath, items, idToCwd)
572
+
573
+ const fileSessionIds = [...new Set(
574
+ Object.keys(dat)
575
+ .map((key) => String(key || '').match(/^session:([^:]+):/)?.[1] || '')
576
+ .filter(Boolean)
577
+ )]
578
+ const mappedFileCwds = [...new Set(fileSessionIds.map((id) => idToCwd.get(id)).filter(Boolean))]
579
+ const workspaceCwd = mappedFileCwds.length === 1 ? mappedFileCwds[0] : fileCwd
580
+
581
+ Object.entries(dat).forEach(([key, value]) => {
582
+ const match = String(key || '').match(/^session:([^:]+):([^:]+)$/)
583
+ if (!match) {
584
+ return
585
+ }
586
+
587
+ const [, id, field] = match
588
+ const cwd = idToCwd.get(id) || workspaceCwd
589
+ const text = field === 'prompt' ? extractOpenCodePromptText(value) : ''
590
+ items.push({
591
+ id,
592
+ engine: AGENT_ENGINES.OPENCODE,
593
+ label: sanitizeOpenCodeSessionLabel(text, cwd, id),
594
+ cwd,
595
+ updatedAt: stat?.mtime,
596
+ updatedAtSource: 'inferred',
597
+ source: 'opencode_desktop',
598
+ })
599
+ })
600
+ })
601
+ })
602
+
603
+ return sortAndLimitCandidates(items, options)
604
+ }
@@ -16,6 +16,7 @@ import {
16
16
  createTurnCompletedEvent,
17
17
  getAgentEngineLabel,
18
18
  } from '../../../../packages/shared/src/index.js'
19
+ import { listKnownClaudeCodeSessions } from '../agentSessionDiscovery.js'
19
20
  import { createManagedSpawnOptions, forceStopChildProcess } from '../processControl.js'
20
21
 
21
22
  const CLAUDE_CODE_BIN = process.env.CLAUDE_CODE_BIN || 'claude'
@@ -842,6 +843,9 @@ export const claudeCodeRunner = {
842
843
  listKnownWorkspaces() {
843
844
  return []
844
845
  },
846
+ listKnownSessions(options = {}) {
847
+ return listKnownClaudeCodeSessions(options)
848
+ },
845
849
  streamSessionPrompt(session, prompt, callbacks = {}) {
846
850
  return streamPromptToClaudeCodeSession(session, prompt, callbacks)
847
851
  },