@menghuan1918/dsh-apollo 0.3.0 → 0.4.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/README.md +45 -24
- package/cordis.patch.yml +3 -3
- package/docs/DEVELOPMENT.md +97 -45
- package/package.json +5 -4
- package/presets/eng/NOTICE +3 -2
- package/presets/eng/plugins/exit-guard/index.js +6 -6
- package/presets/eng/plugins/mode-instructions/index.js +111 -35
- package/presets/eng/plugins/wait-subagent/index.js +9 -4
- package/presets/eng-ptc.patch.yml +186 -0
- package/presets/eng.patch.yml +14 -17
- package/skills/brainstorming/SKILL.md +54 -54
- package/skills/brainstorming/spec-document-reviewer-prompt.md +6 -6
- package/skills/code-review/SKILL.md +1 -1
- package/skills/dev/SKILL.md +16 -18
- package/skills/exec/SKILL.md +56 -69
- package/skills/handoff/SKILL.md +14 -14
- package/skills/main-agent-driven-development/SKILL.md +33 -33
- package/src/cli-adapter.js +1 -1
- package/src/client.js +329 -16
- package/src/config.js +30 -4
- package/src/panel.js +406 -14
- package/src/subagents.js +3 -4
- package/presets/eng-noask/NOTICE +0 -2
- package/presets/eng-noask/plugins/exit-guard/index.js +0 -111
- package/presets/eng-noask/plugins/exit-guard/package.json +0 -7
- package/presets/eng-noask/plugins/mode-instructions/index.js +0 -48
- package/presets/eng-noask/plugins/mode-instructions/package.json +0 -7
- package/presets/eng-noask/plugins/wait-subagent/index.js +0 -228
- package/presets/eng-noask/plugins/wait-subagent/package.json +0 -7
- package/presets/eng-noask.patch.yml +0 -341
package/src/panel.js
CHANGED
|
@@ -2,9 +2,15 @@
|
|
|
2
2
|
* dsh-apollo (npm: @menghuan1918/dsh-apollo) —— 宿主半边只读面板(src/panel.js)。
|
|
3
3
|
*
|
|
4
4
|
* 注册仅限 loopback(或行配置 `trustedHosts` 列出的反向代理权威)+ 同源标志的
|
|
5
|
-
* fenced HTTP
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* fenced HTTP 路由:
|
|
6
|
+
* - `/apollo/api/capabilities`——**能力探针**:两个 CLI 后端配置的可执行文件
|
|
7
|
+
* 在 PATH 上的解析结果与版本、以及 Codex 的 profile 名称;
|
|
8
|
+
* - `/apollo/api/models?cli=codex|claude`——**模型自动发现**:Codex 读
|
|
9
|
+
* `codex debug models` 的 JSON catalog,Claude 读 Anthropic Messages 的
|
|
10
|
+
* `GET /v1/models`(凭据来自进程 env 或 Claude 设置文件,只在内存里用);
|
|
11
|
+
* - `/apollo/api/routes`——**分档路由**:子代理模型选择白名单里的
|
|
12
|
+
* provider/model 及其 reasoning effort(供模型分档下拉框)。
|
|
13
|
+
* 三者都供浏览器半边(`exports["./client"]` 的 `plugins.row.config` 页面)读取。
|
|
8
14
|
*
|
|
9
15
|
* 为什么是「能力」而不是「配置」:设置本身已经是正式 Config(volatile),读写
|
|
10
16
|
* 走 DSH 自己的 configForms/行配置页面(`src/settings.js` 只做一次旧文件导入),
|
|
@@ -13,15 +19,17 @@
|
|
|
13
19
|
* 哪些 profile 可选。
|
|
14
20
|
*
|
|
15
21
|
* 暴露面(写死在契约里,测试守护):
|
|
16
|
-
*
|
|
22
|
+
* capabilities:`{ executables: { codex: { executable, path, version }, claude: {...} },
|
|
17
23
|
* codexProfiles: string[] }`
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
24
|
+
* models:`{ cli, models: [{ id, efforts, defaultEffort }], source, error }`
|
|
25
|
+
* (`error` 只含 `code` 与可选 `status`)
|
|
26
|
+
* **绝不返回配置正文,也绝不返回任何凭据**:只回可执行文件名/路径/版本行、
|
|
27
|
+
* profile 名称、模型 id 与 effort;Claude 凭据只在本进程内拼进请求头。
|
|
28
|
+
* 只读:GET 之外的方法一律 405,且从不读取请求体。
|
|
21
29
|
*
|
|
22
30
|
* 安全模型仍是 DNS-rebinding / 跨站防御而非认证(默认只信 loopback;经反向
|
|
23
31
|
* 代理域名访问 GUI 时把该权威加入行配置 `trustedHosts`,后果上限是泄露本机
|
|
24
|
-
*
|
|
32
|
+
* 上某个可执行文件的路径与版本号、或模型清单)。边界判定与 dsh-better-sidebar 的
|
|
25
33
|
* src/trust-fence.ts 同源(其源头为 BSD-3-Clause 的
|
|
26
34
|
* @deepseek-ai/dsh-client-connection api-request-trust + loopback-hostname)。
|
|
27
35
|
*
|
|
@@ -59,6 +67,72 @@ const VERSION_MAX_CHARS = 120
|
|
|
59
67
|
/** 合法的 profile 名(同时挡住路径分隔符,名称只会被当显示文本用)。 */
|
|
60
68
|
const PROFILE_NAME = /^[A-Za-z0-9._-]+$/
|
|
61
69
|
|
|
70
|
+
// ── 模型自动发现(/apollo/api/models)────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
/** 自动发现的完整路径(GET,查询参数 `cli=codex|claude`)。 */
|
|
73
|
+
export const MODELS_PATH = '/apollo/api/models'
|
|
74
|
+
/** 自动发现接受的 CLI(同时是查询参数取值)。 */
|
|
75
|
+
export const DISCOVERY_CLIS = Object.freeze(['codex', 'claude'])
|
|
76
|
+
/** 单次自动发现的兜底超时(毫秒)。 */
|
|
77
|
+
export const DISCOVERY_TIMEOUT_MS = 10_000
|
|
78
|
+
/** Codex 在线目录失败后 `--bundled` 离线兜底的超时(毫秒)。 */
|
|
79
|
+
export const DISCOVERY_FALLBACK_TIMEOUT_MS = 3_000
|
|
80
|
+
/** Claude `--effort` 的实测取值(`claude --help`;与 src/cli/claude.js 同步)。 */
|
|
81
|
+
export const CLAUDE_EFFORTS = Object.freeze(['low', 'medium', 'high', 'xhigh', 'max'])
|
|
82
|
+
/** Claude 发现结果的默认 effort:五档中偏上的安全值。 */
|
|
83
|
+
const CLAUDE_DEFAULT_EFFORT = 'high'
|
|
84
|
+
/** Anthropic Messages 的模型清单协议版本。 */
|
|
85
|
+
const ANTHROPIC_VERSION = '2023-06-01'
|
|
86
|
+
/** Anthropic 模型清单单页上限(与 DSH pi-ai 的发现实现一致)。 */
|
|
87
|
+
const ANTHROPIC_MODEL_LIMIT = 1000
|
|
88
|
+
/** 发现结果的数量与长度上限:清单来自本机 CLI 或用户配置的端点,不能无限长。 */
|
|
89
|
+
const MAX_MODELS = 200
|
|
90
|
+
const MAX_MODEL_ID_CHARS = 120
|
|
91
|
+
const MAX_EFFORT_CHARS = 32
|
|
92
|
+
const MAX_LISTING_BYTES = 4 * 1024 * 1024
|
|
93
|
+
/** 模型 id / effort 的安全字符集:argv 不经 shell,但仍拒绝空白、引号与控制字符。 */
|
|
94
|
+
const MODEL_ID = /^[A-Za-z0-9][A-Za-z0-9._:/@+\[\]-]*$/
|
|
95
|
+
const EFFORT = /^[A-Za-z][A-Za-z0-9._-]*$/
|
|
96
|
+
|
|
97
|
+
// ── 子代理分档路由(/apollo/api/routes)──────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
/** 分档下拉框数据源的完整路径(GET)。 */
|
|
100
|
+
export const ROUTES_PATH = '/apollo/api/routes'
|
|
101
|
+
|
|
102
|
+
/** 读一个宿主服务;不可见或抛错返回 undefined。 */
|
|
103
|
+
function hostService(ctx, name) {
|
|
104
|
+
try { return ctx.get?.(name) ?? undefined } catch { return undefined }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* 列出子代理模型选择白名单里的路由及各自的 reasoning effort。
|
|
109
|
+
*
|
|
110
|
+
* 白名单来自宿主 `subagentModelSelection.current()`(「插件 → 子代理 → 模型
|
|
111
|
+
* 选择」),effort 来自 `llm.resolveModelInfo`;单条解析失败只让该条 efforts
|
|
112
|
+
* 为空,不影响其他条目。服务缺失或白名单非法时返回未启用的空清单。
|
|
113
|
+
* @param ctx - 插件上下文。
|
|
114
|
+
* @returns `{ enabled, routes: [{ provider, model, efforts, defaultEffort }] }`。
|
|
115
|
+
*/
|
|
116
|
+
export async function collectTierRoutes(ctx) {
|
|
117
|
+
let selection
|
|
118
|
+
try { selection = hostService(ctx, 'subagentModelSelection')?.current() } catch { selection = undefined }
|
|
119
|
+
if (!isRecord(selection) || !Array.isArray(selection.allowedModels)) return { enabled: false, routes: [] }
|
|
120
|
+
const llm = hostService(ctx, 'llm')
|
|
121
|
+
const routes = await Promise.all(selection.allowedModels.map(async ({ provider, model }) => {
|
|
122
|
+
try {
|
|
123
|
+
const reasoning = (await llm?.resolveModelInfo?.(provider, model))?.reasoning
|
|
124
|
+
const efforts = Array.isArray(reasoning?.efforts)
|
|
125
|
+
? reasoning.efforts.map((effort) => effort?.id).filter((id) => typeof id === 'string' && id !== '')
|
|
126
|
+
: []
|
|
127
|
+
const defaultEffort = efforts.includes(reasoning?.defaultEffort) ? reasoning.defaultEffort : ''
|
|
128
|
+
return { provider, model, efforts, defaultEffort }
|
|
129
|
+
} catch {
|
|
130
|
+
return { provider, model, efforts: [], defaultEffort: '' }
|
|
131
|
+
}
|
|
132
|
+
}))
|
|
133
|
+
return { enabled: selection.enabled === true, routes }
|
|
134
|
+
}
|
|
135
|
+
|
|
62
136
|
// ── 部署标志 tap ────────────────────────────────────────────────────────────
|
|
63
137
|
|
|
64
138
|
/**
|
|
@@ -356,6 +430,294 @@ export function createCapabilitiesReader(deps = {}) {
|
|
|
356
430
|
}
|
|
357
431
|
}
|
|
358
432
|
|
|
433
|
+
// ── 模型自动发现 ────────────────────────────────────────────────────────────
|
|
434
|
+
|
|
435
|
+
/** 普通数据对象(数组、null、类实例都不算)。 */
|
|
436
|
+
function isRecord(value) {
|
|
437
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/** 规整模型 id:去空白、限长、拒绝空白/引号/控制字符。 */
|
|
441
|
+
function modelIdOf(value) {
|
|
442
|
+
if (typeof value !== 'string') return null
|
|
443
|
+
const id = value.trim()
|
|
444
|
+
if (id === '' || id.length > MAX_MODEL_ID_CHARS || !MODEL_ID.test(id)) return null
|
|
445
|
+
return id
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/** 规整 effort 名:去空白、限长、拒绝空白/引号/控制字符。 */
|
|
449
|
+
function effortOf(value) {
|
|
450
|
+
if (typeof value !== 'string') return null
|
|
451
|
+
const effort = value.trim()
|
|
452
|
+
if (effort === '' || effort.length > MAX_EFFORT_CHARS || !EFFORT.test(effort)) return null
|
|
453
|
+
return effort
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* 解析 `codex debug models` 的 JSON catalog。
|
|
458
|
+
*
|
|
459
|
+
* 只收 `visibility === 'list'` 的模型(隐藏条目是内部/评审模型);id 取 `slug`;
|
|
460
|
+
* effort 来自 `supported_reasoning_levels[].effort`,默认 effort 取
|
|
461
|
+
* `default_reasoning_level`(不在列表里时退回第一个)。
|
|
462
|
+
* @param text - 命令 stdout。
|
|
463
|
+
* @returns 模型数组;JSON 不可解析时返回 null(调用方报 parse 错误)。
|
|
464
|
+
*/
|
|
465
|
+
export function parseCodexCatalog(text) {
|
|
466
|
+
let parsed
|
|
467
|
+
try {
|
|
468
|
+
parsed = JSON.parse(String(text))
|
|
469
|
+
} catch {
|
|
470
|
+
return null
|
|
471
|
+
}
|
|
472
|
+
const entries = isRecord(parsed) && Array.isArray(parsed.models) ? parsed.models : []
|
|
473
|
+
const models = []
|
|
474
|
+
for (const entry of entries) {
|
|
475
|
+
if (!isRecord(entry)) continue
|
|
476
|
+
if (typeof entry.visibility === 'string' && entry.visibility !== 'list') continue
|
|
477
|
+
const id = modelIdOf(entry.slug ?? entry.id)
|
|
478
|
+
if (id === null || models.some((model) => model.id === id)) continue
|
|
479
|
+
const efforts = []
|
|
480
|
+
const levels = Array.isArray(entry.supported_reasoning_levels) ? entry.supported_reasoning_levels : []
|
|
481
|
+
for (const level of levels) {
|
|
482
|
+
if (!isRecord(level)) continue
|
|
483
|
+
const effort = effortOf(level.effort)
|
|
484
|
+
if (effort !== null && !efforts.includes(effort)) efforts.push(effort)
|
|
485
|
+
if (efforts.length >= 16) break
|
|
486
|
+
}
|
|
487
|
+
const preferred = effortOf(entry.default_reasoning_level)
|
|
488
|
+
models.push({
|
|
489
|
+
id,
|
|
490
|
+
efforts,
|
|
491
|
+
defaultEffort: preferred !== null && efforts.includes(preferred) ? preferred : (efforts[0] ?? ''),
|
|
492
|
+
})
|
|
493
|
+
if (models.length >= MAX_MODELS) break
|
|
494
|
+
}
|
|
495
|
+
return models
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* 解析 Anthropic Messages `GET /v1/models` 的响应。
|
|
500
|
+
*
|
|
501
|
+
* Claude CLI 本身没有模型清单命令,端点只回 id/display_name;effort 取
|
|
502
|
+
* `claude --effort` 的固定五档,默认 high。
|
|
503
|
+
* @param text - 响应正文。
|
|
504
|
+
* @returns 模型数组;JSON 不可解析时返回 null。
|
|
505
|
+
*/
|
|
506
|
+
export function parseClaudeModelList(text) {
|
|
507
|
+
let parsed
|
|
508
|
+
try {
|
|
509
|
+
parsed = JSON.parse(String(text))
|
|
510
|
+
} catch {
|
|
511
|
+
return null
|
|
512
|
+
}
|
|
513
|
+
const entries = isRecord(parsed) && Array.isArray(parsed.data) ? parsed.data : []
|
|
514
|
+
const models = []
|
|
515
|
+
for (const entry of entries) {
|
|
516
|
+
if (!isRecord(entry)) continue
|
|
517
|
+
const id = modelIdOf(entry.id)
|
|
518
|
+
if (id === null || models.some((model) => model.id === id)) continue
|
|
519
|
+
models.push({ id, efforts: [...CLAUDE_EFFORTS], defaultEffort: CLAUDE_DEFAULT_EFFORT })
|
|
520
|
+
if (models.length >= MAX_MODELS) break
|
|
521
|
+
}
|
|
522
|
+
return models
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Anthropic 模型清单 URL:根路径去掉末尾 `/` 与一个末尾 `/v1`,再接 `/v1/models`
|
|
527
|
+
* (兼容网关文档的两种写法;与 DSH pi-ai 的发现规则一致)。
|
|
528
|
+
* @param baseURL - `ANTHROPIC_BASE_URL`(已去空白)。
|
|
529
|
+
* @returns 清单 URL。
|
|
530
|
+
*/
|
|
531
|
+
export function anthropicListingUrl(baseURL) {
|
|
532
|
+
const base = String(baseURL).trim().replace(/\/+$/, '')
|
|
533
|
+
const root = base.endsWith('/v1') ? base.slice(0, -3) : base
|
|
534
|
+
return `${root}/v1/models?limit=${String(ANTHROPIC_MODEL_LIMIT)}`
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/** 读取 Claude 设置文件(可注入 readFile;缺失/坏 JSON 都当空对象)。 */
|
|
538
|
+
function readClaudeSettings(path, readFile) {
|
|
539
|
+
try {
|
|
540
|
+
const parsed = JSON.parse(String(readFile(path)))
|
|
541
|
+
return isRecord(parsed) ? parsed : {}
|
|
542
|
+
} catch {
|
|
543
|
+
return {}
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* 读取 Claude 的有效端点与凭据:`ANTHROPIC_BASE_URL` 逐键回退(进程 env →
|
|
549
|
+
* Claude 用户设置 `<CLAUDE_CONFIG_DIR 或 ~/.claude>/settings.json` 的 `env`),
|
|
550
|
+
* **凭据来源整体优先**——进程 env 里出现任一凭据时整套用 env 的凭据,否则整套
|
|
551
|
+
* 用 settings;不把两边的 token/key 各取一半。
|
|
552
|
+
*
|
|
553
|
+
* **凭据只在内存里用于请求头,绝不进入响应**;本函数导出仅为测试。
|
|
554
|
+
* @param options - `env`、`home`、`readFile` 注入点。
|
|
555
|
+
* @returns `{ baseURL, authToken, apiKey }`(后两者可能为 undefined)。
|
|
556
|
+
*/
|
|
557
|
+
export function claudeApiConfig(options = {}) {
|
|
558
|
+
const env = options.env ?? process.env
|
|
559
|
+
const home = options.home ?? homedir()
|
|
560
|
+
const readFile = options.readFile ?? ((file) => readFileSync(file, 'utf8'))
|
|
561
|
+
const configured = typeof env.CLAUDE_CONFIG_DIR === 'string' && env.CLAUDE_CONFIG_DIR.trim() !== ''
|
|
562
|
+
? expandHome(env.CLAUDE_CONFIG_DIR.trim(), home)
|
|
563
|
+
: join(home, '.claude')
|
|
564
|
+
const settings = readClaudeSettings(join(configured, 'settings.json'), readFile)
|
|
565
|
+
const settingsEnv = isRecord(settings.env) ? settings.env : {}
|
|
566
|
+
const read = (source, key) => {
|
|
567
|
+
const value = source[key]
|
|
568
|
+
return typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined
|
|
569
|
+
}
|
|
570
|
+
const baseURL = read(env, 'ANTHROPIC_BASE_URL')
|
|
571
|
+
?? read(settingsEnv, 'ANTHROPIC_BASE_URL')
|
|
572
|
+
?? 'https://api.anthropic.com'
|
|
573
|
+
const envToken = read(env, 'ANTHROPIC_AUTH_TOKEN')
|
|
574
|
+
const envKey = read(env, 'ANTHROPIC_API_KEY')
|
|
575
|
+
if (envToken !== undefined || envKey !== undefined) {
|
|
576
|
+
return { baseURL, authToken: envToken, apiKey: envToken === undefined ? envKey : undefined }
|
|
577
|
+
}
|
|
578
|
+
const settingsToken = read(settingsEnv, 'ANTHROPIC_AUTH_TOKEN')
|
|
579
|
+
return {
|
|
580
|
+
baseURL,
|
|
581
|
+
authToken: settingsToken,
|
|
582
|
+
apiKey: settingsToken === undefined ? read(settingsEnv, 'ANTHROPIC_API_KEY') : undefined,
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/** Claude 请求头:AUTH_TOKEN 走 Bearer,API key 走 x-api-key;都没有返回 null。 */
|
|
587
|
+
function claudeAuthHeaders(api) {
|
|
588
|
+
const headers = { accept: 'application/json', 'anthropic-version': ANTHROPIC_VERSION }
|
|
589
|
+
if (api.authToken !== undefined) headers.authorization = `Bearer ${api.authToken}`
|
|
590
|
+
else if (api.apiKey !== undefined) headers['x-api-key'] = api.apiKey
|
|
591
|
+
else return null
|
|
592
|
+
return headers
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/** 统一成功结果。 */
|
|
596
|
+
function discovered(cli, models, source) {
|
|
597
|
+
return { cli, models, source, error: null }
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/** 统一失败结果(models 为空,error 只含 code/status)。 */
|
|
601
|
+
function discoveryFailed(cli, code, status) {
|
|
602
|
+
return {
|
|
603
|
+
cli,
|
|
604
|
+
models: [],
|
|
605
|
+
source: null,
|
|
606
|
+
error: status === undefined ? { code } : { code, status },
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* 发现 Claude 模型:`GET {ANTHROPIC_BASE_URL}/v1/models`。
|
|
612
|
+
*
|
|
613
|
+
* 只读凭据来源与 CLI 相同(见 {@link claudeApiConfig}),但不 spawn CLI;
|
|
614
|
+
* 响应有字节上限,错误只回 code/status,不回凭据或 URL。
|
|
615
|
+
* @param deps - `env`、`home`、`readFile`、`fetch`、`timeoutMs` 注入点。
|
|
616
|
+
* @returns `{ cli, models, source, error }`。
|
|
617
|
+
*/
|
|
618
|
+
export async function discoverClaudeModels(deps = {}) {
|
|
619
|
+
const api = claudeApiConfig(deps)
|
|
620
|
+
const headers = claudeAuthHeaders(api)
|
|
621
|
+
if (headers === null) return discoveryFailed('claude', 'no-credentials')
|
|
622
|
+
const fetcher = deps.fetch ?? globalThis.fetch
|
|
623
|
+
if (typeof fetcher !== 'function') return discoveryFailed('claude', 'network')
|
|
624
|
+
const timeoutMs = deps.timeoutMs ?? DISCOVERY_TIMEOUT_MS
|
|
625
|
+
let response
|
|
626
|
+
try {
|
|
627
|
+
response = await fetcher(anthropicListingUrl(api.baseURL), {
|
|
628
|
+
method: 'GET',
|
|
629
|
+
headers,
|
|
630
|
+
// 不跟随重定向:跨域重定向时自定义鉴权头的剥离规则不一致,直接失败更安全。
|
|
631
|
+
redirect: 'error',
|
|
632
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
633
|
+
})
|
|
634
|
+
} catch {
|
|
635
|
+
return discoveryFailed('claude', 'network')
|
|
636
|
+
}
|
|
637
|
+
if (response.status === 401 || response.status === 403) {
|
|
638
|
+
return discoveryFailed('claude', 'unauthorized', response.status)
|
|
639
|
+
}
|
|
640
|
+
if (response.ok !== true) return discoveryFailed('claude', 'http', response.status)
|
|
641
|
+
const declared = Number(response.headers?.get?.('content-length') ?? NaN)
|
|
642
|
+
if (Number.isFinite(declared) && declared > MAX_LISTING_BYTES) return discoveryFailed('claude', 'too-large')
|
|
643
|
+
let text
|
|
644
|
+
try {
|
|
645
|
+
text = String(await response.text())
|
|
646
|
+
} catch {
|
|
647
|
+
return discoveryFailed('claude', 'network')
|
|
648
|
+
}
|
|
649
|
+
if (Buffer.byteLength(text) > MAX_LISTING_BYTES) return discoveryFailed('claude', 'too-large')
|
|
650
|
+
const models = parseClaudeModelList(text)
|
|
651
|
+
if (models === null) return discoveryFailed('claude', 'parse')
|
|
652
|
+
if (models.length === 0) return discoveryFailed('claude', 'empty')
|
|
653
|
+
return discovered('claude', models, 'claude-models-api')
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/** 运行 `codex debug models`:成功返回 stdout,失败/超时返回 undefined。 */
|
|
657
|
+
function runCodexOutput(path, args, options = {}) {
|
|
658
|
+
const timeoutMs = options.timeoutMs ?? DISCOVERY_TIMEOUT_MS
|
|
659
|
+
return new Promise((resolve) => {
|
|
660
|
+
execFile(path, args, {
|
|
661
|
+
timeout: timeoutMs,
|
|
662
|
+
killSignal: 'SIGKILL',
|
|
663
|
+
maxBuffer: MAX_LISTING_BYTES,
|
|
664
|
+
windowsHide: true,
|
|
665
|
+
}, (error, stdout) => {
|
|
666
|
+
if (error !== null) {
|
|
667
|
+
resolve(undefined)
|
|
668
|
+
return
|
|
669
|
+
}
|
|
670
|
+
const text = String(stdout ?? '')
|
|
671
|
+
resolve(Buffer.byteLength(text) > MAX_LISTING_BYTES ? undefined : text)
|
|
672
|
+
})
|
|
673
|
+
})
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/**
|
|
677
|
+
* 发现 Codex 模型:本机 `codex debug models`(在线目录);失败时退回
|
|
678
|
+
* `--bundled`(离线内置目录)。
|
|
679
|
+
*
|
|
680
|
+
* 目录跟随 `~/.codex/config.toml` 的 model_provider,所以填进来的 id 与 CLI
|
|
681
|
+
* 实际可用模型一致;stdout 只含 JSON(告警走 stderr)。
|
|
682
|
+
* @param config - 行配置(取 codex 的 executable)。
|
|
683
|
+
* @param deps - `env`、`home`、`resolveExecutable`、`runCommand`、`timeoutMs`、
|
|
684
|
+
* `fallbackTimeoutMs` 注入点。
|
|
685
|
+
* @returns `{ cli, models, source, error }`。
|
|
686
|
+
*/
|
|
687
|
+
export async function discoverCodexModels(config, deps = {}) {
|
|
688
|
+
const backend = normalizeConfig(config).subagents.codex
|
|
689
|
+
const env = deps.env ?? process.env
|
|
690
|
+
const home = deps.home ?? homedir()
|
|
691
|
+
const resolve = deps.resolveExecutable ?? resolveExecutable
|
|
692
|
+
const run = deps.runCommand ?? runCodexOutput
|
|
693
|
+
const path = attempt(() => resolve(backend.executable, { env, home }))
|
|
694
|
+
if (typeof path !== 'string' || path === '') return discoveryFailed('codex', 'missing')
|
|
695
|
+
let text = await run(path, ['debug', 'models'], { timeoutMs: deps.timeoutMs ?? DISCOVERY_TIMEOUT_MS })
|
|
696
|
+
if (text === undefined) {
|
|
697
|
+
text = await run(path, ['debug', 'models', '--bundled'], {
|
|
698
|
+
timeoutMs: deps.fallbackTimeoutMs ?? DISCOVERY_FALLBACK_TIMEOUT_MS,
|
|
699
|
+
})
|
|
700
|
+
}
|
|
701
|
+
if (text === undefined) return discoveryFailed('codex', 'failed')
|
|
702
|
+
const models = parseCodexCatalog(text)
|
|
703
|
+
if (models === null) return discoveryFailed('codex', 'parse')
|
|
704
|
+
if (models.length === 0) return discoveryFailed('codex', 'empty')
|
|
705
|
+
return discovered('codex', models, 'codex-catalog')
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/**
|
|
709
|
+
* 按 CLI 分派模型发现。
|
|
710
|
+
* @param cli - `codex` 或 `claude`(调用方已校验)。
|
|
711
|
+
* @param config - 行配置。
|
|
712
|
+
* @param deps - 两个发现函数的注入点合集。
|
|
713
|
+
* @returns `{ cli, models, source, error }`。
|
|
714
|
+
*/
|
|
715
|
+
export async function discoverModels(cli, config, deps = {}) {
|
|
716
|
+
if (cli === 'codex') return discoverCodexModels(config, deps)
|
|
717
|
+
if (cli === 'claude') return discoverClaudeModels(deps)
|
|
718
|
+
throw new Error(`unknown discovery cli: ${String(cli)}`)
|
|
719
|
+
}
|
|
720
|
+
|
|
359
721
|
// ── 安检(loopback Host + 同源标志)─────────────────────────────────────────
|
|
360
722
|
// 改编自 dsh-better-sidebar src/trust-fence.ts(其源头为 BSD-3-Clause 的
|
|
361
723
|
// @deepseek-ai/dsh-client-connection api-request-trust + loopback-hostname)。
|
|
@@ -410,17 +772,21 @@ function writeJson(res, status, body) {
|
|
|
410
772
|
}
|
|
411
773
|
|
|
412
774
|
/**
|
|
413
|
-
* 注册只读 `/apollo/api/capabilities`
|
|
775
|
+
* 注册只读 `/apollo/api/capabilities` 与 `/apollo/api/models` 路由
|
|
776
|
+
* (由 `src/settings.js` 调用)。
|
|
414
777
|
*
|
|
415
778
|
* 信任边界按请求重读活配置的 `trustedHosts`(反代部署下改完即时生效),读不到
|
|
416
|
-
* 活配置时退回 `configFor`
|
|
779
|
+
* 活配置时退回 `configFor` 的规整值。发现接口只回模型 id/efforts,绝不回
|
|
780
|
+
* Claude 的端点凭据。
|
|
417
781
|
* @param ctx - 已注入 webServer 的插件上下文。
|
|
418
782
|
* @param configFor - 返回当前配置的函数(或配置值本身)。
|
|
419
|
-
* @param deps - 探针注入点;`read`
|
|
783
|
+
* @param deps - 探针注入点;`read` / `discover` 可直接替换实现(测试用)。
|
|
420
784
|
* @returns disposer 由 `ctx.effect` 持有。
|
|
421
785
|
*/
|
|
422
786
|
export function registerCapabilitiesRoute(ctx, configFor, deps = {}) {
|
|
423
787
|
const read = deps.read ?? createCapabilitiesReader(deps)
|
|
788
|
+
const discover = deps.discover ?? discoverModels
|
|
789
|
+
const routes = deps.routes ?? (() => collectTierRoutes(ctx))
|
|
424
790
|
const configOf = () => (typeof configFor === 'function' ? attempt(configFor) : configFor)
|
|
425
791
|
ctx.effect(() => ctx.webServer.register({
|
|
426
792
|
kind: 'prefix',
|
|
@@ -432,8 +798,34 @@ export function registerCapabilitiesRoute(ctx, configFor, deps = {}) {
|
|
|
432
798
|
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'forbidden' } })
|
|
433
799
|
return
|
|
434
800
|
}
|
|
435
|
-
const
|
|
436
|
-
if (pathname
|
|
801
|
+
const url = new URL(req.url ?? '/', 'http://dsh.internal')
|
|
802
|
+
if (url.pathname === ROUTES_PATH) {
|
|
803
|
+
if (req.method !== 'GET') {
|
|
804
|
+
writeJson(res, 405, { ok: false, error: { code: 'method-error', message: 'method not allowed' } })
|
|
805
|
+
return
|
|
806
|
+
}
|
|
807
|
+
const value = await routes()
|
|
808
|
+
res.setHeader?.('cache-control', 'no-store')
|
|
809
|
+
writeJson(res, 200, { ok: true, value })
|
|
810
|
+
return
|
|
811
|
+
}
|
|
812
|
+
if (url.pathname === MODELS_PATH) {
|
|
813
|
+
if (req.method !== 'GET') {
|
|
814
|
+
writeJson(res, 405, { ok: false, error: { code: 'method-error', message: 'method not allowed' } })
|
|
815
|
+
return
|
|
816
|
+
}
|
|
817
|
+
const cli = url.searchParams.get('cli')
|
|
818
|
+
if (!DISCOVERY_CLIS.includes(cli)) {
|
|
819
|
+
writeJson(res, 400, { ok: false, error: { code: 'bad-request', message: 'unknown cli' } })
|
|
820
|
+
return
|
|
821
|
+
}
|
|
822
|
+
const value = await discover(cli, config)
|
|
823
|
+
// 发现是按需触发的探测,不能被浏览器缓存成旧清单。
|
|
824
|
+
res.setHeader?.('cache-control', 'no-store')
|
|
825
|
+
writeJson(res, 200, { ok: true, value })
|
|
826
|
+
return
|
|
827
|
+
}
|
|
828
|
+
if (url.pathname !== CAPABILITIES_PATH) {
|
|
437
829
|
writeJson(res, 404, { ok: false, error: { code: 'not-found', message: 'unknown apollo API path' } })
|
|
438
830
|
return
|
|
439
831
|
}
|
|
@@ -444,7 +836,7 @@ export function registerCapabilitiesRoute(ctx, configFor, deps = {}) {
|
|
|
444
836
|
writeJson(res, 200, { ok: true, value: await read(config) })
|
|
445
837
|
} catch (error) {
|
|
446
838
|
// 探针已经各自吞掉失败;这里只剩意外错误——日志留细节,响应只给通用文案。
|
|
447
|
-
ctx.logger?.warn?.(`apollo-panel:
|
|
839
|
+
ctx.logger?.warn?.(`apollo-panel: probe failed: ${error instanceof Error ? error.message : String(error)}`)
|
|
448
840
|
writeJson(res, 500, { ok: false, error: { code: 'probe-failed', message: 'capability probe failed' } })
|
|
449
841
|
}
|
|
450
842
|
},
|
package/src/subagents.js
CHANGED
|
@@ -166,10 +166,9 @@ export function installCliRouting(ctx, config, sessions) {
|
|
|
166
166
|
const catalog = cliCatalog(config)
|
|
167
167
|
if (!catalog) return ''
|
|
168
168
|
return [
|
|
169
|
-
'Apollo CLI subagents:
|
|
170
|
-
'
|
|
171
|
-
'
|
|
172
|
-
'Use send_message, list_agents, interrupt_agent and wait_subagent when available to control their stable child ids. Their CLI permissions come from Apollo settings.',
|
|
169
|
+
'Apollo CLI subagents: subagent also accepts provider="codex" or provider="claude"; model and reasoning_effort default to Apollo settings (list_subagent_models with that provider shows allowed values).',
|
|
170
|
+
'They always run in the background, start in cwd (default: current directory) with permissions from Apollo settings; subagent_fork and agent_preset are unsupported.',
|
|
171
|
+
'Control them by child id with send_message, wait_subagent, list_agents and interrupt_agent.',
|
|
173
172
|
catalog,
|
|
174
173
|
].join('\n')
|
|
175
174
|
},
|
package/presets/eng-noask/NOTICE
DELETED
|
@@ -1,111 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `eng-exit-guard` — 工程模式退出守卫(预设本地插件)。
|
|
3
|
-
*
|
|
4
|
-
* pi-atlas task guard 的 DSH 等价物:回合即将结束时(`agent/turn-stopping`,
|
|
5
|
-
* serial 事件——监听器跑完后 agent-loop 会重查 inbox,`agent.steer()` 加入的
|
|
6
|
-
* next-step 消息使回合不关闭),若本代理仍有运行中的直接子代理或后台任务,
|
|
7
|
-
* 注入一条提醒强制回合继续,直到 wait_subagent / job_output 等待收取、
|
|
8
|
-
* job_kill 停止,或用户中断(被中断的回合在 dispatch 前即 throwIfAborted,
|
|
9
|
-
* 天然不触发本守卫——Esc 始终是逃生口)。
|
|
10
|
-
*
|
|
11
|
-
* 无豁免机制(曾有 exitGuardIgnore 子串清单,因会被用来静默绕过守卫而移除
|
|
12
|
-
* ——实测有会话中 agent 被拦一次后自行写入豁免,后续回合全部静默放行):
|
|
13
|
-
* 任何存活的后台 job 都必须被收取或停止,回合才能收尾。需要跨回合常驻的
|
|
14
|
-
* 服务(dev server、watcher)用 pm2/systemd/裸 nohup 等脱离 DSH job 注册表
|
|
15
|
-
* 的方式管理——守卫只看本地 job 注册表与直接子代理。
|
|
16
|
-
*
|
|
17
|
-
* 每次事件读取当前 profile 的 Apollo Config.exitGuard(默认关闭)。
|
|
18
|
-
* 设置保存后即时生效;旧配置只由宿主执行一次性导入。
|
|
19
|
-
* 查询失败(listChildren / jobs.list 抛错)按 fail-open 处理:告警并放行本次
|
|
20
|
-
* 回合结束,避免查询故障把回合卡死。
|
|
21
|
-
*
|
|
22
|
-
* 零 bare import(同 wait-subagent 约束);消息构造参照 goal-round-driver /
|
|
23
|
-
* repeat-tool-reminder 的 plain-object 模式。
|
|
24
|
-
*/
|
|
25
|
-
|
|
26
|
-
import { currentConfig } from '../../../../src/config.js'
|
|
27
|
-
|
|
28
|
-
export const name = 'eng-exit-guard'
|
|
29
|
-
|
|
30
|
-
export const inject = ['subagents', 'agents']
|
|
31
|
-
|
|
32
|
-
/** 后台任务终态(dsh-jobs-local isTerminal 同款)。 */
|
|
33
|
-
const JOB_TERMINAL = new Set(['completed', 'killed', 'failed'])
|
|
34
|
-
|
|
35
|
-
/** 渲染一条运行项清单行。 */
|
|
36
|
-
function lineOf(prefix, id, detail) {
|
|
37
|
-
return ` - ${prefix} ${id}${detail === undefined || detail === '' ? '' : `(${detail})`}`
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* 构造 steer 提醒消息(模型可见,进入会话日志)。
|
|
42
|
-
* @param children - 运行中的直接子代理 listChildren 条目。
|
|
43
|
-
* @param jobs - 运行中的后台任务快照。
|
|
44
|
-
* @returns 一条 user 角色的 plugin notice 消息。
|
|
45
|
-
*/
|
|
46
|
-
function buildMessage(children, jobs) {
|
|
47
|
-
const lines = []
|
|
48
|
-
for (const child of children) {
|
|
49
|
-
// 0.1.1-rc.2 的条目是 meta:{mode,label},0.1.2 起 mode/label 升为顶级;
|
|
50
|
-
// 兼容回退只影响展示措辞,绝不抛错。
|
|
51
|
-
lines.push(lineOf('子代理', child.id, child.label ?? child.meta?.label))
|
|
52
|
-
}
|
|
53
|
-
for (const job of jobs) {
|
|
54
|
-
lines.push(lineOf('后台任务', job.id, job.label === undefined ? job.kind : `${job.label},${job.kind}`))
|
|
55
|
-
}
|
|
56
|
-
const text = [
|
|
57
|
-
'⚠️ 以下后台工作仍在运行,回合不能结束:',
|
|
58
|
-
...lines,
|
|
59
|
-
'',
|
|
60
|
-
'请先用 wait_subagent 等待运行中的子代理(需要续用其对话用 send_message)、'
|
|
61
|
-
+ '用 job_output 收取后台命令的结果;确属不需要的用 job_kill 停止,全部处理完毕后再收尾。',
|
|
62
|
-
].join('\n')
|
|
63
|
-
return {
|
|
64
|
-
role: 'user',
|
|
65
|
-
content: [{ type: 'text', text }],
|
|
66
|
-
source: {
|
|
67
|
-
kind: 'plugin',
|
|
68
|
-
plugin: 'eng-exit-guard',
|
|
69
|
-
form: 'notice',
|
|
70
|
-
summary: `运行中的后台工作阻止回合结束(子代理 ${children.length},后台任务 ${jobs.length})`,
|
|
71
|
-
},
|
|
72
|
-
id: crypto.randomUUID(),
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/**
|
|
77
|
-
* 收集本代理仍在运行的后台工作。
|
|
78
|
-
* @param ctx - 预设插件上下文(subagents / agents 注入,jobs 可选)。
|
|
79
|
-
* @param agent - 回合即将结束的代理。
|
|
80
|
-
* @returns `{ children, jobs }`;查询失败抛出由调用方按 fail-open 处理。
|
|
81
|
-
*/
|
|
82
|
-
async function collectRunning(ctx, agent) {
|
|
83
|
-
const children = []
|
|
84
|
-
for (const entry of await ctx.subagents.listChildren(agent.id)) {
|
|
85
|
-
if (ctx.agents.get(entry.id)?.status === 'running') children.push(entry)
|
|
86
|
-
}
|
|
87
|
-
const jobs = ctx.get('jobs')
|
|
88
|
-
const runningJobs = jobs === undefined
|
|
89
|
-
? []
|
|
90
|
-
// 0.1.7 起 JobRegistry.list 的参数是 SessionId(0.1.6 及以前是 Agent 对象):
|
|
91
|
-
// 传 Agent 对象不报错但恒不匹配,守卫会静默看不见任何 owned 后台任务。
|
|
92
|
-
: jobs.list(agent.id).filter((job) => !JOB_TERMINAL.has(job.status))
|
|
93
|
-
return { children, jobs: runningJobs }
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
export function apply(ctx) {
|
|
97
|
-
ctx.on('agent/turn-stopping', async (payload) => {
|
|
98
|
-
const agent = payload?.agent
|
|
99
|
-
if (agent === undefined || payload?.signal?.aborted) return
|
|
100
|
-
try {
|
|
101
|
-
if (!currentConfig(ctx).exitGuard) return
|
|
102
|
-
const { children, jobs } = await collectRunning(ctx, agent)
|
|
103
|
-
if (children.length === 0 && jobs.length === 0) return
|
|
104
|
-
if (payload.signal?.aborted) return
|
|
105
|
-
agent.steer(buildMessage(children, jobs))
|
|
106
|
-
} catch (error) {
|
|
107
|
-
// fail-open:查询或注入失败时放行本次回合结束,守卫不应卡死会话。
|
|
108
|
-
ctx.logger?.warn?.(`eng-exit-guard: guard skipped: ${error instanceof Error ? error.message : String(error)}`)
|
|
109
|
-
}
|
|
110
|
-
})
|
|
111
|
-
}
|
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `eng-mode-instructions` — preset-scoped prompt section. This copy serves the
|
|
3
|
-
* eng-noask preset (工程模式(无提问)) and carries, beside the two eng-only
|
|
4
|
-
* behaviors (the exit guard contract and the delegation/orchestration policy),
|
|
5
|
-
* the no-ask discipline: the preset exposes no ask-user tool, so sessions
|
|
6
|
-
* must decide autonomously instead of asking in prose.
|
|
7
|
-
* Model tiers stay in ~/.dsh/AGENTS.md: they mirror this host's
|
|
8
|
-
* `subagent-model-selection.allowedModels` route list, which is local config.
|
|
9
|
-
* Everything else — tool mechanics, background-job etiquette, sleep caps —
|
|
10
|
-
* lives in the harness system prompt or the tool descriptions, and is
|
|
11
|
-
* deliberately NOT repeated here. Dedupe first, inject only the delta.
|
|
12
|
-
*
|
|
13
|
-
* Zero imports on purpose (same constraint as the sibling wait-subagent
|
|
14
|
-
* plugin): this module loads from the preset directory, where Node's upward
|
|
15
|
-
* node_modules walk never reaches the harness install.
|
|
16
|
-
*/
|
|
17
|
-
|
|
18
|
-
export const name = 'eng-mode-instructions'
|
|
19
|
-
|
|
20
|
-
export const inject = ['systemPrompt']
|
|
21
|
-
|
|
22
|
-
const TEXT = [
|
|
23
|
-
'# 工程模式(无提问)专属',
|
|
24
|
-
'',
|
|
25
|
-
'工程模式(eng-noask)面向编码与基础设施任务的无人值守运行:与工程模式一致,但没有任何提问渠道。',
|
|
26
|
-
'',
|
|
27
|
-
'- 无提问纪律:本模式没有 ask 工具,任何情况下都不向用户提问、不以纯文本反问然后停下等待答复;需要用户拍板的事项自主决策,把关键假设与被否掉的替代方案写进最终报告。',
|
|
28
|
-
'- 退出守卫:回合结束前仍有运行中的子代理或后台命令时,回合不会关闭——系统注入运行清单提醒,先用 wait_subagent / job_output 等待收取、job_kill 停掉不需要的,处理完毕再收尾。',
|
|
29
|
-
'',
|
|
30
|
-
'## 委派与编排',
|
|
31
|
-
'',
|
|
32
|
-
'- 开局侦察:非琐碎任务在陌生或大型代码库上开始时,情况了解(仓库结构、相关模块定位、约定与测试入口)默认立即并行派发快速档侦察子代理,同批在一条消息里一起派;主线程只保留定义任务必需的指令类文件阅读(AGENTS.md、PRD、任务说明)。不要为「写好委派 prompt」而先单线程探索——侦察 prompt 只需目标、边界、交付物,缺的上下文正是侦察要产出的东西。',
|
|
33
|
-
'- 默认委派:只读、可并行、无状态的调查一律派出去(含大型/陌生仓库、前端代码、批量排查),同一批独立任务在一条消息里一起派,不在主线程先试一遍。',
|
|
34
|
-
'- 转委派阈值:实现/调试中途冒出的新未知,主线程深挖 1–2 轮未收敛立即派出去;开局侦察不适用此阈值。',
|
|
35
|
-
'- 实现分层:写入范围互不重叠的独立实现块派子代理并行推进;线性推进、强耦合改动、需要持续控制活状态的调试(活进程、串行打补丁)留主线程。',
|
|
36
|
-
'- 扇出用 workflow:同一形状的工作重复多份(审计、迁移、多角度验证、批量修复)即属大型多代理编排,直接写 workflow 脚本一次编排;一两次委派仍用 subagent。',
|
|
37
|
-
'- 派发写明目标、写入范围、交付物与验证方式;引用的前置产出给实际文件路径——子代理看不到父对话。',
|
|
38
|
-
'- 并行实现子代理的写入范围不得重叠;最终集成、审查、验证由主代理负责;子代理报告是线索不是证据,亲自核对实际变更状态。',
|
|
39
|
-
'- 子代理以 error / 空返回收场:先读失败原因,修正路由或重派一次;仍失败且无外部阻塞才主线程接管,并在当轮说明为何接管。',
|
|
40
|
-
].join('\n')
|
|
41
|
-
|
|
42
|
-
export function apply(ctx) {
|
|
43
|
-
ctx.effect(() => ctx.systemPrompt.section({
|
|
44
|
-
name: 'eng:mode-instructions',
|
|
45
|
-
order: 1,
|
|
46
|
-
text: TEXT,
|
|
47
|
-
}), 'eng-mode-instructions.section()')
|
|
48
|
-
}
|