@menghuan1918/dsh-apollo 0.0.0-stage → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/LICENSE +674 -0
  2. package/README.md +232 -2
  3. package/cordis.patch.yml +78 -0
  4. package/docs/DEVELOPMENT.md +629 -0
  5. package/package.json +75 -4
  6. package/presets/eng/NOTICE +2 -0
  7. package/presets/eng/plugins/exit-guard/index.js +111 -0
  8. package/presets/eng/plugins/exit-guard/package.json +7 -0
  9. package/presets/eng/plugins/mode-instructions/index.js +48 -0
  10. package/presets/eng/plugins/mode-instructions/package.json +7 -0
  11. package/presets/eng/plugins/wait-subagent/index.js +228 -0
  12. package/presets/eng/plugins/wait-subagent/package.json +7 -0
  13. package/presets/eng-noask/NOTICE +2 -0
  14. package/presets/eng-noask/plugins/exit-guard/index.js +111 -0
  15. package/presets/eng-noask/plugins/exit-guard/package.json +7 -0
  16. package/presets/eng-noask/plugins/mode-instructions/index.js +48 -0
  17. package/presets/eng-noask/plugins/mode-instructions/package.json +7 -0
  18. package/presets/eng-noask/plugins/wait-subagent/index.js +228 -0
  19. package/presets/eng-noask/plugins/wait-subagent/package.json +7 -0
  20. package/presets/eng-noask.patch.yml +341 -0
  21. package/presets/eng.patch.yml +341 -0
  22. package/skills/brainstorming/SKILL.md +90 -0
  23. package/skills/brainstorming/spec-document-reviewer-prompt.md +50 -0
  24. package/skills/code-review/SKILL.md +90 -0
  25. package/skills/dev/SKILL.md +43 -0
  26. package/skills/diagnosing-bugs/SKILL.md +132 -0
  27. package/skills/diagnosing-bugs/scripts/hitl-loop.template.sh +55 -0
  28. package/skills/exec/SKILL.md +91 -0
  29. package/skills/handoff/SKILL.md +23 -0
  30. package/skills/main-agent-driven-development/SKILL.md +64 -0
  31. package/skills/tdd/SKILL.md +34 -0
  32. package/skills/tdd/mocking.md +59 -0
  33. package/skills/tdd/tests.md +77 -0
  34. package/src/cli/claude.js +166 -0
  35. package/src/cli/codex.js +164 -0
  36. package/src/cli/jsonl.js +216 -0
  37. package/src/cli-adapter.js +198 -0
  38. package/src/cli-sessions.js +69 -0
  39. package/src/client.js +815 -0
  40. package/src/config.js +301 -0
  41. package/src/dsh-home.js +38 -0
  42. package/src/index.js +181 -0
  43. package/src/panel.js +452 -0
  44. package/src/settings.js +191 -0
  45. package/src/subagents.js +199 -0
  46. package/third-party/LICENSE-MIT-dsh-standard-preset +21 -0
  47. package/third-party/NOTICE-mattpocock-skills +21 -0
package/package.json CHANGED
@@ -1,6 +1,77 @@
1
1
  {
2
2
  "name": "@menghuan1918/dsh-apollo",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.3.0",
4
+ "description": "DSH plugin: 流程技能、工程模式、Codex/Claude 可续接子代理与统一设置。纯 ESM,无构建步骤。",
5
+ "license": "GPL-3.0-or-later",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/Menghuan1918/dsh-apollo.git"
9
+ },
10
+ "type": "module",
11
+ "main": "src/index.js",
12
+ "exports": {
13
+ ".": "./src/index.js",
14
+ "./client": "./src/client.js",
15
+ "./presets/*": "./presets/*",
16
+ "./package.json": "./package.json"
17
+ },
18
+ "engines": {
19
+ "node": "^22.19.0 || >=24.0.0",
20
+ "dsh": ">=0.2.1-rc.2 <0.3.0"
21
+ },
22
+ "scripts": {
23
+ "test": "node --test"
24
+ },
25
+ "files": [
26
+ "src",
27
+ "docs",
28
+ "skills",
29
+ "presets",
30
+ "third-party",
31
+ "cordis.patch.yml",
32
+ "README.md",
33
+ "LICENSE"
34
+ ],
35
+ "keywords": [
36
+ "dsh",
37
+ "dsh-plugin",
38
+ "deepseek-harness",
39
+ "deepseek-harness-plugin",
40
+ "workflow",
41
+ "orchestration",
42
+ "agent-preset"
43
+ ],
44
+ "publishConfig": {
45
+ "registry": "https://registry.npmjs.org/",
46
+ "access": "public"
47
+ },
48
+ "devDependencies": {
49
+ "js-yaml": "^4.2.0",
50
+ "react": "18.3.1",
51
+ "react-test-renderer": "18.3.1"
52
+ },
53
+ "dependencies": {
54
+ "@deepseek-ai/schemastery": "~3.18.4",
55
+ "dsh-better-goal": "0.1.0"
56
+ },
57
+ "peerDependencies": {
58
+ "react": "^18.2.0"
59
+ },
60
+ "dsh": {
61
+ "bundle": {
62
+ "patch": [
63
+ "./cordis.patch.yml",
64
+ "./presets/eng.patch.yml",
65
+ "./presets/eng-noask.patch.yml"
66
+ ]
67
+ },
68
+ "client": {
69
+ "inject": [
70
+ "@deepseek-ai/dsh-client-runtime",
71
+ "@deepseek-ai/dsh-client-connection",
72
+ "@deepseek-ai/dsh-client-ui-settings"
73
+ ],
74
+ "platform": "web"
75
+ }
76
+ }
77
+ }
@@ -0,0 +1,2 @@
1
+ presets/eng.patch.yml 的 config.plugins 块改编自 DeepSeek Harness 内置
2
+ Standard preset(MIT)。原始 DeepSeek 版权和 MIT 许可声明见开源仓库。
@@ -0,0 +1,111 @@
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
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "name": "dsh-preset-eng-exit-guard",
3
+ "private": true,
4
+ "type": "module",
5
+ "main": "index.js",
6
+ "version": "0.1.0"
7
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * `eng-mode-instructions` — preset-scoped prompt section for the eng preset
3
+ * (工程模式). Carries the two eng-only behaviors no other surface states:
4
+ * - the exit guard contract;
5
+ * - the delegation/orchestration policy (when to delegate, write scopes,
6
+ * verification, failure handling), moved out of the user-global
7
+ * ~/.dsh/AGENTS.md on 2026-09-11.
8
+ * Model tiers stay in ~/.dsh/AGENTS.md: they mirror this host's
9
+ * `subagent-model-selection.allowedModels` route list, which is local config.
10
+ * Everything else — tool mechanics, background-job etiquette, sleep caps —
11
+ * lives in the harness system prompt or the tool descriptions, and is
12
+ * deliberately NOT repeated here. Dedupe first, inject only the delta.
13
+ *
14
+ * Zero imports on purpose (same constraint as the sibling wait-subagent
15
+ * plugin): this module loads from the preset directory, where Node's upward
16
+ * node_modules walk never reaches the harness install.
17
+ */
18
+
19
+ export const name = 'eng-mode-instructions'
20
+
21
+ export const inject = ['systemPrompt']
22
+
23
+ const TEXT = [
24
+ '# 工程模式专属',
25
+ '',
26
+ '工程模式(eng)面向编码与基础设施任务。',
27
+ '',
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
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "name": "dsh-preset-eng-mode-instructions",
3
+ "private": true,
4
+ "type": "module",
5
+ "main": "index.js",
6
+ "version": "0.1.0"
7
+ }
@@ -0,0 +1,228 @@
1
+ /**
2
+ * `wait_subagent` tool — explicit wait for continuable children to settle.
3
+ *
4
+ * Preset-local plugin for the eng preset (resolved relative to this preset's
5
+ * directory). Continuable subagents live in the `subagents` registry, not the
6
+ * `jobs` registry, so the only settlement signal is the scoped `subagent/end`
7
+ * event. The tool takes an ARRAY of durable child ids (`subagent_id`), awaits
8
+ * them all concurrently (duplicates deduped, order preserved), and returns one
9
+ * SHORT line per id (done + stopReason).
10
+ *
11
+ * The children's content is none of this tool's business: the subagent manager
12
+ * unconditionally delivers a settlement notice to the parent BEFORE
13
+ * `subagent/end` fires (dispose order: notifySettlement() →
14
+ * observer.settle()), carrying the one-line account and the closing message.
15
+ * A busy parent is steered, so the notice sits in the pending next-step queue
16
+ * and reaches the model right after this tool result — exactly once, per
17
+ * child, by the framework. Earlier designs returned the child's final message
18
+ * here AND spliced the duplicate notice (and any mid-run `report`) out of the
19
+ * parent inbox; that coupled this plugin to `agent.inbox` internals and to
20
+ * the `subagent-report` source kind, which upstream removed in
21
+ * 0.1.2-alpha.4 (bidirectional `send_message` replaced `report`). Returning
22
+ * "done" lines and leaving delivery to the framework has no such coupling.
23
+ * Mid-run messages the child sends (`send_message`, source kind
24
+ * `agent-message`) likewise stay in place.
25
+ *
26
+ * `timeout_ms` is ONE shared window over the whole batch (all timers start at
27
+ * the same instant), matching the semantics same-message parallel calls had.
28
+ * An id that is not a direct child fails the whole call fast — nobody is
29
+ * waited for — keeping the contract the single-id version had. A child
30
+ * dispatched moments ago may not have started its first turn yet: its status
31
+ * is not `running` and no settlement exists. Returning early with a "not
32
+ * running" note there (the pre-grace behavior) ended the turn before the
33
+ * child even started and forced a re-wait on the next turn — session audit
34
+ * 2f5dd618. Instead, a not-yet-started child gets a startup grace window
35
+ * (START_GRACE_MS): the tool keeps waiting while the child spins up,
36
+ * settles, the optional timeout elapses, or the grace expires without the
37
+ * child ever starting (only then a retryable informational line).
38
+ *
39
+ * Zero imports on purpose: the module loads from the preset directory, where
40
+ * Node's upward node_modules walk never reaches the harness install, so bare
41
+ * package imports would fail. Everything needed comes from the context.
42
+ */
43
+
44
+ export const name = 'tool-wait-subagent'
45
+
46
+ export const inject = ['tools', 'subagents', 'agents']
47
+
48
+ /** Grace window for a just-dispatched child that has not started running yet. */
49
+ const START_GRACE_MS = 30_000
50
+
51
+ /** Status polling interval while waiting for a not-yet-started child. */
52
+ const POLL_MS = 250
53
+
54
+ export function apply(ctx) {
55
+ /** childId -> { time, info } — latest settlement observed since mount. */
56
+ const settled = new Map()
57
+ /** childId -> finish(info) callbacks awaiting settlement. */
58
+ const waiters = new Map()
59
+
60
+ ctx.on('subagent/end', (info) => {
61
+ const id = info && info.id
62
+ if (typeof id !== 'string') return
63
+ settled.set(id, { time: Date.now(), info })
64
+ const list = waiters.get(id)
65
+ if (list !== undefined) {
66
+ waiters.delete(id)
67
+ for (const finish of [...list]) finish({ kind: 'settled', info })
68
+ }
69
+ })
70
+
71
+ ctx.tools.register({
72
+ name: 'wait_subagent',
73
+ description: 'Wait for one or more background continuable subagents (spawned by subagent or subagent_fork) to finish their current turn. Pass their durable ids as an array in subagent_id — all are awaited concurrently and the result carries one short line per id (done + stop reason); a child\'s closing message arrives as the framework settlement notice immediately after this result; do not expect it here. Blocks until every listed child settles, the optional timeout_ms elapses (one shared window over all ids), or the user interrupts — without timeout_ms, until completion. A just-dispatched child that has not started yet is awaited through a brief startup grace rather than reported as not running. Use it when your next step depends on those results; with independent work remaining, prefer background plus completion notices. Do not pass bash job ids — those use job_output.',
74
+ parameters: {
75
+ type: 'object',
76
+ properties: {
77
+ subagent_id: {
78
+ type: 'array',
79
+ items: { type: 'string' },
80
+ minItems: 1,
81
+ description: 'One or more durable subagent ids returned by the subagent or subagent_fork tool call; every id is awaited concurrently.',
82
+ },
83
+ timeout_ms: {
84
+ type: 'integer',
85
+ description: 'Optional maximum wait in milliseconds applied as one shared window over all ids; omit to wait until every child settles. On expiry the tool returns while some children are still running.',
86
+ },
87
+ },
88
+ required: ['subagent_id'],
89
+ },
90
+ output: {
91
+ schema: { type: 'string' },
92
+ render: (_args, value) => [{ type: 'text', text: value }],
93
+ },
94
+ isConcurrencySafe: () => true,
95
+ async execute(args, exec) {
96
+ // Dedupe preserving input order; the schema already enforces a
97
+ // non-empty array, the guard below only covers malformed direct calls.
98
+ const ids = [...new Set(Array.isArray(args.subagent_id) ? args.subagent_id : [])]
99
+ if (ids.length === 0) return 'subagent_id must be a non-empty array of subagent ids.'
100
+ const parent = exec.agent
101
+ if (parent === undefined) throw new Error('wait_subagent requires a calling agent (exec.agent was undefined)')
102
+
103
+ // Verify every id is one of this agent's direct children — fail fast,
104
+ // waiting for nobody (the contract the single-id version had).
105
+ let entries
106
+ try {
107
+ entries = await ctx.subagents.listChildren(parent.id, exec.signal)
108
+ } catch (error) {
109
+ return 'listChildren failed: ' + (error && error.message ? error.message : String(error))
110
+ }
111
+ const known = new Set(entries.map((entry) => entry.id))
112
+ const unknown = ids.filter((id) => !known.has(id))
113
+ if (unknown.length > 0) {
114
+ return `unknown subagent ${unknown.map((id) => `"${id}"`).join(', ')}: not a direct child of this agent (list_agents shows your children; only depth-1 children can be awaited).`
115
+ }
116
+
117
+ const statusOf = (id) => {
118
+ const agent = ctx.agents.get(id)
119
+ return agent === undefined ? 'ready' : agent.status === 'running' ? 'running' : 'idle'
120
+ }
121
+
122
+ /** Settlement line: short done line; content belongs to the framework notice. */
123
+ const doneLine = (id, info) => `subagent ${id} done (${info && info.stopReason ? info.stopReason : 'completed'}); its closing message follows as the settlement notice.`
124
+
125
+ const startedAt = Date.now()
126
+ const capMs = typeof args.timeout_ms === 'number' && args.timeout_ms > 0 ? args.timeout_ms : undefined
127
+
128
+ /**
129
+ * Wait for one child. Resolves with {kind:'settled',info} |
130
+ * {kind:'timedOut'} | {kind:'aborted'} | {kind:'notStarted'}. The
131
+ * per-id machinery (settled fast path, waiter registration, timeout
132
+ * timer, startup-grace poller) is the single-id logic of old,
133
+ * unchanged; Promise.all over ids gives the concurrent batch.
134
+ */
135
+ const waitOne = (id) => {
136
+ // Fast path: the child is not running and a settlement is already on
137
+ // record — resolve immediately (the notice was already delivered when
138
+ // the child settled, so it is in the conversation history).
139
+ if (statusOf(id) !== 'running') {
140
+ const hit = settled.get(id)
141
+ if (hit !== undefined) return Promise.resolve({ kind: 'settled', info: hit.info })
142
+ }
143
+
144
+ return new Promise((resolve) => {
145
+ let done = false
146
+ let timer
147
+ let poller
148
+ const finish = (value) => {
149
+ if (done) return
150
+ done = true
151
+ if (poller !== undefined) clearInterval(poller)
152
+ const list = waiters.get(id)
153
+ if (list !== undefined) {
154
+ const index = list.indexOf(finish)
155
+ if (index >= 0) list.splice(index, 1)
156
+ if (list.length === 0) waiters.delete(id)
157
+ }
158
+ if (timer !== undefined) clearTimeout(timer)
159
+ if (exec.signal !== undefined) exec.signal.removeEventListener('abort', onAbort)
160
+ resolve(value)
161
+ }
162
+ const onAbort = () => finish({ kind: 'aborted' })
163
+
164
+ const list = waiters.get(id) ?? []
165
+ list.push(finish)
166
+ waiters.set(id, list)
167
+ // All ids share startedAt, so one capMs value is one shared window.
168
+ if (capMs !== undefined) timer = setTimeout(() => finish({ kind: 'timedOut' }), capMs)
169
+ if (exec.signal !== undefined) {
170
+ if (exec.signal.aborted) {
171
+ finish({ kind: 'aborted' })
172
+ return
173
+ }
174
+ exec.signal.addEventListener('abort', onAbort, { once: true })
175
+ }
176
+
177
+ // Startup grace: a freshly dispatched child sits between spawn and
178
+ // its first turn (status not `running`, no settlement yet). Keep the
179
+ // wait alive while it spins up; once `running`, only `subagent/end`
180
+ // (the listener above) resolves us. If the grace expires without the
181
+ // child ever starting or settling, fall back to a retryable note
182
+ // instead of the old misleading "is not running" early return.
183
+ poller = setInterval(() => {
184
+ if (done) return
185
+ const hit = settled.get(id)
186
+ if (hit !== undefined) {
187
+ finish({ kind: 'settled', info: hit.info })
188
+ return
189
+ }
190
+ if (statusOf(id) !== 'running' && Date.now() - startedAt >= START_GRACE_MS) {
191
+ finish({ kind: 'notStarted' })
192
+ }
193
+ }, POLL_MS)
194
+
195
+ // Re-check: the child may have settled between the fast path above
196
+ // and waiter registration. Only a settlement AFTER this wait started
197
+ // counts as a live result.
198
+ const hit = settled.get(id)
199
+ if (hit !== undefined && hit.time >= startedAt) finish({ kind: 'settled', info: hit.info })
200
+ })
201
+ }
202
+
203
+ const outcomes = await Promise.all(ids.map((id) => waitOne(id)))
204
+
205
+ // Every waiter shares one abort signal, so an interruption either
206
+ // aborts the whole batch or lands after some children already settled.
207
+ // Collapse the all-aborted case into one line; mixed cases keep
208
+ // per-id lines so the settled ones are not lost.
209
+ if (outcomes.length > 0 && outcomes.every((outcome) => outcome.kind === 'aborted')) {
210
+ return `wait for ${ids.length} subagent${ids.length === 1 ? '' : 's'} was aborted by user interruption; the children may still be running.`
211
+ }
212
+
213
+ const lines = outcomes.map((outcome, index) => {
214
+ const id = ids[index]
215
+ if (outcome.kind === 'aborted') return `wait for subagent ${id} was aborted by user interruption; the child may still be running.`
216
+ if (outcome.kind === 'timedOut') return `timed out waiting for subagent ${id}; it is still running. You will be notified when it finishes — you can wait_subagent it again or continue with other work.`
217
+ if (outcome.kind === 'notStarted') {
218
+ return `subagent ${id} has not started after ${Math.round(START_GRACE_MS / 1000)}s (status: ${statusOf(id)}). It may still be spinning up — call wait_subagent again, or continue with other work; you will be notified when it finishes.`
219
+ }
220
+ // Real settlement: short done line; the framework's settlement notice
221
+ // (already delivered to the parent inbox before `subagent/end`)
222
+ // carries the closing message — nothing to consume or deduplicate.
223
+ return doneLine(id, outcome.info)
224
+ })
225
+ return lines.join('\n')
226
+ },
227
+ })
228
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "name": "dsh-preset-eng-wait-subagent",
3
+ "private": true,
4
+ "type": "module",
5
+ "main": "index.js",
6
+ "version": "0.1.0"
7
+ }
@@ -0,0 +1,2 @@
1
+ presets/eng-noask.patch.yml 的 config.plugins 块改编自 DeepSeek Harness 内置
2
+ Standard preset(MIT)。原始 DeepSeek 版权和 MIT 许可声明见开源仓库。
@@ -0,0 +1,111 @@
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
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "name": "dsh-preset-eng-exit-guard",
3
+ "private": true,
4
+ "type": "module",
5
+ "main": "index.js",
6
+ "version": "0.1.0"
7
+ }