@whaletalk/dsh-codex-collab 0.1.7 → 0.1.10

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 (32) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README.md +197 -26
  3. package/codex-plugin/plugins/dsh/.codex-plugin/plugin.json +1 -1
  4. package/codex-plugin/plugins/dsh/skills/dsh-collab/SKILL.md +102 -6
  5. package/codex-plugin/plugins/dsh/skills/dsh-collab/scripts/review.mjs +91 -6
  6. package/codex-plugin/plugins/dsh/skills/dsh-collab/scripts/task.mjs +571 -44
  7. package/docs/CODEX_DSH_APPROVAL_DELEGATION_PLAN.md +130 -0
  8. package/docs/CODEX_DSH_APPROVAL_M0_EVIDENCE.md +285 -0
  9. package/docs/CODEX_DSH_DELIVERY_CONTROL_PLAN.md +81 -0
  10. package/docs/CODEX_DSH_MODEL_ROUTING_PLAN.md +75 -0
  11. package/docs/CODEX_DSH_TASK_REPLY_PLAN.md +206 -0
  12. package/docs/DSH_APPROVAL_DELEGATION.md +142 -0
  13. package/docs/DSH_DELIVERY_M0_EVIDENCE.md +69 -0
  14. package/docs/DSH_MODEL_ROUTING_M0_EVIDENCE.md +166 -0
  15. package/docs/DSH_TO_CODEX_INBOX.md +167 -0
  16. package/docs/DSH_TO_CODEX_MESSAGING_PLAN.md +140 -0
  17. package/docs/codex-desktop-attachment-evidence.md +116 -0
  18. package/harness/audit-tail.mjs +32 -0
  19. package/harness/codex-approvals.mjs +1078 -0
  20. package/harness/codex-associations.mjs +297 -0
  21. package/harness/codex-outbox.mjs +432 -0
  22. package/harness/dsh-bridge.mjs +2429 -167
  23. package/harness/fs-lock.mjs +208 -0
  24. package/harness/model-route-host.mjs +530 -0
  25. package/harness/model-routing.mjs +500 -0
  26. package/harness/session-observer.mjs +217 -0
  27. package/harness/session-target.mjs +291 -2
  28. package/harness/task-table.mjs +34 -0
  29. package/mcp/codex-desktop-probe.mjs +407 -0
  30. package/mcp/codex-inbox.mjs +271 -0
  31. package/mcp/dsh-mcp.mjs +616 -24
  32. package/package.json +4 -2
@@ -0,0 +1,208 @@
1
+ /**
2
+ * fs-lock.mjs —— 本机跨进程写锁(零依赖),供收件箱与任务关联存储复用。
3
+ *
4
+ * 互斥只靠一条:**一个共享路径**上的一次**原子独占创建**(先把完整元数据写进唯一命名的
5
+ * `pending-<token>.json`,再 `linkSync` 到锁路径;平台不支持硬链接时退化为 `wx` 独占创建 + 立即写入)。
6
+ * 共享路径只能被创建成功一次,所以同一时刻至多一个持有者。
7
+ *
8
+ * **在线竞争期间绝不自动回收**:在共享路径上"检查持有者 → 再删除/改名"无法原子化,回收者可能
9
+ * 删掉别人刚创建的新锁(ABA/TOCTOU,已被真实复现过)。因此只有三种结果:
10
+ * - 持有者活着 → 有限等待,超时抛 lock-timeout;
11
+ * - 持有者已死 → 二次确认(锁仍在、仍是同一把、pid 仍不可达)后抛 lock-stale-holder(可恢复,人工清理);
12
+ * - 元数据不可确认 → 抛 lock-unverified-holder(可恢复,人工清理)。
13
+ * 宁可阻塞等待人工处理,也不冒"两个写者"的风险。
14
+ */
15
+ import fs from 'node:fs'
16
+ import os from 'node:os'
17
+ import path from 'node:path'
18
+
19
+ export const LOCK_REASONS = {
20
+ /** 持有者活着,等不到锁(可重试)。 */
21
+ TIMEOUT: 'lock-timeout',
22
+ /** 持有者已死:不自动回收,需人工清理(可恢复错误)。 */
23
+ STALE_HOLDER: 'lock-stale-holder',
24
+ /** 锁存在但无法确认持有者:不按时间偷(可恢复错误)。 */
25
+ UNVERIFIED_HOLDER: 'lock-unverified-holder',
26
+ }
27
+
28
+ /** 观察到"无法确认持有者"的元数据后,最多再观察这么久(容忍原子创建的极小空窗),然后 fail closed。 */
29
+ const UNREADABLE_GRACE_MS = 200
30
+ /** 残留 pending 文件的清理年龄下限:先证明其记录里的 pid 已死,再删(名字唯一,不涉及共享路径)。 */
31
+ const PENDING_STALE_MS = 1000
32
+ const DEFAULT_TIMEOUT_MS = 10000
33
+
34
+ const uid = (prefix) => prefix + '-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 10)
35
+
36
+ /** 同步睡眠(不占 CPU);Node 主线程允许 Atomics.wait。 */
37
+ const sleep = (ms) => {
38
+ try {
39
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms)
40
+ } catch (e) {
41
+ const end = Date.now() + ms
42
+ while (Date.now() < end) { /* spin */ }
43
+ }
44
+ }
45
+
46
+ export const isProcessAlive = (pid) => {
47
+ if (typeof pid !== 'number' || !Number.isInteger(pid) || pid <= 0) return false
48
+ try {
49
+ process.kill(pid, 0)
50
+ return true
51
+ } catch (e) {
52
+ if (e !== null && typeof e === 'object' && e.code === 'ESRCH') return false
53
+ // EPERM 等:进程存在但不是我们能发信号的 → 视为活着(宁可等,也不抢)
54
+ return true
55
+ }
56
+ }
57
+
58
+ const lockError = (reason, message, extra) => Object.assign(new Error(message), { reason, ...(extra === undefined ? {} : extra) })
59
+
60
+ /**
61
+ * 在 `lockFile` 上创建一把跨进程写锁。
62
+ * @param {{lockFile: string, timeoutMs?: number}} options
63
+ */
64
+ export const createFileLock = (options) => {
65
+ const lockFile = path.resolve(options.lockFile)
66
+ const lockDir = path.dirname(lockFile)
67
+ const timeoutMs = typeof options.timeoutMs === 'number' && options.timeoutMs > 0 ? options.timeoutMs : DEFAULT_TIMEOUT_MS
68
+
69
+ const ensureDir = () => { fs.mkdirSync(lockDir, { recursive: true }) }
70
+
71
+ /** 读锁的持有者元数据。读不动/解析失败/没有 pid → 无法确认持有者(调用方必须 fail closed)。 */
72
+ const readLockMeta = () => {
73
+ let text
74
+ try {
75
+ text = fs.readFileSync(lockFile, 'utf8')
76
+ } catch (e) {
77
+ if (e !== null && typeof e === 'object' && e.code === 'ENOENT') return { ok: false, missing: true, why: 'lock file is gone' }
78
+ return { ok: false, missing: false, why: 'unreadable (' + e.message + ')' }
79
+ }
80
+ let value
81
+ try {
82
+ value = JSON.parse(text)
83
+ } catch (e) {
84
+ return { ok: false, missing: false, why: 'content is not valid JSON' }
85
+ }
86
+ if (value === null || typeof value !== 'object' || typeof value.pid !== 'number' || !Number.isInteger(value.pid)) {
87
+ return { ok: false, missing: false, why: 'content has no owner pid' }
88
+ }
89
+ return { ok: true, meta: value }
90
+ }
91
+
92
+ /**
93
+ * 原子独占创建:先写完整元数据到唯一命名的 pending 文件,再硬链接到锁路径。
94
+ * link 原子且独占,且"内容完整才可能可见";不支持硬链接时退化为 `wx` + 立即写入
95
+ * (极小空窗内竞争者会读到不可确认的元数据 → fail closed,只会短暂报错,绝不误删)。
96
+ */
97
+ const publishLock = (meta) => {
98
+ ensureDir()
99
+ const intentPath = path.join(lockDir, 'pending-' + meta.token + '.json')
100
+ fs.writeFileSync(intentPath, JSON.stringify(meta), 'utf8')
101
+ try {
102
+ fs.linkSync(intentPath, lockFile)
103
+ return true
104
+ } catch (e) {
105
+ if (e !== null && typeof e === 'object' && e.code === 'EEXIST') return false
106
+ try {
107
+ fs.writeFileSync(lockFile, JSON.stringify(meta), { encoding: 'utf8', flag: 'wx' })
108
+ return true
109
+ } catch (e2) {
110
+ if (e2 !== null && typeof e2 === 'object' && e2.code === 'EEXIST') return false
111
+ throw e2
112
+ }
113
+ } finally {
114
+ try { fs.unlinkSync(intentPath) } catch (e) { /* 已被清理 */ }
115
+ }
116
+ }
117
+
118
+ /** 清理残留 pending:只在能证明其记录里的 pid 已死、且已过短宽限时删(名字唯一,不碰共享路径)。 */
119
+ const sweepPending = () => {
120
+ let names
121
+ try { names = fs.readdirSync(lockDir) } catch (e) { return }
122
+ for (const name of names) {
123
+ if (!name.startsWith('pending-')) continue
124
+ const file = path.join(lockDir, name)
125
+ try {
126
+ const age = Date.now() - fs.statSync(file).mtimeMs
127
+ if (age < PENDING_STALE_MS) continue
128
+ const owner = JSON.parse(fs.readFileSync(file, 'utf8'))
129
+ if (owner !== null && typeof owner === 'object' && isProcessAlive(owner.pid)) continue
130
+ fs.unlinkSync(file)
131
+ } catch (e) { /* 读不到/已被清理:下一轮再说 */ }
132
+ }
133
+ }
134
+
135
+ const acquire = () => {
136
+ ensureDir()
137
+ const token = uid('lock')
138
+ const meta = { token, pid: process.pid, host: os.hostname(), at: Date.now() }
139
+ const deadline = Date.now() + timeoutMs
140
+ let unreadableSince
141
+
142
+ for (;;) {
143
+ sweepPending()
144
+
145
+ // 1) 唯一的进入方式:原子独占创建。
146
+ if (publishLock(meta)) return { token }
147
+
148
+ // 2) 已存在:读元数据判定,只决定"等待 / 报错",绝不删除或改名。
149
+ const info = readLockMeta()
150
+ if (info.missing === true) continue // 刚好被持有者释放:重试
151
+ if (info.ok === true) {
152
+ if (isProcessAlive(info.meta.pid)) {
153
+ if (Date.now() > deadline) {
154
+ const holder = 'pid ' + info.meta.pid
155
+ throw lockError(LOCK_REASONS.TIMEOUT, 'could not acquire the write lock within ' + timeoutMs + 'ms; it is held by ' + holder + ' (a live holder is never stolen). Retry later.', { holder, file: lockFile })
156
+ }
157
+ sleep(15)
158
+ continue
159
+ }
160
+ // 持有者看起来已死——但它可能刚刚**正常释放并退出**(读到内容之后、检查存活之前)。
161
+ // 报 stale 之前必须二次确认:锁文件仍在、还是同一把(token 相同)、pid 仍不可达。
162
+ const confirm = readLockMeta()
163
+ if (confirm.missing === true) continue
164
+ if (confirm.ok === true && confirm.meta.token === info.meta.token && !isProcessAlive(confirm.meta.pid)) {
165
+ throw lockError(
166
+ LOCK_REASONS.STALE_HOLDER,
167
+ 'the write lock is held by pid ' + info.meta.pid + ', which is no longer running. Refusing to auto-reclaim it online: removing a shared lock path is racy (a reclaimer can delete a lock someone else just created). '
168
+ + 'Stop all writers, then delete ' + lockFile + ' manually and retry.',
169
+ { file: lockFile, stalePid: info.meta.pid },
170
+ )
171
+ }
172
+ continue
173
+ }
174
+
175
+ // 3) 元数据空/损坏:给原子创建留一点观察窗(只延迟报错,绝不删除),随后 fail closed。
176
+ if (unreadableSince === undefined) unreadableSince = Date.now()
177
+ if (Date.now() - unreadableSince >= UNREADABLE_GRACE_MS) {
178
+ throw lockError(
179
+ LOCK_REASONS.UNVERIFIED_HOLDER,
180
+ 'the write lock at ' + lockFile + ' has no verifiable owner (' + info.why + '); refusing to reclaim it on a timer. '
181
+ + 'Stop the writers and inspect that file; remove it yourself only if you are sure no writer is running.',
182
+ { file: lockFile },
183
+ )
184
+ }
185
+ sleep(15)
186
+ }
187
+ }
188
+
189
+ const release = (handle) => {
190
+ // 只删自己创建的那把锁:核对 token 后删除。注意——这只是"尽力而为"的一致性检查,
191
+ // **不是互斥的基础**;互斥来自独占创建 + 从不自动回收(没有任何自动路径会删活锁)。
192
+ const info = readLockMeta()
193
+ if (info.ok === true && info.meta.token === handle.token) {
194
+ try { fs.unlinkSync(lockFile) } catch (e) { /* 已被外部清理 */ }
195
+ }
196
+ }
197
+
198
+ const withLock = (fn) => {
199
+ const handle = acquire()
200
+ try {
201
+ return fn()
202
+ } finally {
203
+ release(handle)
204
+ }
205
+ }
206
+
207
+ return { lockFile, timeoutMs, acquire, release, withLock, readLockMeta }
208
+ }