deskbuddy 0.2.0-beta.6 → 0.2.0-beta.8

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.
package/dist/hook.cjs CHANGED
@@ -86,8 +86,8 @@ const FALLBACK_REGISTRY = {
86
86
  format: 'codex-toml',
87
87
  processNames: ['codex'],
88
88
  // codex-thread-name 是机会主义的一级:session_index.jsonl 里的 thread_name
89
- // 原生只有 /rename 手动设,多数用户是空的,取不到就往下回落
90
- titleSources: ['codex-thread-name', 'git-branch', 'project'],
89
+ // 只有 /rename 或 VS Code 扩展起过名的才有,多数是空的,取不到用首条指令
90
+ titleSources: ['codex-thread-name', 'first-prompt', 'git-branch', 'project'],
91
91
  events: {
92
92
  UserPromptSubmit: 'watch',
93
93
  PreToolUse: 'watch',
@@ -100,7 +100,9 @@ const FALLBACK_REGISTRY = {
100
100
  workbuddy: {
101
101
  format: 'cc-hook',
102
102
  processNames: ['WorkBuddy'],
103
- titleSources: ['git-branch', 'project'],
103
+ // WorkBuddy 的 transcript 与 Claude Code 同格式,也写 ai-title(2026-10-01 WorkBuddy 5.6.2 实测);
104
+ // 工作目录是按时间自动建的,目录名不能当标题
105
+ titleSources: ['cc-ai-title', 'first-prompt', 'git-branch', 'project'],
104
106
  events: {
105
107
  UserPromptSubmit: 'watch',
106
108
  PreToolUse: 'watch',
@@ -113,7 +115,9 @@ const FALLBACK_REGISTRY = {
113
115
  qoder: {
114
116
  format: 'cc-hook',
115
117
  processNames: ['Qoder CN'],
116
- titleSources: ['git-branch', 'project'],
118
+ // 桌面端会话库里的标题(先是首条指令,后被换成 AI 摘要);qodercli 命令行版的会话不在库里,落到首条指令
119
+ // (CLI 1.1.61 实测 transcript 里没有 ai-title,UserPromptSubmit 带 prompt)
120
+ titleSources: ['qoder-session-title', 'first-prompt', 'git-branch', 'project'],
117
121
  events: {
118
122
  UserPromptSubmit: 'watch',
119
123
  PreToolUse: 'watch',
@@ -126,8 +130,8 @@ const FALLBACK_REGISTRY = {
126
130
  opencode: {
127
131
  format: 'oc-plugin',
128
132
  processNames: ['opencode'],
129
- // opencode 自带的会话标题是模型对提问的摘要,违反隐私约束(spec §6.4),不用
130
- titleSources: ['git-branch', 'project'],
133
+ // opencode 自己生成的会话标题(插件从 session.updated 取,随事件传 oc_session_title),没有就用首条指令
134
+ titleSources: ['oc-session-title', 'first-prompt', 'git-branch', 'project'],
131
135
  events: {
132
136
  UserPromptSubmit: 'watch',
133
137
  PreToolUse: 'watch',
@@ -321,7 +325,137 @@ function readGitBranch(cwd, maxUp) {
321
325
  return null
322
326
  }
323
327
 
324
- /** 按 registry 给的顺序逐级回落。所有来源都不碰 prompt 原文。 */
328
+ /** 首条指令当标题时的最大字数(按字符),与 src/core/title.ts 一致 */
329
+ const FIRST_PROMPT_MAX = 30
330
+
331
+ /** 用户指令 → 可上屏的短标题,与 src/core/title.ts 的 cleanPrompt 一致 */
332
+ function cleanPrompt(text) {
333
+ if (typeof text !== 'string') return ''
334
+ // eslint-disable-next-line no-control-regex
335
+ const flat = text.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]+/g, ' ').replace(/\s+/g, ' ').trim()
336
+ const chars = Array.from(flat)
337
+ return chars.length > FIRST_PROMPT_MAX ? chars.slice(0, FIRST_PROMPT_MAX).join('').trimEnd() + '…' : flat
338
+ }
339
+
340
+ /** 会话库标题的最大字数,与 src/core/title.ts 一致 */
341
+ const SESSION_TITLE_MAX = 100
342
+
343
+ /** 外部来的标题清洗,与 src/core/title.ts 的 cleanTitle 一致 */
344
+ function cleanTitle(text) {
345
+ if (typeof text !== 'string') return ''
346
+ // eslint-disable-next-line no-control-regex
347
+ const flat = text.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]+/g, ' ').replace(/\s+/g, ' ').trim()
348
+ return Array.from(flat).slice(0, SESSION_TITLE_MAX).join('').trimEnd()
349
+ }
350
+
351
+ /**
352
+ * node:sqlite 是 Node 内置模块(22.13+ / 23.4+ 免 flag),更老的 Node require 直接抛,调用方跳过。
353
+ * 加载时的 ExperimentalWarning 吞掉,免得打进智能体的 hook 输出
354
+ */
355
+ function loadNodeSqlite() {
356
+ const orig = process.emitWarning
357
+ process.emitWarning = function () {
358
+ if (String(arguments[0] == null ? '' : arguments[0]).indexOf('SQLite') !== -1) return
359
+ return orig.apply(process, arguments)
360
+ }
361
+ try {
362
+ return require('node:sqlite')
363
+ } finally {
364
+ process.emitWarning = orig
365
+ }
366
+ }
367
+
368
+ /** Qoder 桌面端会话库候选路径,与 src/core/title.ts 的 qoderDbPaths 一致(来源与未核对项见那边注释) */
369
+ function qoderDbPaths(platform, home, env) {
370
+ const pf = platform || process.platform
371
+ const h = home || os.homedir()
372
+ const e = env || process.env
373
+ let root
374
+ if (pf === 'darwin') root = path.join(h, 'Library', 'Application Support')
375
+ else if (pf === 'win32') root = e.APPDATA || path.join(h, 'AppData', 'Roaming')
376
+ else root = e.XDG_CONFIG_HOME || path.join(h, '.config')
377
+ return ['com.qodercn.app.stable', 'com.qoder.app.stable'].map((d) => path.join(root, d, 'main.sqlite'))
378
+ }
379
+
380
+ /** Qoder 桌面端的会话标题(chat_sessions.title),与 src/core/title.ts 一致。任何失败返回 null */
381
+ function readQoderSessionTitle(sessionId, paths, load) {
382
+ if (!sessionId) return null
383
+ const existing = (paths || qoderDbPaths()).filter((p) => fs.existsSync(p))
384
+ if (existing.length === 0) return null
385
+ let mod
386
+ try {
387
+ mod = (load || loadNodeSqlite)()
388
+ } catch (e) {
389
+ return null
390
+ }
391
+ for (const p of existing) {
392
+ let db
393
+ try {
394
+ db = new mod.DatabaseSync(p, { readOnly: true })
395
+ const row = db.prepare('select title from chat_sessions where session_id = ?').get(sessionId)
396
+ const t = cleanTitle(row && row.title)
397
+ if (t) return t
398
+ } catch (e) {
399
+ /* 这个库读不了,试下一个 */
400
+ } finally {
401
+ try {
402
+ if (db) db.close()
403
+ } catch (e) {
404
+ /* ignore */
405
+ }
406
+ }
407
+ }
408
+ return null
409
+ }
410
+
411
+ // ---------- 非项目目录(与 src/core/dirFilter.ts 同构) ----------
412
+
413
+ function dirNorm(p, win) {
414
+ if (win) return path.win32.resolve(p).replace(/[\\/]+$/, '').toLowerCase()
415
+ return path.posix.resolve(p).replace(/\/+$/, '')
416
+ }
417
+
418
+ function dirUnder(p, base, win) {
419
+ return p === base || p.startsWith(base + (win ? '\\' : '/'))
420
+ }
421
+
422
+ /**
423
+ * 智能体按时间自动建的工作区。WorkBuddy:<主目录>/WorkBuddy/<日期开头>(5.6.2 实测 2026-09-24-18-50-03 /
424
+ * 20260430143452 / 2026-05-16-task-1);Qoder CN:<主目录>/Documents/Qoder/<YYYY-MM-DD>/<8 位十六进制>
425
+ */
426
+ function isAutoWorkspace(p, opts) {
427
+ if (!p) return false
428
+ const o = opts || {}
429
+ const win = (o.platform || process.platform) === 'win32'
430
+ const P = win ? path.win32 : path.posix
431
+ const home = dirNorm(o.home || os.homedir(), win)
432
+ const full = dirNorm(p, win)
433
+ const name = P.basename(full)
434
+ const parent = P.dirname(full)
435
+ if (parent === dirNorm(P.join(home, 'WorkBuddy'), win) && /^\d{4}-?\d{2}-?\d{2}/.test(name)) return true
436
+ return (
437
+ P.dirname(parent) === dirNorm(P.join(home, 'Documents', 'Qoder'), win) &&
438
+ /^\d{4}-\d{2}-\d{2}$/.test(P.basename(parent)) &&
439
+ /^[0-9a-f]{8}$/i.test(name)
440
+ )
441
+ }
442
+
443
+ /** 能不能作为最近项目目录上报:主目录 / 根目录 / 临时目录及其下 / 自动工作区都不报 */
444
+ function isReportableDir(p, opts) {
445
+ if (!p) return false
446
+ const o = opts || {}
447
+ const win = (o.platform || process.platform) === 'win32'
448
+ const P = win ? path.win32 : path.posix
449
+ const full = dirNorm(p, win)
450
+ if (full === dirNorm(P.parse(P.resolve(p)).root, win)) return false
451
+ if (full === dirNorm(o.home || os.homedir(), win)) return false
452
+ const tmps = [o.tmp || os.tmpdir()]
453
+ if (!win) tmps.push('/tmp', '/private/tmp', '/var/folders', '/private/var/folders')
454
+ for (const t of tmps) if (t && dirUnder(full, dirNorm(t, win), win)) return false
455
+ return !isAutoWorkspace(p, o)
456
+ }
457
+
458
+ /** 按 registry 给的顺序逐级回落。只有 first-prompt 用到 prompt(首条指令前 30 字),其余来源不碰 prompt 原文。 */
325
459
  function resolveTitle(sources, ctx) {
326
460
  const list = sources || []
327
461
  for (const source of list) {
@@ -329,7 +463,10 @@ function resolveTitle(sources, ctx) {
329
463
  if (source === 'cc-ai-title') value = readAiTitle(ctx.transcriptPath)
330
464
  else if (source === 'codex-thread-name') value = readCodexThreadName(ctx.sessionId, ctx.codexHome)
331
465
  else if (source === 'git-branch') value = readGitBranch(ctx.cwd)
332
- else if (source === 'project') value = path.basename(ctx.cwd || '') || null
466
+ else if (source === 'first-prompt') value = ctx.firstPrompt || null
467
+ else if (source === 'qoder-session-title') value = readQoderSessionTitle(ctx.sessionId, ctx.qoderDbPaths, ctx.loadSqlite)
468
+ else if (source === 'oc-session-title') value = typeof ctx.ocSessionTitle === 'string' ? ctx.ocSessionTitle.trim() || null : null
469
+ else if (source === 'project') value = ctx.cwd && !isAutoWorkspace(ctx.cwd) ? path.basename(ctx.cwd) || null : null
333
470
  // 不认识的来源直接跳过:新来源应该先发版再进 registry
334
471
  if (value) return value
335
472
  }
@@ -949,7 +1086,8 @@ const DIRS_KEEP = 30
949
1086
 
950
1087
  /** UserPromptSubmit 时记下工作目录:{全路径: 最近使用毫秒}。🔴 全路径只留本机 */
951
1088
  function recordDir(cwd, now) {
952
- if (!cwd) return
1089
+ // 主目录 / 临时目录 / 自动工作区不记,免得把真正的项目挤出 DIRS_KEEP
1090
+ if (!cwd || !isReportableDir(cwd)) return
953
1091
  const file = path.join(deskbuddyHome(), 'dirs.json')
954
1092
  let map = {}
955
1093
  try {
@@ -1157,7 +1295,13 @@ async function main(argv) {
1157
1295
  const prev = readState(sessionId)
1158
1296
  const now = Date.now()
1159
1297
  if (event === 'UserPromptSubmit') recordDir(payload.cwd, now)
1160
- if (superseded(prev, eventAt)) return
1298
+ // 首条指令(标题来源 first-prompt):只记第一次
1299
+ const firstPrompt = (prev && prev.firstPrompt) || (event === 'UserPromptSubmit' ? cleanPrompt(payload.prompt) : '') || undefined
1300
+ if (superseded(prev, eventAt)) {
1301
+ // 被并发的更晚事件(多半是同一轮的 Stop)抢先写了:状态不覆盖,但首条指令要补上,不然标题就成了第二条指令
1302
+ if (firstPrompt && !prev.firstPrompt) writeState(Object.assign({}, prev, { firstPrompt: firstPrompt }))
1303
+ return
1304
+ }
1161
1305
  // 状态里记的进程还活着才沿用(评审):先远程 -p 跑过、后又在窗口 resume 同一会话时,
1162
1306
  // 不能拿死掉的 -p 进程的 interactive=false 挡不住窗口
1163
1307
  const prevAlive = !!(prev && prev.pid && pidExists(prev.pid))
@@ -1196,6 +1340,7 @@ async function main(argv) {
1196
1340
  at: now,
1197
1341
  },
1198
1342
  ])).slice(-RECENT_MAX),
1343
+ firstPrompt: firstPrompt,
1199
1344
  }
1200
1345
 
1201
1346
  // 进 / 出 DeskBuddy 窗口不一定伴随状态跃迁(窗口里 --resume 一个已完成的会话),要立刻报,否则设备最长 2 分钟仍被 8348 挡
@@ -1203,7 +1348,10 @@ async function main(argv) {
1203
1348
  // 跃迁要跟磁盘上的最新状态比,不能只跟开头读到的 prev 比(评审):上一轮停在 done 时 Stop 读到的 prev 是旧 done,
1204
1349
  // 并发的 UserPromptSubmit 已把 watch 写盘并上报了,Stop 若按 done→done 静默落盘,设备收到的最后一份就是 watch
1205
1350
  const latest = readState(sessionId)
1206
- if (superseded(latest, eventAt)) return
1351
+ if (superseded(latest, eventAt)) {
1352
+ if (next.firstPrompt && !latest.firstPrompt) writeState(Object.assign({}, latest, { firstPrompt: next.firstPrompt }))
1353
+ return
1354
+ }
1207
1355
  if (!windowChanged && !shouldReport(latest || prev, next, now)) {
1208
1356
  writeState(next)
1209
1357
  return
@@ -1216,12 +1364,18 @@ async function main(argv) {
1216
1364
  transcriptPath: payload.transcript_path,
1217
1365
  cwd: next.project,
1218
1366
  sessionId: sessionId,
1367
+ firstPrompt: next.firstPrompt,
1368
+ ocSessionTitle: payload.oc_session_title,
1219
1369
  }) ||
1220
1370
  next.title ||
1221
1371
  ''
1222
1372
  next.lastReportAt = now
1223
1373
  // 取标题 / 查进程期间可能已有更晚发生的事件写完了:那份更新,这次既不落盘也不上报
1224
- if (superseded(readState(sessionId), eventAt)) return
1374
+ const last = readState(sessionId)
1375
+ if (superseded(last, eventAt)) {
1376
+ if (next.firstPrompt && !last.firstPrompt) writeState(Object.assign({}, last, { firstPrompt: next.firstPrompt }))
1377
+ return
1378
+ }
1225
1379
  writeState(next)
1226
1380
 
1227
1381
  await reportIfAuthorized(tool, now)
@@ -1261,6 +1415,14 @@ module.exports = {
1261
1415
  readAliveStates,
1262
1416
  readCodexThreadName,
1263
1417
  readGitBranch,
1418
+ cleanPrompt,
1419
+ cleanTitle,
1420
+ FIRST_PROMPT_MAX,
1421
+ SESSION_TITLE_MAX,
1422
+ qoderDbPaths,
1423
+ readQoderSessionTitle,
1424
+ isAutoWorkspace,
1425
+ isReportableDir,
1264
1426
  readState,
1265
1427
  removeState,
1266
1428
  retireState,
@@ -0,0 +1,229 @@
1
+ 'use strict'
2
+ /**
3
+ * DeskBuddy 稳定入口(自动升级 spec 2026-10-01 §4.1)。
4
+ *
5
+ * 开机自启固定执行 `node ~/.deskbuddy/launcher.cjs daemon`,不再写死某一版 CLI 的路径:
6
+ * 跑哪一版由 ~/.deskbuddy/versions/current.json 决定。
7
+ *
8
+ * 🔴 只能用 Node 内置模块:它随 hook 一起被复制到 ~/.deskbuddy,旁边没有 node_modules。
9
+ * 🔴 current.json / failed.json / crashes.json 的格式与 src/core/versions.ts 共用,改一边必须改另一边。
10
+ *
11
+ * - 正常:spawn `node <cliPath> daemon`,stdio 继承,退出码透传,由系统自启负责重拉
12
+ * (Windows 的「启动」文件夹不会重拉:verifying 期间由本进程自己隔 10 秒重拉,见 main)
13
+ * - state=verifying 时子进程 5 分钟内异常退出累计 3 次 → 切回 previous、把这一版记进 failed.json、写 daemon.log
14
+ */
15
+ const fs = require('node:fs')
16
+ const os = require('node:os')
17
+ const path = require('node:path')
18
+ const { spawn } = require('node:child_process')
19
+
20
+ const CRASH_WINDOW_MS = 5 * 60 * 1000
21
+ const CRASH_LIMIT = 3
22
+ const WIN_RESTART_MS = 10 * 1000
23
+
24
+ function home(env) {
25
+ return (env || process.env).DESKBUDDY_HOME || path.join(os.homedir(), '.deskbuddy')
26
+ }
27
+
28
+ function paths(h) {
29
+ const versions = path.join(h, 'versions')
30
+ return {
31
+ versions,
32
+ current: path.join(versions, 'current.json'),
33
+ failed: path.join(versions, 'failed.json'),
34
+ crashes: path.join(versions, 'crashes.json'),
35
+ log: path.join(h, 'daemon.log'),
36
+ pid: path.join(h, 'launcher.pid'),
37
+ }
38
+ }
39
+
40
+ function readJson(p) {
41
+ try {
42
+ return JSON.parse(fs.readFileSync(p, 'utf8'))
43
+ } catch {
44
+ return null
45
+ }
46
+ }
47
+
48
+ function writeJson(p, v) {
49
+ fs.mkdirSync(path.dirname(p), { recursive: true })
50
+ const tmp = p + '.' + process.pid + '.tmp'
51
+ fs.writeFileSync(tmp, JSON.stringify(v, null, 2), 'utf8')
52
+ fs.renameSync(tmp, p)
53
+ }
54
+
55
+ function log(h, line) {
56
+ try {
57
+ fs.mkdirSync(h, { recursive: true })
58
+ fs.appendFileSync(paths(h).log, new Date().toISOString() + ' [launcher] ' + line + '\n', 'utf8')
59
+ } catch {
60
+ /* 写不了日志不能挡住启动 */
61
+ }
62
+ }
63
+
64
+ function isRef(r) {
65
+ return !!r && typeof r.version === 'string' && typeof r.cliPath === 'string' && r.cliPath !== ''
66
+ }
67
+
68
+ function readCurrent(h) {
69
+ const c = readJson(paths(h).current)
70
+ if (!isRef(c)) return null
71
+ return {
72
+ version: c.version,
73
+ cliPath: c.cliPath,
74
+ previous: isRef(c.previous) ? { version: c.previous.version, cliPath: c.previous.cliPath } : null,
75
+ state: c.state === 'verifying' ? 'verifying' : 'good',
76
+ switchedAt: typeof c.switchedAt === 'number' ? c.switchedAt : 0,
77
+ }
78
+ }
79
+
80
+ /**
81
+ * 切回 previous。markFailed=true 时把出问题的版本记进 failed.json(之后不再自动升到它)。
82
+ * 返回切过去的版本;没有可退的返回 null
83
+ */
84
+ function rollback(h, cur, why, now, markFailed) {
85
+ const p = paths(h)
86
+ if (!cur.previous || !fs.existsSync(cur.previous.cliPath)) {
87
+ log(h, '版本 ' + cur.version + ' ' + why + ',但没有可回退的上一版本,继续用它')
88
+ return null
89
+ }
90
+ writeJson(p.current, {
91
+ version: cur.previous.version,
92
+ cliPath: cur.previous.cliPath,
93
+ // 出问题的那版留作 previous:目录不会被清理,便于排查
94
+ previous: { version: cur.version, cliPath: cur.cliPath },
95
+ state: 'good',
96
+ switchedAt: now,
97
+ })
98
+ if (markFailed) {
99
+ const f = readJson(p.failed) || {}
100
+ const versions = Array.isArray(f.versions) ? f.versions.filter((v) => typeof v === 'string') : []
101
+ if (!versions.includes(cur.version)) versions.push(cur.version)
102
+ writeJson(p.failed, { versions, attempts: f.attempts && typeof f.attempts === 'object' ? f.attempts : {} })
103
+ }
104
+ try {
105
+ fs.rmSync(p.crashes, { force: true })
106
+ } catch {
107
+ /* ignore */
108
+ }
109
+ log(h, '版本 ' + cur.version + ' ' + why + ',已回滚到 ' + cur.previous.version + (markFailed ? ',之后不再自动升到 ' + cur.version : ''))
110
+ return cur.previous.version
111
+ }
112
+
113
+ /**
114
+ * 选这次要跑的那份:current;它的 cliPath 不在了 → 退回 previous(verifying 时同时记失败)。
115
+ * 返回 null = 没有可跑的
116
+ */
117
+ function pickTarget(h, now) {
118
+ let cur = readCurrent(h)
119
+ if (!cur) return null
120
+ if (!fs.existsSync(cur.cliPath)) {
121
+ // 🔴 只有 verifying(刚切过来、没验证过)才拉黑:good 版本的文件被误删 / 被清理不代表这一版有问题(评审 5)
122
+ if (rollback(h, cur, '的入口文件不存在', now, cur.state === 'verifying') === null) return null
123
+ cur = readCurrent(h)
124
+ if (!cur || !fs.existsSync(cur.cliPath)) return null
125
+ }
126
+ return cur
127
+ }
128
+
129
+ /**
130
+ * 子进程异常退出一次:verifying 期间记崩溃,5 分钟内满 3 次回滚。返回是否回滚了
131
+ */
132
+ function recordCrash(h, cur, now) {
133
+ if (cur.state !== 'verifying') return false
134
+ const p = paths(h)
135
+ const prev = readJson(p.crashes)
136
+ let times = prev && prev.version === cur.version && Array.isArray(prev.times) ? prev.times.filter((t) => typeof t === 'number') : []
137
+ times = times.filter((t) => now - t < CRASH_WINDOW_MS)
138
+ times.push(now)
139
+ writeJson(p.crashes, { version: cur.version, times })
140
+ log(h, '版本 ' + cur.version + ' 验证期内异常退出(5 分钟内第 ' + times.length + ' 次)')
141
+ if (times.length < CRASH_LIMIT) return false
142
+ return rollback(h, cur, '验证期内 5 分钟崩溃 ' + times.length + ' 次', now, true) !== null
143
+ }
144
+
145
+ /**
146
+ * 子进程环境:剔除自动切换用的 FROM_* 变量(Windows 上 update --auto 直接拉起 launcher 时会继承它们),
147
+ * 免得一路传到 daemon 起的智能体子进程里。名字与 src/daemon/autoUpdate.ts 一致
148
+ */
149
+ const SWITCH_ENV_KEYS = ['DESKBUDDY_UPDATE_FROM_VERSION', 'DESKBUDDY_UPDATE_FROM_CLI']
150
+ function childEnv(env) {
151
+ const out = Object.assign({}, env)
152
+ for (const k of SWITCH_ENV_KEYS) delete out[k]
153
+ return out
154
+ }
155
+
156
+ function runOnce(h, cur, args) {
157
+ return new Promise((resolve) => {
158
+ let stopping = false
159
+ const child = spawn(process.execPath, [cur.cliPath, ...args], { stdio: 'inherit', windowsHide: true, env: childEnv(process.env) })
160
+ const forward = (sig) => {
161
+ stopping = true
162
+ try {
163
+ child.kill(sig)
164
+ } catch {
165
+ /* 已经退了 */
166
+ }
167
+ }
168
+ const handlers = {}
169
+ for (const sig of ['SIGTERM', 'SIGINT', 'SIGHUP']) {
170
+ handlers[sig] = () => forward(sig)
171
+ process.on(sig, handlers[sig])
172
+ }
173
+ const done = (code, signal, err) => {
174
+ for (const sig of Object.keys(handlers)) process.removeListener(sig, handlers[sig])
175
+ resolve({ code, signal, stopping, err })
176
+ }
177
+ child.on('error', (e) => done(null, null, e))
178
+ child.on('exit', (code, signal) => done(code, signal, null))
179
+ })
180
+ }
181
+
182
+ async function main(argv, deps) {
183
+ const d = deps || {}
184
+ const h = home(d.env)
185
+ const platform = d.platform || process.platform
186
+ const now = d.now || (() => Date.now())
187
+ const args = argv.length > 0 ? argv : ['daemon']
188
+ const run = d.runOnce || ((cur) => runOnce(h, cur, args))
189
+ const sleep = d.sleep || ((ms) => new Promise((r) => setTimeout(r, ms)))
190
+ try {
191
+ fs.mkdirSync(h, { recursive: true })
192
+ fs.writeFileSync(paths(h).pid, String(process.pid), 'utf8')
193
+ } catch {
194
+ /* 记不上 pid:Windows 升级时停不掉本进程,最坏多一个 launcher 在等子进程 */
195
+ }
196
+ for (;;) {
197
+ const cur = pickTarget(h, now())
198
+ if (!cur) {
199
+ // 🔴 返回 0:launchd(SuccessfulExit=false)/ systemd(on-failure)都不再重拉 —— 返回非 0 会每 10 秒重拉一次刷满日志(评审 4)
200
+ log(
201
+ h,
202
+ '没有可运行的 CLI(' + paths(h).current + ' 缺失,或它和上一版本指向的文件都不在)。守护进程不再自动重启;' +
203
+ '恢复办法:在终端执行 npx -y deskbuddy@beta update'
204
+ )
205
+ return 0
206
+ }
207
+ const r = await run(cur)
208
+ const abnormal = !r.stopping && (r.err || r.signal || r.code !== 0)
209
+ if (r.err) log(h, '起不来版本 ' + cur.version + ':' + r.err.message)
210
+ if (!abnormal) return r.code == null ? 0 : r.code
211
+ const rolledBack = recordCrash(h, cur, now())
212
+ // 非 Windows:交给 launchd / systemd 按「非正常退出」重拉(它们会重新执行本 launcher,读到回滚后的 current.json)
213
+ // Windows:「启动」文件夹只在登录时跑一次,崩了没人拉 —— verifying 期间(或刚回滚)自己重拉,否则保持原来的行为直接退出
214
+ if (platform !== 'win32' || (cur.state !== 'verifying' && !rolledBack)) return r.code == null ? 1 : r.code || 1
215
+ await sleep(WIN_RESTART_MS)
216
+ }
217
+ }
218
+
219
+ module.exports = { main, childEnv, SWITCH_ENV_KEYS, pickTarget, recordCrash, rollback, readCurrent, paths, CRASH_WINDOW_MS, CRASH_LIMIT }
220
+
221
+ if (require.main === module) {
222
+ main(process.argv.slice(2)).then(
223
+ (code) => process.exit(code),
224
+ (e) => {
225
+ log(home(), 'launcher 出错:' + (e && e.stack ? e.stack : String(e)))
226
+ process.exit(1)
227
+ }
228
+ )
229
+ }
package/dist/opencode.mjs CHANGED
@@ -130,6 +130,26 @@ export function openPermissions(cfg) {
130
130
  if (build && build.permission && typeof build.permission === 'object') open(build.permission)
131
131
  }
132
132
 
133
+ /** chat.message 的 output({ message, parts })里用户自己打的字:text 类 part,跳过 synthetic(插件 / 系统补的) */
134
+ export function userText(output) {
135
+ const parts = output && Array.isArray(output.parts) ? output.parts : []
136
+ return parts
137
+ .filter((p) => p && p.type === 'text' && !p.synthetic && typeof p.text === 'string')
138
+ .map((p) => p.text)
139
+ .join('\n')
140
+ }
141
+
142
+ /** opencode 起标题前的占位名(1.18.32 isDefaultTitle:`New session - ` / `Child session - ` + ISO 时间),不当标题 */
143
+ const OC_DEFAULT_TITLE_RE = /^(New session - |Child session - )\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/
144
+
145
+ /** session.updated 的 info → 记下自动标题。子会话(subagent,带 parentID)、占位名不记 */
146
+ export function rememberTitle(titles, info) {
147
+ if (!info || typeof info.id !== 'string' || !info.id || info.parentID) return
148
+ const t = typeof info.title === 'string' ? info.title.trim() : ''
149
+ if (!t || OC_DEFAULT_TITLE_RE.test(t)) return
150
+ titles.set(info.id, t.slice(0, 100))
151
+ }
152
+
133
153
  /** 活着的子进程;opencode 退出时一并结束,不留孤儿 gate 挂着设备卡片(评审) */
134
154
  const LIVE = new Set()
135
155
  process.on('exit', () => {
@@ -183,9 +203,12 @@ export function make(opts = {}) {
183
203
 
184
204
  // ---- 状态上报:串行喂 hook.cjs,保证 PreToolUse / PostToolUse / Stop 的先后 ----
185
205
  let queue = Promise.resolve()
206
+ const titles = new Map() // sessionID → opencode 自动生成的标题(session.updated 里来)
186
207
  const report = (event, sessionID, extra) => {
187
208
  if (!sessionID) return
188
209
  const input = { hook_event_name: event, session_id: sessionID, cwd, ...extra }
210
+ const title = titles.get(sessionID)
211
+ if (title) input.oc_session_title = title
189
212
  queue = queue.then(() => runChild(node, HOOK, [TOOL], input, HOOK_KILL_MS)).catch(() => {})
190
213
  }
191
214
 
@@ -273,8 +296,9 @@ export function make(opts = {}) {
273
296
  if (runId) openPermissions(cfg)
274
297
  },
275
298
 
276
- 'chat.message': async (input) => {
277
- report('UserPromptSubmit', input && input.sessionID)
299
+ 'chat.message': async (input, output) => {
300
+ // prompt 给 hook 的 first-prompt 标题来源用(只记首条,见 hook.cjs)
301
+ report('UserPromptSubmit', input && input.sessionID, { prompt: userText(output) })
278
302
  },
279
303
 
280
304
  'tool.execute.before': async (input, output) => {
@@ -318,6 +342,9 @@ export function make(opts = {}) {
318
342
  case 'session.idle':
319
343
  report('Stop', p.sessionID)
320
344
  break
345
+ case 'session.updated':
346
+ rememberTitle(titles, p.info)
347
+ break
321
348
  case 'session.deleted':
322
349
  report('SessionEnd', (p.info && p.info.id) || p.sessionID)
323
350
  break