@dsh-fly/dsh-plugin-fly-smart 0.0.0-stage → 0.2.2

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/index.js ADDED
@@ -0,0 +1,847 @@
1
+ /**
2
+ * DSH Fly smart workbench — host half.
3
+ *
4
+ * An ordinary Cordis row that claims the Web carrier prefix `/fly/smart`. It
5
+ * serves the smart task tree as JSON **and owns the route-configuration surface**:
6
+ * the page inside the right drawer is the product entry for `smart.brainRoute` /
7
+ * `smart.handRoute`, so the user never has to find the upstream Settings form
8
+ * (which only projects host-plane rows anyway — see `docs/smart-mode-design.md`).
9
+ *
10
+ * The state machine still lives in the agent-plane package `dsh-plugin-fly-smart`;
11
+ * duplicating it here would give the product two truths about what phase a task is
12
+ * in. What this row owns is the *configuration* interface, because it is the one
13
+ * that can: it has the HTTP surface the browser half talks to.
14
+ *
15
+ * Only `node:` builtins plus this package's own modules are imported, so the
16
+ * Loader can import this from a profile's own `node_modules` with no build step.
17
+ *
18
+ * Routes:
19
+ *
20
+ * GET /fly/smart → `{ plugin, path, root, exists, routes, routeConfig, endpoints }`
21
+ * GET /fly/smart/tasks → `{ root, tasks, legacyCount, unknownWorkspace? }`
22
+ * GET /fly/smart/task?id=<id> → `{ root, state, artifacts, consults, … }`
23
+ * GET /fly/smart/routes → `{ path, default, session, effective, … }`
24
+ * POST /fly/smart/routes → write one hand route; `{ changed, … }`
25
+ *
26
+ * Status contract, checked in this order so the client can tell the cases apart:
27
+ *
28
+ * 404 unknown endpoint — before anything else, so a typo never looks like a refusal
29
+ * 405 wrong method, with the derived `allow` header
30
+ * 403 cross-site write (CSRF) — separate from 415 so a security refusal is never
31
+ * reported as a content-type problem
32
+ * 415 a write body that is not JSON
33
+ * 413 a write body over 4096 bytes (connection stays reusable)
34
+ * 400 malformed route / unknown key / impossible session id / too many new sessions
35
+ * 503 the root directory does not exist, or this composition cannot write routes.
36
+ * Never an empty 200: "no data" and "no capability" must not look alike.
37
+ * 409 the mirror lock is held by another writer
38
+ * 404 the root exists but that task does not
39
+ *
40
+ * `unknownWorkspace` is the one soft case: the root exists, but this build could
41
+ * not learn which session's workspace is asking (`acceptance.md` M3/M5). It is a
42
+ * 200 with an empty list plus the host's own sentence — a browser logs every
43
+ * non-2xx fetch as a console error, and a missing session id is not a fault.
44
+ *
45
+ * @module dsh-plugin-fly-smart-panel
46
+ */
47
+
48
+ import { existsSync, readdirSync, readFileSync } from 'node:fs'
49
+ import { homedir } from 'node:os'
50
+ import { isAbsolute, join, resolve } from 'node:path'
51
+ import { fileURLToPath } from 'node:url'
52
+ import { BUNDLED_SKILL_RANK } from '@deepseek-ai/dsh-skill'
53
+ import z from '@deepseek-ai/schemastery'
54
+ import { ID_PATTERN, isSafeTaskId, readIndex, readTask } from './panel/reader.js'
55
+ import {
56
+ LockBusy,
57
+ MAX_BODY_BYTES,
58
+ MAX_SESSIONS,
59
+ MIRROR_PATH_ENV,
60
+ MIRROR_VERSION,
61
+ ROUTE_MIRROR_FILE,
62
+ formatRoute,
63
+ isValidSessionId,
64
+ mergeRowRoutes,
65
+ mirrorLockPath,
66
+ parseRouteString,
67
+ readMirrorDocument,
68
+ resolveHand,
69
+ resolveReview,
70
+ resolveMirrorPath,
71
+ updateMirror,
72
+ } from './panel/routes.mjs'
73
+
74
+ export { MIRROR_PATH_ENV, MIRROR_VERSION, ROUTE_MIRROR_FILE, isValidSessionId, mergeRowRoutes, parseRouteString, resolveMirrorPath }
75
+
76
+ /** Cordis plugin name, matched by the bundle patch's row `name`. */
77
+ export const name = 'fly-smart-panel'
78
+
79
+ /** Host dependencies: the Web carrier, plus `skills` (this row ships bundled skills). */
80
+ export const inject = ['webServer', 'skills']
81
+
82
+ /** Route namespace this workbench owns. */
83
+ export const FLY_SMART_PATH = '/fly/smart'
84
+
85
+ /** Service name this row publishes for other Host plugins and tools. */
86
+ export const FLY_SMART_SERVICE = 'flySmartPanel'
87
+
88
+ /**
89
+ * Where smart mode wrote **before** the task root became the session workspace.
90
+ *
91
+ * Still the read-compat root: tasks created by older builds live here, and the
92
+ * workbench must keep showing (and finishing) them. New tasks do not land here.
93
+ */
94
+ export function resolveLegacyRoot() {
95
+ return join(homedir(), '.dsh-fly', 'smart')
96
+ }
97
+
98
+ /** The legacy root, as a constant (kept for callers that only need the default). */
99
+ export const DEFAULT_ROOT_DIR = resolveLegacyRoot()
100
+
101
+ /** Every task of the workspace root lives under this directory name. */
102
+ export const WORKSPACE_ROOT_DIR = join('.dsh-fly', 'smart')
103
+
104
+ /**
105
+ * Environment override for the smart root.
106
+ *
107
+ * The row config is the product-facing switch; this exists because verification
108
+ * boots a real carrier in a scratch home (`DSH_HOME`), while `homedir()` still
109
+ * points at the developer's real home — without an override a verify run would
110
+ * have to plant fixtures in the user's own `~/.dsh-fly/smart`, which is exactly
111
+ * the kind of write the hard rules forbid.
112
+ */
113
+ export const ROOT_DIR_ENV = 'DSH_FLY_SMART_ROOT'
114
+
115
+ /**
116
+ * Smart's live preferences, declared here because this is the **host-plane** row.
117
+ *
118
+ * `rootDir` is not volatile: the browser has no business editing where tasks land.
119
+ * `brainRoute` / `handRoute` stay volatile because the upstream Settings form is
120
+ * still a supported (if no longer primary) way to set the pair — the drawer page
121
+ * writes the same mirror the tools read, so both entries agree on disk.
122
+ */
123
+ export const Config = z.object({
124
+ rootDir: z.string().default(''),
125
+ // `"provider/model"`(可选 `@effort`);空 = 用户还没选,工具会在调用时指路。
126
+ brainRoute: z.string().default('').volatile(),
127
+ handRoute: z.string().default('').volatile(),
128
+ reviewRoute: z.string().default('').volatile(),
129
+ })
130
+
131
+ /**
132
+ * Read one row-config field that may arrive as a **reference box**.
133
+ *
134
+ * `.volatile()` fields (the ones the upstream Settings form needs to see) are refs
135
+ * in `config`, with the value behind `.get()` — the engine reads its own the same
136
+ * way (`dsh-agent-preset-registry`: `this.config.selectedDefault.get()`). Fields
137
+ * without `.volatile()` (our `rootDir`) are plain values, so **one config object
138
+ * carries both shapes** and the read has to accept either.
139
+ *
140
+ * Getting this wrong is silent: `typeof ref === 'string'` is always false, so the
141
+ * row looks empty and every "the row outranks the page" branch quietly takes the
142
+ * other way. That is a real bug this file shipped once (found while writing the
143
+ * docs, `upstream-tracking.md` §7.1).
144
+ *
145
+ * @param field - a ref box, a plain value, or nothing.
146
+ * @returns the trimmed string value, or `''`.
147
+ */
148
+ export function readRowString(field) {
149
+ const value = field !== null && typeof field === 'object' && typeof field.get === 'function' ? field.get() : field
150
+ return typeof value === 'string' ? value.trim() : ''
151
+ }
152
+
153
+ /**
154
+ * Endpoint table, also served at the namespace root so the UI can self-describe.
155
+ *
156
+ * Every route this row answers appears here — a "self-describing" table that omits
157
+ * the configuration endpoint would be describing a different product.
158
+ */
159
+ export const ENDPOINTS = [
160
+ { path: `${FLY_SMART_PATH}/tasks`, summary: '任务索引(id/标题/阶段/开着的门/更新时间),按更新时间倒序', methods: ['GET', 'HEAD'] },
161
+ { path: `${FLY_SMART_PATH}/task?id=<id>`, summary: '单个任务的状态机、六个产物、评审记录、取证、门记录与返工历史', methods: ['GET', 'HEAD'] },
162
+ { path: `${FLY_SMART_PATH}/routes`, summary: '双模型路由配置:本会话 / 所有会话默认的 hand 路由(GET 读,POST 写)', methods: ['GET', 'HEAD', 'POST'] },
163
+ ]
164
+
165
+ /** Methods per endpoint, derived for the `allow` header (never hard-coded twice). */
166
+ const ALLOWED = new Map([
167
+ ['/', ['GET', 'HEAD']],
168
+ ['/tasks', ['GET', 'HEAD']],
169
+ ['/task', ['GET', 'HEAD']],
170
+ ['/routes', ['GET', 'HEAD', 'POST']],
171
+ ])
172
+
173
+ /**
174
+ * Resolve the **explicit** smart root: row config first, then the env override.
175
+ *
176
+ * "Explicit" means a human said so (or a test did) — it is not derived from a
177
+ * session. When it is set, the workbench lists that root and does not pretend to
178
+ * know a workspace.
179
+ *
180
+ * @param rawConfig - the row's `config` object, as Cordis passes it.
181
+ * @param env - the process environment (injectable for tests).
182
+ * @returns `{ root, source }` or null.
183
+ */
184
+ export function resolveExplicitRoot(rawConfig = {}, env = process.env) {
185
+ const configured = readRowString(rawConfig?.rootDir)
186
+ if (configured !== '') return { root: resolve(configured), source: 'row' }
187
+ const fromEnv = typeof env?.[ROOT_DIR_ENV] === 'string' ? env[ROOT_DIR_ENV].trim() : ''
188
+ if (fromEnv !== '') return { root: resolve(fromEnv), source: 'env' }
189
+ return null
190
+ }
191
+
192
+ /**
193
+ * Resolve the smart root: row config, then the env override, then the legacy root.
194
+ * @param rawConfig - the row's `config` object, as Cordis passes it.
195
+ * @param env - the process environment (injectable for tests).
196
+ * @returns an absolute path.
197
+ */
198
+ export function resolveRootDir(rawConfig = {}, env = process.env) {
199
+ return resolveExplicitRoot(rawConfig, env)?.root ?? resolveLegacyRoot()
200
+ }
201
+
202
+ /**
203
+ * The task root of one session's workspace: `<header.cwd>/.dsh-fly/smart`.
204
+ *
205
+ * `header.cwd` is the session's own working directory — **not** the host process
206
+ * cwd and **not** the workspace service's root. Those three are routinely
207
+ * different (the desktop shell's cwd is `/`), and `process.cwd()` is banned as a
208
+ * fallback for exactly that reason: it would silently file a task into the repo
209
+ * root. Unknown cwd means "unknown workspace", which the page renders as such.
210
+ *
211
+ * @param sessionId - the session asking.
212
+ * @param sessions - the `sessions` service, if this composition has one.
213
+ * @returns an absolute root, or null when it cannot be known.
214
+ */
215
+ export function workspaceRootFromSession(sessionId, sessions) {
216
+ if (!isValidSessionId(sessionId) || sessions === undefined || sessions === null) return null
217
+ let session
218
+ try {
219
+ session = typeof sessions.get === 'function' ? sessions.get(sessionId) : undefined
220
+ } catch {
221
+ return null
222
+ }
223
+ const cwd = session?.header?.cwd
224
+ if (typeof cwd !== 'string' || cwd === '' || !isAbsolute(cwd)) return null
225
+ return join(cwd, WORKSPACE_ROOT_DIR)
226
+ }
227
+
228
+ /**
229
+ * Every root this request could be talking about, in precedence order.
230
+ *
231
+ * @param input - `{ rawConfig, env, sessionId, sessions }`.
232
+ * @returns `{ primary, explicit, explicitSource, workspace, workspaceSource, legacy, unknownWorkspace, candidates }`.
233
+ */
234
+ export function resolveRoots({ rawConfig = {}, env = process.env, sessionId = null, sessions = undefined } = {}) {
235
+ const explicit = resolveExplicitRoot(rawConfig, env)
236
+ const workspace = workspaceRootFromSession(sessionId, sessions)
237
+ const legacy = resolveLegacyRoot()
238
+ const candidates = []
239
+ for (const root of [explicit?.root ?? null, workspace, legacy]) {
240
+ if (root !== null && !candidates.includes(root)) candidates.push(root)
241
+ }
242
+ return {
243
+ primary: explicit?.root ?? workspace ?? legacy,
244
+ explicit: explicit?.root ?? null,
245
+ explicitSource: explicit?.source ?? null,
246
+ workspace,
247
+ workspaceSource: workspace === null ? null : 'header.cwd',
248
+ legacy,
249
+ unknownWorkspace: explicit === null && workspace === null,
250
+ candidates,
251
+ }
252
+ }
253
+
254
+ /**
255
+ * Find the task directory of one id across the candidate roots.
256
+ *
257
+ * The root a task is read from is decided by **what is on disk**, not by a value
258
+ * stored inside the task: a recorded `rootDir` in `state.json` is only checked for
259
+ * consistency, so copying a task directory cannot redirect a read (or a later
260
+ * write) into an arbitrary path (`acceptance.md` Y7).
261
+ *
262
+ * @param taskId - task id.
263
+ * @param candidates - roots in precedence order.
264
+ * @returns `{ root, taskDir, note }` or null.
265
+ */
266
+ export function locateTask(taskId, candidates) {
267
+ if (!isSafeTaskId(taskId)) return null
268
+ const hits = []
269
+ for (const root of candidates) {
270
+ const taskDir = join(root, 'tasks', taskId)
271
+ if (existsSync(join(taskDir, 'state.json'))) hits.push({ root, taskDir })
272
+ }
273
+ if (hits.length === 0) return null
274
+ return {
275
+ ...hits[0],
276
+ // 同 taskId 双根:工作区根胜出,但要留下痕迹 —— 静默挑一个会让"我的产物去哪了"
277
+ // 变成无从下手的悬案(acceptance.md M10)。
278
+ ...hits.length > 1 ? { note: `task "${taskId}" exists in ${hits.length} roots; reading ${hits[0].root}` } : {},
279
+ }
280
+ }
281
+
282
+ /** The sentence that says smart has produced nothing yet. */
283
+ export function missingRootMessage(rootDir) {
284
+ return `smart 任务目录不存在:${rootDir}(smart 模式还没有产出任何任务;在会话里发「/smart <任务>」开始一个)`
285
+ }
286
+
287
+ /** The sentence for "the root is fine, but I cannot tell which workspace you are". */
288
+ export function unknownWorkspaceMessage(legacyRoot) {
289
+ return `无法确定当前工作区:右抽屉没有读到会话的工作目录,所以只列出兼容根 ${legacyRoot} 的计数。请在会话里打开这一页(或先选中一个会话)。`
290
+ }
291
+
292
+ /** A write the handler must refuse with a specific status. */
293
+ export class RouteWriteError extends Error {
294
+ constructor(status, message) {
295
+ super(message)
296
+ this.name = 'RouteWriteError'
297
+ this.status = status
298
+ }
299
+ }
300
+
301
+ /**
302
+ * The read side of the configuration surface.
303
+ *
304
+ * Reconciles the scripting plane's row config on read (`acceptance.md` S1 item 4):
305
+ * a non-empty row value is the hard floor and is merged into the mirror's
306
+ * `default` layer before the state is reported, so what the page shows is what the
307
+ * agent will use. An empty row value writes **nothing at all** — a boot must never
308
+ * touch the user's mirror.
309
+ *
310
+ * @param input - `{ mirrorPath, rowHand, sessionId }`.
311
+ * @returns the payload served at `/fly/smart/routes` and as `routeConfig`.
312
+ */
313
+ export function readRoutesConfig({ mirrorPath, rowHand = '', rowReview = '', sessionId = null }) {
314
+ if (rowHand !== '') {
315
+ try { mergeRowRoutes(mirrorPath, { hand: rowHand }) } catch { /* read remains useful */ }
316
+ }
317
+ const doc = readMirrorDocument(mirrorPath)
318
+ const known = isValidSessionId(sessionId)
319
+ const session = known && doc.sessions[sessionId] !== undefined
320
+ ? { id: sessionId, hand: doc.sessions[sessionId].hand ?? '', review: doc.sessions[sessionId].review ?? '', at: doc.sessions[sessionId].at }
321
+ : null
322
+ const mirror = resolveHand(doc, known ? sessionId : null)
323
+ const review = resolveReview(doc, known ? sessionId : null)
324
+ const effective = rowHand !== '' ? { hand: rowHand, source: 'preset' } : { hand: mirror.hand, source: mirror.source }
325
+ const effectiveReview = rowReview !== '' ? { review: rowReview, source: 'preset' } : review
326
+ return {
327
+ path: mirrorPath, lock: mirrorLockPath(mirrorPath), version: MIRROR_VERSION, exists: existsSync(mirrorPath),
328
+ presetRow: { hand: rowHand, review: rowReview, at: null, effective: rowHand !== '' || rowReview !== '' },
329
+ default: doc.defaultHand === '' && doc.defaultReview === '' ? null : { hand: doc.defaultHand, review: doc.defaultReview, at: doc.defaultAt },
330
+ session, sessionKnown: known, sessionCount: Object.keys(doc.sessions).length,
331
+ brain: doc.legacyBrain === '' ? null : { value: doc.legacyBrain, consumer: null },
332
+ effective, effectiveReview, effectiveFromPanel: rowHand === '' && rowReview === '',
333
+ }
334
+ }
335
+
336
+ /**
337
+ * The write side: one hand route into one layer of the mirror.
338
+ *
339
+ * @param input - `{ mirrorPath, rowHand, hand, scope, sessionId }`.
340
+ * @returns the fixed success payload `{ changed, effectiveFromPanel, source, sessionKnown, at, path }`.
341
+ * @throws {RouteWriteError} for the 400-class refusals.
342
+ */
343
+ export function writeRoutesConfig({ mirrorPath, rowHand = '', rowReview = '', value, hand = '', role = 'hand', scope, sessionId = null }) {
344
+ value = value ?? hand ?? ''
345
+ const doc = readMirrorDocument(mirrorPath)
346
+ if (scope === 'session' && value !== '' && doc.sessions[sessionId] === undefined && Object.keys(doc.sessions).length >= MAX_SESSIONS) {
347
+ throw new RouteWriteError(400, `smart routes: 会话路由已达上限 ${MAX_SESSIONS} 条 —— 只挡新增键,先清掉一些会话路由再保存`)
348
+ }
349
+ let result
350
+ try {
351
+ result = updateMirror(mirrorPath, role === 'review' ? { role, review: value, scope, sessionId } : { role, hand: value, scope, sessionId })
352
+ } catch (error) {
353
+ if (error instanceof LockBusy) throw new RouteWriteError(409, error.message)
354
+ throw new RouteWriteError(500, `smart routes: 镜像写入失败:${String(error?.message ?? error)}`)
355
+ }
356
+ const config = readRoutesConfig({ mirrorPath, rowHand, rowReview, sessionId })
357
+ return {
358
+ changed: result.changed,
359
+ effectiveFromPanel: rowHand === '' && rowReview === '',
360
+ source: role === 'review' ? config.effectiveReview.source : config.effective.source,
361
+ sessionKnown: config.sessionKnown,
362
+ at: result.at,
363
+ path: result.path,
364
+ }
365
+ }
366
+
367
+ /** Does this request look like a cross-site write? */
368
+ function crossSite(req) {
369
+ const site = String(req?.headers?.['sec-fetch-site'] ?? '').toLowerCase()
370
+ if (site === 'cross-site') return true
371
+ const origin = typeof req?.headers?.origin === 'string' ? req.headers.origin : ''
372
+ if (origin === '' || origin === 'null') return false
373
+ const host = req?.headers?.host
374
+ if (typeof host !== 'string' || host === '') return false
375
+ try {
376
+ return new URL(origin).host !== host
377
+ } catch {
378
+ return true
379
+ }
380
+ }
381
+
382
+ /**
383
+ * Read a request body with a hard byte ceiling.
384
+ *
385
+ * Resolves as soon as the ceiling is crossed **without** unsubscribing: the rest
386
+ * of the request is drained, which is what keeps the connection reusable for the
387
+ * next request (`acceptance.md` S1b-2 measures this on a real socket).
388
+ *
389
+ * @param req - incoming request.
390
+ * @param limit - maximum accepted bytes.
391
+ * @returns `{ text }` or `{ tooLarge: true }`.
392
+ */
393
+ export async function readBody(req, limit = MAX_BODY_BYTES) {
394
+ return await new Promise(resolveBody => {
395
+ let total = 0
396
+ const chunks = []
397
+ let decided = false
398
+ const finish = value => {
399
+ if (decided) return
400
+ decided = true
401
+ resolveBody(value)
402
+ }
403
+ req?.on?.('data', chunk => {
404
+ total += chunk.length
405
+ if (total > limit) {
406
+ chunks.length = 0
407
+ finish({ tooLarge: true })
408
+ return
409
+ }
410
+ chunks.push(chunk)
411
+ })
412
+ req?.on?.('end', () => finish({ text: Buffer.concat(chunks).toString('utf8') }))
413
+ req?.on?.('error', () => finish({ text: '' }))
414
+ })
415
+ }
416
+
417
+ /**
418
+ * Answer one smart request. The handler owns the whole response lifecycle — the
419
+ * carrier answers a thrown handler with a bare 400 and no diagnostics.
420
+ *
421
+ * @param deps - the composition's capabilities:
422
+ * - `roots(sessionId)` → {@link resolveRoots} shape
423
+ * - `readRoutes(sessionId)` → {@link readRoutesConfig} shape
424
+ * - `writeRoutes({hand, scope, sessionId})` → write payload, or undefined for a read-only host
425
+ * - `locate(taskId, sessionId?)` → `{root, taskDir, note?}` or null
426
+ * - `readIndex(root)` / `readTask(root, id)` → readers (injectable for tests)
427
+ * - `log()` → optional logger service
428
+ * @param req - the incoming request.
429
+ * @param res - the response to finish.
430
+ * @returns a promise that settles once the response has been written.
431
+ */
432
+ export async function answerSmartRequest(deps, req, res) {
433
+ const method = String(req?.method ?? 'GET').toUpperCase()
434
+ const pathname = String(req?.url ?? '/').split('?')[0]
435
+ const index = deps?.readIndex ?? readIndex
436
+ const detail = deps?.readTask ?? readTask
437
+ const log = typeof deps?.log === 'function' ? deps.log() : undefined
438
+
439
+ /** Log a line when a logger is available; a missing logger is not an error. */
440
+ const note = message => {
441
+ try {
442
+ log?.warn?.(message)
443
+ } catch {
444
+ // 日志降级:宿主没有 log 服务时只体现在响应字段里(acceptance.md M15)。
445
+ }
446
+ }
447
+
448
+ const json = (status, payload) => {
449
+ const body = JSON.stringify(payload, null, 2)
450
+ res.writeHead(status, {
451
+ 'content-type': 'application/json; charset=utf-8',
452
+ 'cache-control': 'no-store',
453
+ 'x-content-type-options': 'nosniff',
454
+ })
455
+ res.end(method === 'HEAD' ? undefined : body)
456
+ }
457
+
458
+ // ── 1. Route table: an unknown path is a 404 before any other judgement ──────
459
+ const relative = pathname === FLY_SMART_PATH
460
+ ? '/'
461
+ : pathname.startsWith(`${FLY_SMART_PATH}/`) ? pathname.slice(FLY_SMART_PATH.length) : null
462
+ if (relative === null || !ALLOWED.has(relative)) {
463
+ json(404, { error: `unknown smart endpoint: ${pathname}`, endpoints: ENDPOINTS })
464
+ return
465
+ }
466
+
467
+ // ── 2. Method: 405 with the derived allow list ──────────────────────────────
468
+ const allowed = ALLOWED.get(relative)
469
+ const isWrite = relative === '/routes' && method === 'POST'
470
+ if (!allowed.includes(method)) {
471
+ res.writeHead(405, {
472
+ 'content-type': 'text/plain; charset=utf-8',
473
+ 'cache-control': 'no-store',
474
+ allow: allowed.join(', '),
475
+ })
476
+ res.end('method not allowed')
477
+ return
478
+ }
479
+
480
+ const sessionId = (() => {
481
+ try {
482
+ const url = new URL(String(req?.url ?? '/'), 'http://127.0.0.1')
483
+ const raw = url.searchParams.get('sessionId')
484
+ return raw === null || raw === '' ? null : raw
485
+ } catch {
486
+ return null
487
+ }
488
+ })()
489
+
490
+ // ── 3. CSRF, then content type, then size, then shape ──────────────────────
491
+ let body = null
492
+ if (isWrite) {
493
+ if (crossSite(req)) {
494
+ json(403, { error: 'smart routes: 跨站写入被拒(同源页面才能改模型路由)' })
495
+ return
496
+ }
497
+ const type = String(req?.headers?.['content-type'] ?? '').toLowerCase()
498
+ if (!type.startsWith('application/json')) {
499
+ json(415, { error: 'smart routes: 请求体必须是 application/json(面板只回显这句宿主文案)' })
500
+ return
501
+ }
502
+ const read = await readBody(req)
503
+ if (read.tooLarge === true) {
504
+ json(413, { error: `smart routes: 请求体超过 ${MAX_BODY_BYTES} 字节上限(路由值本身很短,请检查客户端)` })
505
+ return
506
+ }
507
+ try {
508
+ body = JSON.parse(read.text === '' ? '{}' : read.text)
509
+ } catch {
510
+ json(400, { error: 'smart routes: 请求体不是合法 JSON' })
511
+ return
512
+ }
513
+ }
514
+
515
+ try {
516
+ const roots = deps.roots(sessionId)
517
+ const missingRoot = () => json(503, {
518
+ error: missingRootMessage(roots.primary),
519
+ root: roots.primary,
520
+ endpoints: ENDPOINTS,
521
+ })
522
+
523
+ if (relative === '/') {
524
+ // "有数据吗"问的是**任何一个**候选根:产品里新任务是工作区的,老任务在 legacy
525
+ // 根里,只探测 primary 会把"这台机器上还有老任务"误报成"什么都没有"。
526
+ const exists = roots.candidates.some(root => existsSync(root))
527
+ const config = deps.readRoutes(sessionId)
528
+ json(200, {
529
+ plugin: name,
530
+ path: FLY_SMART_PATH,
531
+ root: roots.primary,
532
+ rootSource: roots.explicitSource ?? roots.workspaceSource ?? 'legacy',
533
+ exists,
534
+ routes: config,
535
+ // 面板读到的配置随根 payload 一起回去:页面首屏不必再发一次 GET /routes。
536
+ routeConfig: config,
537
+ ...exists ? {} : { message: missingRootMessage(roots.primary) },
538
+ endpoints: ENDPOINTS,
539
+ })
540
+ return
541
+ }
542
+
543
+ if (relative === '/routes') {
544
+ if (!isWrite) {
545
+ json(200, deps.readRoutes(sessionId))
546
+ return
547
+ }
548
+ if (typeof deps.writeRoutes !== 'function') {
549
+ json(503, {
550
+ error: 'smart routes: 这个宿主没有可写的路由面(只读组合)—— 用 tool-fly-smart 行的 config 配置配对',
551
+ path: deps.readRoutes(sessionId).path,
552
+ })
553
+ return
554
+ }
555
+ const payload = body
556
+ if (payload === null || typeof payload !== 'object' || Array.isArray(payload)) {
557
+ json(400, { error: 'smart routes: 请求体必须是一个 JSON 对象' })
558
+ return
559
+ }
560
+ for (const key of Object.keys(payload)) {
561
+ if (!['hand', 'review', 'role', 'scope', 'sessionId'].includes(key)) {
562
+ json(400, { error: `smart routes: 未知字段 ${JSON.stringify(key)}(只认 hand / review / role / scope / sessionId)` })
563
+ return
564
+ }
565
+ }
566
+ const scope = payload.scope ?? 'session'
567
+ if (scope !== 'session' && scope !== 'default') {
568
+ json(400, { error: `smart routes: scope 只能是 "session" 或 "default"(收到 ${JSON.stringify(payload.scope)})` })
569
+ return
570
+ }
571
+ // 一条写路径服务两个角色:`role` 指名要写哪一条(缺省看有没有 review 字段,再退 hand)。
572
+ // 老客户端的 `{ hand, scope, sessionId }` 因此逐字节照旧可用。
573
+ const role = payload.role === 'review' || typeof payload.review === 'string' ? 'review' : 'hand'
574
+ const value = payload[role]
575
+ if (typeof value !== 'string') {
576
+ json(400, { error: `smart routes: ${role} 必须是字符串(空串 = 删除该层的路由)` })
577
+ return
578
+ }
579
+ const routeValue = value.trim()
580
+ if (routeValue !== '' && parseRouteString(routeValue) === undefined) {
581
+ json(400, { error: `smart routes: 路由值 ${JSON.stringify(routeValue)} 不合法 —— 期望 provider/model(可选 @effort),例如 deepseek/deepseek-flash` })
582
+ return
583
+ }
584
+ const wanted = payload.sessionId ?? sessionId
585
+ if (wanted !== null && wanted !== undefined && !isValidSessionId(wanted)) {
586
+ json(400, { error: `smart routes: sessionId ${JSON.stringify(wanted)} 不可用(不能为空、不能含 / 或 .. 、不能有控制字符、不超过 200 字符)` })
587
+ return
588
+ }
589
+ if (scope === 'session' && !isValidSessionId(wanted)) {
590
+ json(400, { error: 'smart routes: 保存到本会话需要 sessionId(页面没能读到会话 id 时请改用"设为所有会话默认")' })
591
+ return
592
+ }
593
+ const written = deps.writeRoutes({ role, value: routeValue, hand: role === 'hand' ? routeValue : '', scope, sessionId: isValidSessionId(wanted) ? wanted : null })
594
+ json(200, written)
595
+ return
596
+ }
597
+
598
+ if (relative === '/tasks') {
599
+ const anyRootExists = roots.candidates.some(root => existsSync(root))
600
+ if (!anyRootExists) {
601
+ missingRoot()
602
+ return
603
+ }
604
+ if (roots.unknownWorkspace) {
605
+ const legacyIndex = existsSync(roots.legacy) ? index(roots.legacy) : { source: 'none', tasks: [] }
606
+ json(200, {
607
+ root: roots.legacy,
608
+ source: legacyIndex.source,
609
+ tasks: [],
610
+ legacyCount: legacyIndex.tasks.length,
611
+ unknownWorkspace: true,
612
+ message: unknownWorkspaceMessage(roots.legacy),
613
+ })
614
+ return
615
+ }
616
+ if (roots.explicit !== null) {
617
+ const only = index(roots.explicit)
618
+ json(200, { root: roots.explicit, source: only.source, tasks: only.tasks, legacyCount: 0 })
619
+ return
620
+ }
621
+ // 工作区模式:本工作区的任务 + legacy 兼容根的计数与可读列表(同 id 工作区胜出)。
622
+ const hasWorkspace = roots.workspace !== null && existsSync(roots.workspace)
623
+ const workspaceIndex = hasWorkspace ? index(roots.workspace) : { source: 'none', tasks: [] }
624
+ const legacyIndex = existsSync(roots.legacy) ? index(roots.legacy) : { source: 'none', tasks: [] }
625
+ const seen = new Map()
626
+ for (const row of [...workspaceIndex.tasks, ...legacyIndex.tasks]) if (!seen.has(row.id)) seen.set(row.id, row)
627
+ const duplicateIds = workspaceIndex.tasks
628
+ .filter(row => legacyIndex.tasks.some(other => other.id === row.id))
629
+ .map(row => row.id)
630
+ json(200, {
631
+ root: hasWorkspace ? roots.workspace : roots.legacy,
632
+ source: hasWorkspace ? workspaceIndex.source : legacyIndex.source,
633
+ tasks: [...seen.values()],
634
+ legacyCount: legacyIndex.tasks.length,
635
+ legacyRoot: roots.legacy,
636
+ ...duplicateIds.length === 0 ? {} : {
637
+ note: `同 taskId 双根(工作区根优先):${duplicateIds.join(', ')}`,
638
+ },
639
+ })
640
+ return
641
+ }
642
+
643
+ // relative === '/task'
644
+ const url = new URL(String(req?.url ?? '/'), 'http://127.0.0.1')
645
+ const id = url.searchParams.get('id')
646
+ // Refused before anything is joined into a path.
647
+ if (id === null || !isSafeTaskId(id)) {
648
+ json(400, {
649
+ error: `smart: invalid task id ${JSON.stringify(id)} — expected /${ID_PATTERN.source}/ and no ".."`,
650
+ root: roots.primary,
651
+ })
652
+ return
653
+ }
654
+ if (!roots.candidates.some(root => existsSync(root))) {
655
+ missingRoot()
656
+ return
657
+ }
658
+ const found = deps.locate(id, sessionId)
659
+ if (found === null) {
660
+ json(404, {
661
+ error: `smart: no such task "${id}" under ${roots.candidates.map(root => join(root, 'tasks')).join(' or ')}`,
662
+ root: roots.primary,
663
+ })
664
+ return
665
+ }
666
+ const task = detail(found.root, id)
667
+ if (task === null) {
668
+ json(404, { error: `smart: no such task "${id}" under ${join(found.root, 'tasks')}`, root: found.root })
669
+ return
670
+ }
671
+ // Y7:state.rootDir 只当校验值 —— 它不决定读哪个根(那是磁盘扫描的事),
672
+ // 不一致就要大声,因为那正是"复制任务目录 → 重定向写入"的指纹。
673
+ const recorded = task.state?.rootDir
674
+ const mismatch = typeof recorded === 'string' && recorded !== '' && resolve(recorded) !== resolve(found.root)
675
+ ? { recorded, actual: found.root }
676
+ : null
677
+ if (mismatch !== null) note(`fly-smart-panel: task "${id}" state.rootDir=${mismatch.recorded} 与实际读根 ${mismatch.actual} 不一致`)
678
+ json(200, {
679
+ root: found.root,
680
+ ...found.note === undefined ? {} : { note: found.note },
681
+ ...mismatch === null ? {} : { rootDirMismatch: mismatch },
682
+ ...task,
683
+ })
684
+ } catch (error) {
685
+ if (error instanceof RouteWriteError) {
686
+ json(error.status, { error: error.message })
687
+ return
688
+ }
689
+ note(`fly-smart-panel: request failed: ${String(error?.message ?? error)}`)
690
+ json(500, { error: `fly-smart-panel: read failed: ${String(error)}` })
691
+ }
692
+ }
693
+
694
+ /**
695
+ * Plugin body: publish the service and claim the route namespace.
696
+ *
697
+ * @param ctx - the Host plugin context.
698
+ * @param rawConfig - the row's `config` object (`{ rootDir, brainRoute, handRoute }`).
699
+ */
700
+ // ─────────────────────────────────────────────────────────────────────────────
701
+ // bundled skills(移植自 obra/superpowers,见 THIRD_PARTY_NOTICES.md)
702
+ //
703
+ // 技能放在**宿主平面**这一行:它们的价值是**零 smart 任务就能用** —— 修个 bug、
704
+ // 写段测试、声称完成,这些不需要开工作流。形状照 `dsh-plugin-fly-blackbox`:
705
+ // 注册一个 skill provider,从包内 `assets/<name>/` 读,别名即目录名。
706
+ // ─────────────────────────────────────────────────────────────────────────────
707
+
708
+ /** `assets/` 的绝对路径(打包版走真实路径,不是 SEA)。 */
709
+ const ASSETS_ROOT = fileURLToPath(new URL('./assets/', import.meta.url))
710
+
711
+ /** frontmatter 里必须有的两个键:`name` 与 `description`(技能目录只看 description)。 */
712
+ function skillFrontmatter(raw, path) {
713
+ const block = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/u.exec(raw)
714
+ if (block?.[1] === undefined) throw new Error(`fly-smart: ${path} has no YAML frontmatter`)
715
+ const name = /^name:\s*(.+)$/mu.exec(block[1])?.[1]?.trim()
716
+ const description = /^description:\s*(.+)$/mu.exec(block[1])?.[1]?.trim()
717
+ if (name === undefined || name === '') throw new Error(`fly-smart: ${path} has no name`)
718
+ if (description === undefined || description === '') throw new Error(`fly-smart: ${path} has no description`)
719
+ return { name, description, content: raw.slice(block[0].length).trim() }
720
+ }
721
+
722
+ /**
723
+ * Every bundled skill, listed once at startup.
724
+ *
725
+ * A missing or malformed `SKILL.md` throws here rather than silently dropping the
726
+ * skill: "the skill is not in the catalog" is the exact failure the plugin exists
727
+ * to prevent, and it must be loud.
728
+ */
729
+ function bundledSkills() {
730
+ const entries = readdirSync(ASSETS_ROOT, { withFileTypes: true })
731
+ .filter(entry => entry.isDirectory())
732
+ .map(entry => entry.name)
733
+ .sort()
734
+ return entries.map(dirName => {
735
+ const base = join(ASSETS_ROOT, dirName)
736
+ const locator = join(base, 'SKILL.md')
737
+ const raw = readFileSync(locator, 'utf8')
738
+ const { name, description } = skillFrontmatter(raw, locator)
739
+ return {
740
+ name,
741
+ description,
742
+ invocation: { modelInvocable: true, userInvocable: true },
743
+ provider: 'fly-smart',
744
+ source: 'bundled',
745
+ rank: BUNDLED_SKILL_RANK,
746
+ resourceBase: { kind: 'directory', path: base },
747
+ locator,
748
+ }
749
+ })
750
+ }
751
+
752
+ function smartSkillProvider() {
753
+ const skills = bundledSkills()
754
+ return {
755
+ name: 'fly-smart',
756
+ list: () => Promise.resolve(skills),
757
+ async get(requested, options) {
758
+ const { rank: _rank, locator: file, ...summary } = requested
759
+ const raw = readFileSync(file, { encoding: 'utf8', signal: options?.signal })
760
+ return { ...summary, content: skillFrontmatter(raw, file).content }
761
+ },
762
+ }
763
+ }
764
+
765
+ export function apply(ctx, rawConfig = {}) {
766
+ const env = process.env
767
+ const mirrorPath = resolveMirrorPath(env)
768
+ const rowHand = readRowString(rawConfig?.handRoute)
769
+ const rowReview = readRowString(rawConfig?.reviewRoute)
770
+
771
+ // 行 ref 非空 = 脚本化硬底:启动时并进 default 层(永不删键)。行 ref 为空时
772
+ // **一个字节都不写** —— 真实的 ~/.dsh-fly/smart-routes.json 是用户的配置,不是我们的草稿纸。
773
+ ctx.effect(() => {
774
+ if (rowHand === '') return
775
+ try {
776
+ mergeRowRoutes(mirrorPath, { hand: rowHand })
777
+ } catch (error) {
778
+ ctx.get?.('log')?.warn?.(`fly-smart-panel: route mirror merge failed: ${String(error?.message ?? error)}`)
779
+ }
780
+ }, 'fly-smart-panel: route mirror')
781
+
782
+ const sessions = () => {
783
+ try {
784
+ // 可选链:宿主行没有声明 `inject: ['sessions']`(见 acceptance.md M4),
785
+ // 拿不到就是"工作区未知",而不是整块不渲染。
786
+ return ctx.get?.('sessions')
787
+ } catch {
788
+ return undefined
789
+ }
790
+ }
791
+
792
+ const rootsOf = sessionId => resolveRoots({ rawConfig, env, sessionId, sessions: sessions() })
793
+
794
+ const service = Object.freeze({
795
+ path: FLY_SMART_PATH,
796
+ rootDir: resolveRootDir(rawConfig, env),
797
+ legacyRoot: resolveLegacyRoot(),
798
+ mirrorPath,
799
+ activatedAt: new Date().toISOString(),
800
+ endpoints: ENDPOINTS.map(endpoint => ({ ...endpoint })),
801
+ roots: sessionId => rootsOf(sessionId),
802
+ routes: sessionId => readRoutesConfig({ mirrorPath, rowHand, rowReview, sessionId }),
803
+ tasks: sessionId => {
804
+ const roots = rootsOf(sessionId)
805
+ return roots.unknownWorkspace ? { source: 'unknown', tasks: [] } : readIndex(roots.primary)
806
+ },
807
+ locate: (id, sessionId) => locateTask(id, rootsOf(sessionId).candidates),
808
+ task: (id, sessionId) => {
809
+ const found = locateTask(id, rootsOf(sessionId).candidates)
810
+ return found === null ? null : readTask(found.root, id)
811
+ },
812
+ })
813
+ ctx.provide(FLY_SMART_SERVICE, service)
814
+
815
+ const deps = {
816
+ roots: rootsOf,
817
+ readRoutes: sessionId => readRoutesConfig({ mirrorPath, rowHand, rowReview, sessionId }),
818
+ writeRoutes: ({ role = 'hand', value = '', hand = value, scope, sessionId }) => writeRoutesConfig({ mirrorPath, rowHand, rowReview, role, value, hand, scope, sessionId }),
819
+ locate: (id, sessionId) => locateTask(id, rootsOf(sessionId).candidates),
820
+ readIndex,
821
+ readTask,
822
+ log: () => {
823
+ try {
824
+ return ctx.get?.('log')
825
+ } catch {
826
+ return undefined
827
+ }
828
+ },
829
+ }
830
+
831
+ // 自带技能:零 smart 任务即可用(修 bug / 写测试 / 声称完成都不必开工作流)。
832
+ // **故意在注册时就展开全部技能**:少一个 SKILL.md 或 frontmatter 缺键 ⇒ 抛,
833
+ // 而不是静默少一个技能 —— 「技能不在目录里」正是本插件要防的失败形状。
834
+ ctx.skills.registerProvider(() => smartSkillProvider())
835
+
836
+ ctx.effect(
837
+ () => ctx.webServer.register({
838
+ kind: 'prefix',
839
+ path: FLY_SMART_PATH,
840
+ handler: (req, res) => answerSmartRequest(deps, req, res),
841
+ }),
842
+ 'fly-smart-panel: /fly/smart/*',
843
+ )
844
+ }
845
+
846
+ /** Re-exported for the route panel's own tests and for `formatRoute` consumers. */
847
+ export { formatRoute }