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/cli.js +1673 -819
- package/dist/hook.cjs +174 -12
- package/dist/launcher.cjs +229 -0
- package/dist/opencode.mjs +29 -2
- package/hook/hook.cjs +174 -12
- package/hook/launcher.cjs +229 -0
- package/hook/opencode.d.mts +2 -0
- package/hook/opencode.mjs +29 -2
- package/package.json +2 -2
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
/**
|
|
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 === '
|
|
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
|
-
|
|
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
|
-
|
|
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))
|
|
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
|
-
|
|
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
|
-
|
|
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
|