deskbuddy 0.2.0-beta.7 → 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.
@@ -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
+ }
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "deskbuddy",
3
- "version": "0.2.0-beta.7",
3
+ "version": "0.2.0-beta.8",
4
4
  "description": "把 Claude Code、Codex 等 AI 编码智能体每个会话窗口的状态,实时同步到桌面硬件屏幕",
5
5
  "keywords": [
6
6
  "ai",
@@ -23,7 +23,7 @@
23
23
  "README.md"
24
24
  ],
25
25
  "scripts": {
26
- "build": "tsup && cp hook/hook.cjs dist/hook.cjs && cp hook/gate.cjs dist/gate.cjs && cp hook/wake.cjs dist/wake.cjs && cp hook/opencode.mjs dist/opencode.mjs",
26
+ "build": "tsup && cp hook/hook.cjs dist/hook.cjs && cp hook/gate.cjs dist/gate.cjs && cp hook/wake.cjs dist/wake.cjs && cp hook/opencode.mjs dist/opencode.mjs && cp hook/launcher.cjs dist/launcher.cjs",
27
27
  "prepublishOnly": "npm run typecheck && npm test && npm run build",
28
28
  "test": "vitest run",
29
29
  "typecheck": "tsc --noEmit"