@ohmynpm/dsh-baton 0.0.0-stage → 0.2.1
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/LICENSE +21 -0
- package/README.md +159 -2
- package/README.zh-CN.md +135 -0
- package/client.js +901 -0
- package/connection.js +397 -0
- package/cordis.patch.yml +8 -0
- package/icon.svg +5 -0
- package/index.js +498 -0
- package/locale/en.json +1 -0
- package/locale/zh.json +1 -0
- package/package.json +64 -4
- package/session.js +221 -0
package/index.js
ADDED
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host half of Baton: one WebSocket to baton-server, the agent tools, and the
|
|
3
|
+
* authenticated route the panel uses. Session creation lives in session.js so
|
|
4
|
+
* the start state machine stays testable without DSH.
|
|
5
|
+
*/
|
|
6
|
+
import { randomUUID } from 'node:crypto'
|
|
7
|
+
import { access, mkdir, readFile, realpath, rename, stat, unlink, writeFile } from 'node:fs/promises'
|
|
8
|
+
import { constants } from 'node:fs'
|
|
9
|
+
import { homedir, userInfo } from 'node:os'
|
|
10
|
+
import { isAbsolute, join } from 'node:path'
|
|
11
|
+
|
|
12
|
+
import { BatonConnection, readSecret, validateServerUrl } from './connection.js'
|
|
13
|
+
import { BatonStartError, LocalState, PHASE, createStarter } from './session.js'
|
|
14
|
+
|
|
15
|
+
export const name = 'baton'
|
|
16
|
+
export const inject = ['tools', 'sessionController', 'workspaceRegistry', 'connection']
|
|
17
|
+
|
|
18
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
19
|
+
const SEND_FIELDS = ['room', 'to', 'env', 'title', 'brief']
|
|
20
|
+
const RETENTION_MS = 7 * 24 * 60 * 60 * 1000
|
|
21
|
+
|
|
22
|
+
function json(data, status = 200) {
|
|
23
|
+
return new Response(JSON.stringify(data), {
|
|
24
|
+
status,
|
|
25
|
+
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' },
|
|
26
|
+
})
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function text(value) {
|
|
30
|
+
return [{ type: 'text', text: value }]
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function loadIdentity(base) {
|
|
34
|
+
await mkdir(base, { recursive: true, mode: 0o700 })
|
|
35
|
+
const path = join(base, 'identity.json')
|
|
36
|
+
try {
|
|
37
|
+
const parsed = JSON.parse(await readFile(path, 'utf8'))
|
|
38
|
+
if (!UUID.test(parsed?.userId)) throw new Error('invalid installation identity')
|
|
39
|
+
return parsed
|
|
40
|
+
} catch (error) {
|
|
41
|
+
if (error?.code !== 'ENOENT') throw new Error(`identity file is unreadable: ${error.message}`)
|
|
42
|
+
}
|
|
43
|
+
const identity = { userId: randomUUID() }
|
|
44
|
+
await writeFile(path, JSON.stringify(identity), { mode: 0o600 })
|
|
45
|
+
return identity
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function normalizeEnvironments(entries) {
|
|
49
|
+
if (!Array.isArray(entries)) throw new Error('environments must be an array')
|
|
50
|
+
if (entries.length > 20) throw new Error('at most 20 environments are supported')
|
|
51
|
+
const keys = new Set()
|
|
52
|
+
return entries.map((entry) => {
|
|
53
|
+
if (typeof entry?.key !== 'string' || !/^[A-Za-z0-9_-]{1,64}$/.test(entry.key)) throw new Error(`invalid environment key: ${entry?.key}`)
|
|
54
|
+
if (keys.has(entry.key)) throw new Error(`duplicate environment key: ${entry.key}`)
|
|
55
|
+
keys.add(entry.key)
|
|
56
|
+
if (typeof entry.cwd !== 'string' || !isAbsolute(entry.cwd)) throw new Error(`environment ${entry.key} needs an absolute cwd`)
|
|
57
|
+
const label = typeof entry.label === 'string' && entry.label.trim() ? entry.label.trim() : entry.key
|
|
58
|
+
if (Array.from(label).length > 40) throw new Error(`environment ${entry.key} label is too long`)
|
|
59
|
+
return { key: entry.key, label, cwd: entry.cwd }
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function normalizeConfig(config) {
|
|
64
|
+
const environments = normalizeEnvironments(Array.isArray(config?.environments) ? config.environments : [])
|
|
65
|
+
const displayName = config?.displayName?.trim() || (userInfo().username || '未命名').slice(0, 40)
|
|
66
|
+
if (Array.from(displayName).length > 40) throw new Error('displayName must be 1–40 characters')
|
|
67
|
+
return {
|
|
68
|
+
serverUrl: config?.serverUrl,
|
|
69
|
+
secretEnv: config?.secretEnv ?? 'BATON_SECRET',
|
|
70
|
+
secretFile: config?.secretFile,
|
|
71
|
+
displayName,
|
|
72
|
+
environments,
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function loadEnvironments(base, seed) {
|
|
77
|
+
const path = join(base, 'environments.json')
|
|
78
|
+
try {
|
|
79
|
+
const parsed = JSON.parse(await readFile(path, 'utf8'))
|
|
80
|
+
if (parsed?.version !== 1) throw new Error('unsupported environments file')
|
|
81
|
+
return normalizeEnvironments(parsed.environments)
|
|
82
|
+
} catch (error) {
|
|
83
|
+
if (error?.code === 'ENOENT') return seed
|
|
84
|
+
throw new Error(`environments file is unreadable: ${error.message}`)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function saveEnvironments(base, environments) {
|
|
89
|
+
await mkdir(base, { recursive: true, mode: 0o700 })
|
|
90
|
+
const path = join(base, 'environments.json')
|
|
91
|
+
const temporary = `${path}.${randomUUID()}.tmp`
|
|
92
|
+
try {
|
|
93
|
+
await writeFile(temporary, JSON.stringify({ version: 1, environments }, null, 2), { mode: 0o600, flag: 'wx' })
|
|
94
|
+
await rename(temporary, path)
|
|
95
|
+
} finally {
|
|
96
|
+
await unlink(temporary).catch(error => { if (error.code !== 'ENOENT') throw error })
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function advertisedEnvironments(environments) {
|
|
101
|
+
return environments.map(({ key, label }) => ({ key, label }))
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export async function apply(ctx, config) {
|
|
105
|
+
const cfg = normalizeConfig(config)
|
|
106
|
+
const base = process.env.BATON_HOME ?? join(homedir(), '.dsh', 'baton')
|
|
107
|
+
const identity = await loadIdentity(base)
|
|
108
|
+
const state = new LocalState(join(base, 'state.json'))
|
|
109
|
+
await state.load()
|
|
110
|
+
|
|
111
|
+
let environments = await loadEnvironments(base, cfg.environments)
|
|
112
|
+
let snapshot = { rooms: [], tasks: [] }
|
|
113
|
+
let status = { state: cfg.serverUrl ? 'idle' : 'unconfigured' }
|
|
114
|
+
let connection
|
|
115
|
+
let disposed = false
|
|
116
|
+
let preparingSend = Promise.resolve()
|
|
117
|
+
let savingEnvironments = Promise.resolve()
|
|
118
|
+
const details = new Map()
|
|
119
|
+
|
|
120
|
+
const adapter = {
|
|
121
|
+
workspaces: new Map(),
|
|
122
|
+
async ensureWorkspace(cwd) {
|
|
123
|
+
const workspace = await ctx.workspaceRegistry.create(cwd, 'Baton')
|
|
124
|
+
this.workspaces.set(workspace.id, workspace)
|
|
125
|
+
return workspace.id
|
|
126
|
+
},
|
|
127
|
+
async create({ workspaceId }) {
|
|
128
|
+
const created = await ctx.sessionController.create({ workspaceId })
|
|
129
|
+
const workspace = this.workspaces.get(workspaceId)
|
|
130
|
+
if (workspace && !workspace.sessionIds.includes(created.sessionId)) await workspace.attachSession(created.sessionId)
|
|
131
|
+
return { sessionId: created.sessionId }
|
|
132
|
+
},
|
|
133
|
+
rename({ sessionId, title }) {
|
|
134
|
+
return ctx.sessionController.rename({ sessionId, title })
|
|
135
|
+
},
|
|
136
|
+
prompt({ sessionId, text: content }) {
|
|
137
|
+
return ctx.sessionController.prompt({
|
|
138
|
+
requestId: randomUUID(),
|
|
139
|
+
sessionId,
|
|
140
|
+
mode: 'queue',
|
|
141
|
+
content: [{ type: 'text', text: content }],
|
|
142
|
+
}, new AbortController().signal)
|
|
143
|
+
},
|
|
144
|
+
}
|
|
145
|
+
const starter = createStarter({ adapter, state })
|
|
146
|
+
|
|
147
|
+
const normalizeCode = (value) => (typeof value === 'string' ? value.trim().toUpperCase() : '')
|
|
148
|
+
|
|
149
|
+
function environmentCwd(key) {
|
|
150
|
+
const entry = environments.find((item) => item.key === key)
|
|
151
|
+
if (!entry) return undefined
|
|
152
|
+
return entry.cwd
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function canonicalDirectory(path) {
|
|
156
|
+
if (typeof path !== 'string' || !isAbsolute(path)) throw new BatonStartError('invalid-cwd', '请为该环境配置绝对路径。')
|
|
157
|
+
try {
|
|
158
|
+
const canonical = await realpath(path)
|
|
159
|
+
if (!(await stat(canonical)).isDirectory()) throw new Error('not a directory')
|
|
160
|
+
await access(canonical, constants.R_OK | constants.X_OK)
|
|
161
|
+
return canonical
|
|
162
|
+
} catch { throw new BatonStartError('invalid-cwd', '执行目录不存在或不可访问,请先修正环境配置。') }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function requireConnection() {
|
|
166
|
+
if (!connection) throw new BatonStartError('unconfigured', 'Baton 尚未配置 serverUrl')
|
|
167
|
+
if (!connection.authenticated) throw new BatonStartError('connection-closed', '接力服务暂未连接,请连接后重试。')
|
|
168
|
+
return connection
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function requireRecipient(task) {
|
|
172
|
+
if (task.to.userId !== identity.userId) throw new BatonStartError('forbidden', '只有收件人可以处理这条任务。')
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function isActiveTask(task) {
|
|
176
|
+
return ((task.to.userId === identity.userId ? task.toVisibility : task.fromVisibility) ?? 'active') === 'active'
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function sendTask(input) {
|
|
180
|
+
const client = requireConnection()
|
|
181
|
+
const payload = Object.fromEntries(SEND_FIELDS.map(field => [field, input[field]]))
|
|
182
|
+
payload.room = normalizeCode(payload.room)
|
|
183
|
+
if (!UUID.test(payload.to) || !/^[A-Za-z0-9_-]{1,64}$/.test(payload.env ?? '') || typeof payload.title !== 'string' || Array.from(payload.title).length < 1 || Array.from(payload.title).length > 120 || typeof payload.brief !== 'string' || !payload.brief || Buffer.byteLength(payload.brief) > 8192) {
|
|
184
|
+
throw new BatonStartError('invalid_request', '请检查收件人、环境、标题及任务说明。')
|
|
185
|
+
}
|
|
186
|
+
if (input.taskId !== undefined && !UUID.test(input.taskId)) throw new BatonStartError('invalid_request', '任务标识无效。')
|
|
187
|
+
let request
|
|
188
|
+
const preparation = preparingSend.then(async () => {
|
|
189
|
+
const original = input.taskId ? state.data.sends[input.taskId] : Object.values(state.data.sends).find(item => item.status === 'unconfirmed' && item.expiresAt > Date.now() && !snapshot.tasks.some(task => task.taskId === item.taskId && task.from.userId === identity.userId) && SEND_FIELDS.every(field => item[field] === payload[field]))
|
|
190
|
+
if (original && !SEND_FIELDS.every(field => original[field] === payload[field])) throw new BatonStartError('conflict', '请先确认原投递结果,再创建新的任务。')
|
|
191
|
+
if (original && original.expiresAt <= Date.now()) throw new BatonStartError('not_found', '这次投递已过期,请新建任务。')
|
|
192
|
+
request = original ?? { ...payload, taskId: input.taskId ?? randomUUID(), createdAt: Date.now(), expiresAt: Date.now() + RETENTION_MS, status: 'unconfirmed' }
|
|
193
|
+
await state.update(data => ({ ...data, sends: { ...data.sends, [request.taskId]: request } }))
|
|
194
|
+
})
|
|
195
|
+
preparingSend = preparation.catch(() => {})
|
|
196
|
+
await preparation
|
|
197
|
+
let result
|
|
198
|
+
try {
|
|
199
|
+
result = await client.taskSend(Object.fromEntries(['taskId', ...SEND_FIELDS].map(field => [field, request[field]])))
|
|
200
|
+
} catch (error) {
|
|
201
|
+
error.taskId = request.taskId
|
|
202
|
+
// A later rejection cannot disprove an earlier commit whose response was lost.
|
|
203
|
+
throw error
|
|
204
|
+
}
|
|
205
|
+
try {
|
|
206
|
+
await state.update(data => ({ ...data, sends: { ...data.sends, [request.taskId]: { ...request, status: 'saved' } } }))
|
|
207
|
+
} catch {
|
|
208
|
+
const error = new BatonStartError('internal', '任务已送出,但本地确认保存失败。重试会使用同一个任务标识。')
|
|
209
|
+
error.taskId = request.taskId
|
|
210
|
+
throw error
|
|
211
|
+
}
|
|
212
|
+
return result
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function localRecords() {
|
|
216
|
+
const records = { ...state.data.tasks }
|
|
217
|
+
for (const [id, record] of Object.entries(records)) {
|
|
218
|
+
if ([PHASE.creating, PHASE.submitting].includes(record.phase) && !starter.isActive(id)) records[id] = { ...record, phase: PHASE.needsReview, error: '本地记录保存失败,请先核对会话和消息。' }
|
|
219
|
+
}
|
|
220
|
+
for (const task of snapshot.tasks) {
|
|
221
|
+
if (task.to.userId === identity.userId && task.status === 'accepted' && !records[task.taskId]) {
|
|
222
|
+
records[task.taskId] = { taskId: task.taskId, phase: PHASE.needsReview, error: '本机没有这次启动的记录,请先在 DSH 中核对后自行处理。' }
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return records
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function panelSnapshot() {
|
|
229
|
+
return {
|
|
230
|
+
ok: true, userId: identity.userId, displayName: cfg.displayName, connection: status,
|
|
231
|
+
rooms: snapshot.rooms, tasks: snapshot.tasks, local: localRecords(),
|
|
232
|
+
outbox: Object.values(state.data.sends).filter(item => item.status === 'unconfirmed' && item.expiresAt > Date.now() && !snapshot.tasks.some(task => task.taskId === item.taskId)),
|
|
233
|
+
environments,
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async function connect() {
|
|
238
|
+
if (!cfg.serverUrl || connection) return
|
|
239
|
+
const { secret } = await readSecret({ envName: cfg.secretEnv, filePath: cfg.secretFile })
|
|
240
|
+
if (disposed) return
|
|
241
|
+
validateServerUrl(cfg.serverUrl)
|
|
242
|
+
connection = new BatonConnection({
|
|
243
|
+
serverUrl: cfg.serverUrl,
|
|
244
|
+
secret,
|
|
245
|
+
userId: identity.userId,
|
|
246
|
+
displayName: cfg.displayName,
|
|
247
|
+
environments: advertisedEnvironments(environments),
|
|
248
|
+
onSnapshot: (next) => {
|
|
249
|
+
snapshot = next
|
|
250
|
+
const active = new Set(next.tasks.map(task => task.taskId))
|
|
251
|
+
for (const id of details.keys()) if (!active.has(id)) details.delete(id)
|
|
252
|
+
const confirmed = new Set(next.tasks.filter(task => task.from.userId === identity.userId).map(task => task.taskId))
|
|
253
|
+
if (Object.values(state.data.sends).some(item => item.status === 'unconfirmed' && confirmed.has(item.taskId))) {
|
|
254
|
+
preparingSend = preparingSend.then(() => state.update(data => ({
|
|
255
|
+
...data,
|
|
256
|
+
sends: Object.fromEntries(Object.entries(data.sends).map(([id, item]) => [id, confirmed.has(id) ? { ...item, status: 'saved' } : item])),
|
|
257
|
+
}))).catch(() => {}) // The snapshot still prevents reuse if the checkpoint fails.
|
|
258
|
+
}
|
|
259
|
+
return preparingSend
|
|
260
|
+
},
|
|
261
|
+
onStatus: (next) => { status = next },
|
|
262
|
+
})
|
|
263
|
+
connection.start()
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
ctx.effect(() => {
|
|
267
|
+
connect().catch((error) => { status = { state: 'fatal', detail: error.message } })
|
|
268
|
+
return () => { disposed = true; connection?.stop() }
|
|
269
|
+
}, 'baton: connection')
|
|
270
|
+
|
|
271
|
+
function toolError(error) {
|
|
272
|
+
return JSON.stringify({ ok: false, code: error.code ?? 'internal', ...(error.taskId ? { taskId: error.taskId } : {}), error: '接力操作未完成,请在面板检查连接或投递状态;重试请复用 taskId。' })
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
ctx.effect(() => ctx.tools.register({
|
|
276
|
+
name: 'team_rooms',
|
|
277
|
+
description: 'List Baton rooms this install belongs to. Returns room codes, owner ids, member ids, and online flags; names stay in the panel.',
|
|
278
|
+
parameters: {},
|
|
279
|
+
output: { schema: { type: 'string' }, render: (_args, value) => text(value) },
|
|
280
|
+
async execute() {
|
|
281
|
+
try {
|
|
282
|
+
await requireConnection()
|
|
283
|
+
const rooms = snapshot.rooms.map((room) => ({
|
|
284
|
+
code: room.code,
|
|
285
|
+
ownerId: room.ownerId,
|
|
286
|
+
members: room.members.map((member) => ({ userId: member.userId, online: Boolean(member.online) })),
|
|
287
|
+
}))
|
|
288
|
+
return JSON.stringify({ ok: true, rooms }, null, 2)
|
|
289
|
+
} catch (error) {
|
|
290
|
+
return toolError(error)
|
|
291
|
+
}
|
|
292
|
+
},
|
|
293
|
+
}), 'baton: rooms tool')
|
|
294
|
+
|
|
295
|
+
ctx.effect(() => ctx.tools.register({
|
|
296
|
+
name: 'team_task_send',
|
|
297
|
+
description: 'Send one Baton task to one member of a room. Use a userId from team_rooms; the panel selects people by name. Returns only taskId and status.',
|
|
298
|
+
parameters: {
|
|
299
|
+
taskId: { type: 'string', description: 'For retrying an uncertain send, reuse the taskId returned with the error; omit for a new task.' },
|
|
300
|
+
room: { type: 'string', required: true, description: 'Room code.' },
|
|
301
|
+
to: { type: 'string', required: true, description: 'Recipient userId.' },
|
|
302
|
+
env: { type: 'string', required: true, description: 'Environment key as configured by the recipient.' },
|
|
303
|
+
title: { type: 'string', required: true, description: 'Short task title, at most 120 characters.' },
|
|
304
|
+
brief: { type: 'string', required: true, description: 'Task brief, at most 8 KiB UTF-8.' },
|
|
305
|
+
},
|
|
306
|
+
output: { schema: { type: 'string' }, render: (_args, value) => text(value) },
|
|
307
|
+
async execute(args) {
|
|
308
|
+
try {
|
|
309
|
+
const result = await sendTask(args)
|
|
310
|
+
return JSON.stringify({ ok: true, taskId: result.task.taskId, status: result.task.status }, null, 2)
|
|
311
|
+
} catch (error) {
|
|
312
|
+
return toolError(error)
|
|
313
|
+
}
|
|
314
|
+
},
|
|
315
|
+
}), 'baton: send tool')
|
|
316
|
+
|
|
317
|
+
ctx.effect(() => ctx.tools.register({
|
|
318
|
+
name: 'team_tasks',
|
|
319
|
+
description: 'List Baton tasks related to this install with ids and status only. Remote titles, briefs, display names, and room names never enter the current agent context.',
|
|
320
|
+
parameters: {},
|
|
321
|
+
output: { schema: { type: 'string' }, render: (_args, value) => text(value) },
|
|
322
|
+
async execute() {
|
|
323
|
+
try {
|
|
324
|
+
await requireConnection()
|
|
325
|
+
const tasks = snapshot.tasks.filter(isActiveTask).map((task) => ({
|
|
326
|
+
taskId: task.taskId,
|
|
327
|
+
status: task.status,
|
|
328
|
+
fromUserId: task.from.userId,
|
|
329
|
+
direction: task.to.userId === identity.userId ? 'incoming' : 'outgoing',
|
|
330
|
+
}))
|
|
331
|
+
return JSON.stringify({ ok: true, tasks }, null, 2)
|
|
332
|
+
} catch (error) {
|
|
333
|
+
return toolError(error)
|
|
334
|
+
}
|
|
335
|
+
},
|
|
336
|
+
}), 'baton: tasks tool')
|
|
337
|
+
|
|
338
|
+
ctx.effect(() => ctx.connection.fetch.register({
|
|
339
|
+
path: '/api/baton',
|
|
340
|
+
methods: ['POST'],
|
|
341
|
+
requestBody: 'buffered',
|
|
342
|
+
async fetch(request) {
|
|
343
|
+
try {
|
|
344
|
+
const body = await request.json()
|
|
345
|
+
const action = body?.action
|
|
346
|
+
if (action === 'snapshot') return json(panelSnapshot())
|
|
347
|
+
if (action === 'session') {
|
|
348
|
+
const sessionId = typeof body.sessionId === 'string' ? body.sessionId : ''
|
|
349
|
+
if (!sessionId) return json({ ok: false, error: 'sessionId is required' }, 400)
|
|
350
|
+
const persistence = ctx.get('sessionPersistence')
|
|
351
|
+
const stored = persistence ? await persistence.stat(sessionId) : undefined
|
|
352
|
+
let exists = Boolean(stored)
|
|
353
|
+
if (exists) {
|
|
354
|
+
try {
|
|
355
|
+
const list = await ctx.sessionController.list({}, new AbortController().signal)
|
|
356
|
+
exists = list.items.some(item => item.sessionId === sessionId)
|
|
357
|
+
} catch {}
|
|
358
|
+
}
|
|
359
|
+
return json({ ok: true, exists })
|
|
360
|
+
}
|
|
361
|
+
if (action === 'counts') {
|
|
362
|
+
const records = localRecords()
|
|
363
|
+
const mine = snapshot.tasks.filter(task => task.to.userId === identity.userId && isActiveTask(task))
|
|
364
|
+
const attention = mine.filter(task => task.status === 'pending' || (task.status === 'accepted' && ['unsubmitted', 'needs-review'].includes(records[task.taskId]?.phase ?? 'needs-review'))).length
|
|
365
|
+
return json({ ok: true, attention, pending: mine.filter(task => task.status === 'pending').length })
|
|
366
|
+
}
|
|
367
|
+
if (action === 'environments.list') {
|
|
368
|
+
return json({ ok: true, environments })
|
|
369
|
+
}
|
|
370
|
+
if (action === 'environments.save') {
|
|
371
|
+
const saving = savingEnvironments.then(async () => {
|
|
372
|
+
try {
|
|
373
|
+
const next = normalizeEnvironments(body.environments)
|
|
374
|
+
const canonical = []
|
|
375
|
+
for (const entry of next) canonical.push({ ...entry, cwd: await canonicalDirectory(entry.cwd) })
|
|
376
|
+
await saveEnvironments(base, canonical)
|
|
377
|
+
environments = canonical
|
|
378
|
+
} catch (error) {
|
|
379
|
+
return json({ ok: false, error: error.message }, 400)
|
|
380
|
+
}
|
|
381
|
+
try {
|
|
382
|
+
const result = await connection?.setEnvironments(advertisedEnvironments(environments))
|
|
383
|
+
return json({ ok: true, environments, published: result?.published === true })
|
|
384
|
+
} catch (error) {
|
|
385
|
+
return json({ ok: false, code: 'environment-publish-failed', saved: true, environments, error: `环境已保存到本机,但同步失败:${error.message}` }, 502)
|
|
386
|
+
}
|
|
387
|
+
})
|
|
388
|
+
savingEnvironments = saving.catch(() => {})
|
|
389
|
+
return saving
|
|
390
|
+
}
|
|
391
|
+
const client = requireConnection()
|
|
392
|
+
if (['archive', 'restore', 'delete'].includes(action)) {
|
|
393
|
+
if (action === 'delete' && body.confirmed !== true) throw new BatonStartError('invalid_request', '请确认永久删除这条任务;已有 DSH 会话会保留。')
|
|
394
|
+
if (!UUID.test(body.taskId)) throw new BatonStartError('invalid_request', '任务标识无效。')
|
|
395
|
+
const result = await ({ archive: () => client.taskArchive(body.taskId), restore: () => client.taskRestore(body.taskId), delete: () => client.taskDelete(body.taskId) })[action]()
|
|
396
|
+
await client.sync()
|
|
397
|
+
return json({ ok: true, ...result })
|
|
398
|
+
}
|
|
399
|
+
if (action === 'create') return json({ ok: true, ...(await client.roomCreate(body.name)) })
|
|
400
|
+
if (action === 'join') return json({ ok: true, ...(await client.roomJoin(normalizeCode(body.code))) })
|
|
401
|
+
if (action === 'leave') return json({ ok: true, ...(await client.roomLeave(normalizeCode(body.code))) })
|
|
402
|
+
if (action === 'disband') return json({ ok: true, ...(await client.roomDisband(normalizeCode(body.code))) })
|
|
403
|
+
if (action === 'send') {
|
|
404
|
+
const result = await sendTask(body)
|
|
405
|
+
return json({ ok: true, task: result.task })
|
|
406
|
+
}
|
|
407
|
+
if (action === 'retry-send') {
|
|
408
|
+
const saved = state.data.sends[body.taskId]
|
|
409
|
+
if (!saved) throw new BatonStartError('not_found', '没有找到这次投递记录。')
|
|
410
|
+
return json({ ok: true, ...(await sendTask(saved)) })
|
|
411
|
+
}
|
|
412
|
+
if (action === 'detail') {
|
|
413
|
+
const task = snapshot.tasks.find(item => item.taskId === body.taskId)
|
|
414
|
+
let result = task && details.has(body.taskId) && task.expiresAt > Date.now() ? { task, brief: details.get(body.taskId) } : await client.taskGet(body.taskId)
|
|
415
|
+
details.set(body.taskId, result.brief)
|
|
416
|
+
let record = localRecords()[body.taskId]
|
|
417
|
+
let cwd = null
|
|
418
|
+
let cwdError = null
|
|
419
|
+
if (result.task.to.userId === identity.userId) {
|
|
420
|
+
try { cwd = await canonicalDirectory(record?.cwd && result.task.status === 'accepted' ? record.cwd : environmentCwd(result.task.env)) } catch (error) {
|
|
421
|
+
cwdError = error.message
|
|
422
|
+
if (result.task.status === 'accepted' && record?.phase === PHASE.unsubmitted) {
|
|
423
|
+
await state.update(data => {
|
|
424
|
+
const current = data.tasks[body.taskId]
|
|
425
|
+
// A slow detail request must never overwrite an active start.
|
|
426
|
+
if (current?.phase !== PHASE.unsubmitted || starter.isActive(body.taskId)) return data
|
|
427
|
+
return { ...data, tasks: { ...data.tasks, [body.taskId]: { ...current, phase: PHASE.needsReview, error: '原执行目录无法确认,请先在 DSH 核对并自行处理。' } } }
|
|
428
|
+
})
|
|
429
|
+
record = localRecords()[body.taskId]
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
return json({ ok: true, task: result.task, brief: result.brief, local: record, cwd, cwdError })
|
|
434
|
+
}
|
|
435
|
+
if (action === 'decline') {
|
|
436
|
+
const result = await client.taskRespond(body.taskId, 'declined')
|
|
437
|
+
return json({ ok: true, task: result.task })
|
|
438
|
+
}
|
|
439
|
+
if (action === 'accept') {
|
|
440
|
+
const result = await client.taskGet(body.taskId)
|
|
441
|
+
const task = result.task
|
|
442
|
+
requireRecipient(task)
|
|
443
|
+
if (task.status === 'declined') throw new BatonStartError('conflict', '这条任务已经拒绝。')
|
|
444
|
+
if (task.status === 'accepted' && !state.get(task.taskId)) throw new BatonStartError('needs-review', '任务已接受但缺少本地记录,请先核对。')
|
|
445
|
+
const cwd = await canonicalDirectory(environmentCwd(task.env))
|
|
446
|
+
if (body.cwd !== cwd) throw new BatonStartError('cwd-changed', '执行目录已变化,请重新查看并确认目录后再接受。')
|
|
447
|
+
const record = await starter.start({
|
|
448
|
+
taskId: task.taskId,
|
|
449
|
+
room: task.room,
|
|
450
|
+
title: task.title,
|
|
451
|
+
brief: result.brief,
|
|
452
|
+
cwd,
|
|
453
|
+
respond: () => client.taskRespond(task.taskId, 'accepted'),
|
|
454
|
+
})
|
|
455
|
+
return json({ ok: true, record, sessionId: record.sessionId })
|
|
456
|
+
}
|
|
457
|
+
if (action === 'resume') {
|
|
458
|
+
const record = state.get(body.taskId)
|
|
459
|
+
if (!record) return json({ ok: false, error: 'no local record' }, 404)
|
|
460
|
+
const result = await client.taskGet(record.taskId)
|
|
461
|
+
requireRecipient(result.task)
|
|
462
|
+
if (result.task.status !== 'accepted') throw new BatonStartError('conflict', '任务尚未被接受。')
|
|
463
|
+
const resumed = await starter.resume({
|
|
464
|
+
taskId: record.taskId,
|
|
465
|
+
brief: result.brief,
|
|
466
|
+
verify: async existing => {
|
|
467
|
+
let actual
|
|
468
|
+
try { actual = await canonicalDirectory(existing.cwd) } catch (error) {
|
|
469
|
+
if (error.code !== 'invalid-cwd') throw error
|
|
470
|
+
}
|
|
471
|
+
if (!actual || actual !== existing.cwd) {
|
|
472
|
+
await state.put({ ...existing, phase: PHASE.needsReview, error: '原执行目录无法确认,请先在 DSH 核对并自行处理。' })
|
|
473
|
+
throw new BatonStartError('needs-review', '原执行目录无法确认,请先在 DSH 核对并自行处理。')
|
|
474
|
+
}
|
|
475
|
+
if (body.cwd !== actual) throw new BatonStartError('cwd-changed', '请先查看并确认已有会话的执行目录。')
|
|
476
|
+
},
|
|
477
|
+
})
|
|
478
|
+
return json({ ok: true, record: resumed })
|
|
479
|
+
}
|
|
480
|
+
if (action === 'handled') {
|
|
481
|
+
if (body.confirmed !== true) throw new BatonStartError('invalid_request', '请确认已经核对并自行处理这条任务。')
|
|
482
|
+
const result = await client.taskGet(body.taskId)
|
|
483
|
+
requireRecipient(result.task)
|
|
484
|
+
if (result.task.status !== 'accepted') throw new BatonStartError('conflict', '任务尚未被接受。')
|
|
485
|
+
if (!state.get(body.taskId)) await state.put({ taskId: body.taskId, phase: PHASE.needsReview })
|
|
486
|
+
return json({ ok: true, record: await starter.markHandled(body.taskId) })
|
|
487
|
+
}
|
|
488
|
+
return json({ ok: false, error: 'unknown action' }, 400)
|
|
489
|
+
} catch (error) {
|
|
490
|
+
const code = error.code ?? 'internal'
|
|
491
|
+
const statusCode = ['connection-closed', 'unconfigured'].includes(code) ? 503
|
|
492
|
+
: ['cwd-changed', 'conflict', 'needs-review', 'start-in-progress', 'already-submitted', 'not-resumable', 'not-reviewable'].includes(code) ? 409
|
|
493
|
+
: code === 'not_found' ? 404 : code === 'forbidden' ? 403 : ['invalid_request', 'invalid-cwd'].includes(code) ? 400 : 500
|
|
494
|
+
return json({ ok: false, code, error: error.message, ...(error.taskId ? { taskId: error.taskId } : {}) }, statusCode)
|
|
495
|
+
}
|
|
496
|
+
},
|
|
497
|
+
}), 'baton: panel route')
|
|
498
|
+
}
|
package/locale/en.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"meta": {"title": "Baton relay", "description": "Receive a teammate's relay task in DSH, then start a Session in a local directory after explicit approval."}}
|
package/locale/zh.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"meta": {"title": "Baton 接力", "description": "在 DSH 里接收队友的接力任务,人工确认后在本机目录启动会话。"}}
|
package/package.json
CHANGED
|
@@ -1,6 +1,66 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ohmynpm/dsh-baton",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.1",
|
|
4
|
+
"private": false,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"test": "node --test",
|
|
8
|
+
"test:integration": "node dev/integration.mjs",
|
|
9
|
+
"test:flow": "node dev/full-flow.mjs",
|
|
10
|
+
"test:client": "node dev/client-regression.mjs",
|
|
11
|
+
"test:tasks": "node dev/task-management-regression.mjs",
|
|
12
|
+
"local:setup": "node ../scripts/local.mjs setup",
|
|
13
|
+
"local:start": "node ../scripts/local.mjs start",
|
|
14
|
+
"local:check": "node ../scripts/local.mjs check",
|
|
15
|
+
"preview": "node dev/server.mjs"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"esbuild": "^0.25.0",
|
|
19
|
+
"react": "^19.2.0",
|
|
20
|
+
"react-dom": "^19.2.0"
|
|
21
|
+
},
|
|
22
|
+
"exports": {
|
|
23
|
+
".": "./index.js",
|
|
24
|
+
"./client": "./client.js",
|
|
25
|
+
"./locale/*.json": "./locale/*.json",
|
|
26
|
+
"./icon": "./icon.svg"
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"index.js",
|
|
30
|
+
"client.js",
|
|
31
|
+
"connection.js",
|
|
32
|
+
"session.js",
|
|
33
|
+
"locale/*.json",
|
|
34
|
+
"icon.svg",
|
|
35
|
+
"cordis.patch.yml",
|
|
36
|
+
"README.md",
|
|
37
|
+
"README.zh-CN.md",
|
|
38
|
+
"LICENSE"
|
|
39
|
+
],
|
|
40
|
+
"dsh": {
|
|
41
|
+
"bundle": {
|
|
42
|
+
"patch": "./cordis.patch.yml"
|
|
43
|
+
},
|
|
44
|
+
"client": {
|
|
45
|
+
"platform": "web",
|
|
46
|
+
"immediately": true,
|
|
47
|
+
"inject": [
|
|
48
|
+
"@deepseek-ai/dsh-client-ui-conversation",
|
|
49
|
+
"@deepseek-ai/dsh-client-ui-sidebar"
|
|
50
|
+
]
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
"license": "MIT",
|
|
54
|
+
"author": "Baton contributors",
|
|
55
|
+
"repository": {
|
|
56
|
+
"type": "git",
|
|
57
|
+
"url": "https://github.com/Of-qinhai/dsh-team-baton-client.git"
|
|
58
|
+
},
|
|
59
|
+
"homepage": "https://github.com/Of-qinhai/dsh-team-baton-client#readme",
|
|
60
|
+
"bugs": {
|
|
61
|
+
"url": "https://github.com/Of-qinhai/dsh-team-baton-client/issues"
|
|
62
|
+
},
|
|
63
|
+
"publishConfig": {
|
|
64
|
+
"access": "public"
|
|
65
|
+
}
|
|
66
|
+
}
|