@menghuan1918/dsh-apollo 0.0.0-stage → 0.4.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 (40) hide show
  1. package/LICENSE +674 -0
  2. package/README.md +253 -2
  3. package/cordis.patch.yml +78 -0
  4. package/docs/DEVELOPMENT.md +682 -0
  5. package/package.json +76 -4
  6. package/presets/eng/NOTICE +3 -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 +124 -0
  10. package/presets/eng/plugins/mode-instructions/package.json +7 -0
  11. package/presets/eng/plugins/wait-subagent/index.js +233 -0
  12. package/presets/eng/plugins/wait-subagent/package.json +7 -0
  13. package/presets/eng-ptc.patch.yml +186 -0
  14. package/presets/eng.patch.yml +338 -0
  15. package/skills/brainstorming/SKILL.md +90 -0
  16. package/skills/brainstorming/spec-document-reviewer-prompt.md +50 -0
  17. package/skills/code-review/SKILL.md +90 -0
  18. package/skills/dev/SKILL.md +41 -0
  19. package/skills/diagnosing-bugs/SKILL.md +132 -0
  20. package/skills/diagnosing-bugs/scripts/hitl-loop.template.sh +55 -0
  21. package/skills/exec/SKILL.md +78 -0
  22. package/skills/handoff/SKILL.md +23 -0
  23. package/skills/main-agent-driven-development/SKILL.md +64 -0
  24. package/skills/tdd/SKILL.md +34 -0
  25. package/skills/tdd/mocking.md +59 -0
  26. package/skills/tdd/tests.md +77 -0
  27. package/src/cli/claude.js +166 -0
  28. package/src/cli/codex.js +164 -0
  29. package/src/cli/jsonl.js +216 -0
  30. package/src/cli-adapter.js +198 -0
  31. package/src/cli-sessions.js +69 -0
  32. package/src/client.js +1128 -0
  33. package/src/config.js +327 -0
  34. package/src/dsh-home.js +38 -0
  35. package/src/index.js +181 -0
  36. package/src/panel.js +844 -0
  37. package/src/settings.js +191 -0
  38. package/src/subagents.js +198 -0
  39. package/third-party/LICENSE-MIT-dsh-standard-preset +21 -0
  40. package/third-party/NOTICE-mattpocock-skills +21 -0
package/package.json CHANGED
@@ -1,6 +1,78 @@
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.4.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
+ "prompts": "node scripts/render-prompts.mjs"
25
+ },
26
+ "files": [
27
+ "src",
28
+ "docs",
29
+ "skills",
30
+ "presets",
31
+ "third-party",
32
+ "cordis.patch.yml",
33
+ "README.md",
34
+ "LICENSE"
35
+ ],
36
+ "keywords": [
37
+ "dsh",
38
+ "dsh-plugin",
39
+ "deepseek-harness",
40
+ "deepseek-harness-plugin",
41
+ "workflow",
42
+ "orchestration",
43
+ "agent-preset"
44
+ ],
45
+ "publishConfig": {
46
+ "registry": "https://registry.npmjs.org/",
47
+ "access": "public"
48
+ },
49
+ "devDependencies": {
50
+ "js-yaml": "^4.2.0",
51
+ "react": "18.3.1",
52
+ "react-test-renderer": "18.3.1"
53
+ },
54
+ "dependencies": {
55
+ "@deepseek-ai/schemastery": "~3.18.4",
56
+ "dsh-better-goal": "0.1.0"
57
+ },
58
+ "peerDependencies": {
59
+ "react": "^18.2.0"
60
+ },
61
+ "dsh": {
62
+ "bundle": {
63
+ "patch": [
64
+ "./cordis.patch.yml",
65
+ "./presets/eng.patch.yml",
66
+ "./presets/eng-ptc.patch.yml"
67
+ ]
68
+ },
69
+ "client": {
70
+ "inject": [
71
+ "@deepseek-ai/dsh-client-runtime",
72
+ "@deepseek-ai/dsh-client-connection",
73
+ "@deepseek-ai/dsh-client-ui-settings"
74
+ ],
75
+ "platform": "web"
76
+ }
77
+ }
78
+ }
@@ -0,0 +1,3 @@
1
+ presets/eng.patch.yml 与 presets/eng-ptc.patch.yml 的 config.plugins 块改编自
2
+ DeepSeek Harness 内置 Standard / PTC preset(MIT)。原始 DeepSeek 版权和 MIT
3
+ 许可声明见开源仓库。
@@ -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('subagent', child.id, child.label ?? child.meta?.label))
52
+ }
53
+ for (const job of jobs) {
54
+ lines.push(lineOf('background job', job.id, job.label === undefined ? job.kind : `${job.label}, ${job.kind}`))
55
+ }
56
+ const text = [
57
+ 'This background work is still running, so the turn cannot end yet:',
58
+ ...lines,
59
+ '',
60
+ 'Wait for running subagents with wait_subagent (use send_message to continue one), '
61
+ + 'collect background command results with job_output, and stop work you no longer need with job_kill. Finish only after all of it is handled.',
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,124 @@
1
+ /**
2
+ * `eng-mode-instructions` — preset-scoped prompt section for 工程模式
3
+ * (`eng`) and 工程(PTC)模式 (`eng-ptc`).
4
+ *
5
+ * Carries what no other surface states: the general engineering discipline
6
+ * (moved out of the user-global ~/.dsh/AGENTS.md, which keeps only
7
+ * machine-local conventions), the delegation policy, the subagent model tiers
8
+ * rendered from Apollo settings (`tiers.*`), and the exit guard contract while
9
+ * that switch is on. Tool mechanics, background-job etiquette and run_code
10
+ * syntax live in the harness prompt and tool descriptions and are NOT
11
+ * repeated here.
12
+ *
13
+ * The text is a function re-rendered per request from the live Apollo config,
14
+ * so settings edits apply without remounting; an edit costs the prefix cache
15
+ * once, which is acceptable for rarely changed settings. The row config
16
+ * `ptc: true` (set by eng-ptc) adds a rule for fanning out subagents inside
17
+ * one run_code program (workflow usage is left to the harness tool guidance); run_code mechanics themselves
18
+ * come from the harness `tools:sdk` / `tools:ptc-only` sections.
19
+ */
20
+
21
+ import z from '@deepseek-ai/schemastery'
22
+ import { currentConfig } from '../../../../src/config.js'
23
+
24
+ export const name = 'eng-mode-instructions'
25
+
26
+ export const inject = ['systemPrompt']
27
+
28
+ export const Config = z.object({
29
+ /** Render the PTC variant (eng-ptc). */
30
+ ptc: z.boolean().default(false),
31
+ })
32
+
33
+ const PRINCIPLES = `# Engineering mode
34
+
35
+ For coding and infrastructure work. These are defaults; explicit user instructions take precedence.
36
+
37
+ ## Principles
38
+ - KISS / YAGNI: use the simplest approach that works. Do not add abstractions, indirection, or options the task does not need.
39
+ - Inspect first: read the actual files, run the actual commands, check the actual state, then judge. When evidence and judgment disagree, evidence wins. Look up unfamiliar library/API behavior instead of relying on memory.
40
+ - Fix the root cause: trace a bad value back to the violated assumption; no workarounds that only hide the symptom.
41
+ - One coherent change at a time, each with an observable result and a pass/fail check; do not bundle unrelated edits.
42
+ - Follow existing conventions: match surrounding naming, style, error handling, and patterns; use the project's own build/test/lint commands; read the repo's coding-standards files (if any) before writing code.
43
+ - Think about requirements, approach, and verification; do not draft whole implementations in your reasoning. If your reasoning starts repeating, stop, change angle, or go gather evidence.
44
+
45
+ ## Avoid over-engineering and over-defending
46
+ - Do not add complexity for speculative needs or vanishingly unlikely race/security edge cases. If one seems necessary, first explain the concrete failure mode, its likelihood, and the complexity cost to the user and get agreement; never silently expand scope for theoretical robustness.
47
+ - Validate unknown input once, at the boundary that owns it, and pass settled values inward; do not re-check what a schema, constructor, or type already guarantees.
48
+ - Put try/catch only where the error can actually be handled; never use it to swallow errors or hide failures.
49
+ - Do not extract single-use helpers unless they name a real concept or isolate a genuinely complex boundary.
50
+ - Comment non-obvious constraints and surprising behavior, not what the code already says.
51
+ - Test the real implementation; minimize mocks; do not copy implementation logic into tests.
52
+
53
+ ## Verification and reporting
54
+ - Claim success only with fresh evidence from the changed state. Passing lint does not prove runtime behavior; unit tests do not prove deployment integration.
55
+ - In reports, separate verified, executed-but-unverified, and not executed. Never fake progress.
56
+
57
+ ## Boundaries
58
+ - Changing shared branches such as main or dev needs explicit authorization. Within the task, commits and merges on isolated branches are fine; create worktrees for isolation when useful.
59
+ - Preserve unrelated changes and keep the main worktree clean; recurring local files can go in .git/info/exclude.
60
+ - Commit with the configured global Git identity, never placeholder identities such as bot or agent.
61
+ - Tell the user when you install system packages or download dependencies outside the package manager; ask where to install missing unmanaged dependencies; prefer prebuilt packages over compiling.
62
+ - No filesystem-wide or whole-home-directory searches without asking first.
63
+ - Deviating from these rules needs the user's consent. Before asking, explore the code, tests, and prior decisions yourself; ask only questions whose answers change what you do, each with a recommended answer and why.
64
+
65
+ ## Commands and processes
66
+ - One background job per goal: check job_list first; do not run near-duplicate commands in parallel.
67
+ - Work on data where it already is (including remote mounts) instead of downloading or copying large files.
68
+ - Before killing a process, confirm its full command line and PID with \`pgrep -af <keyword>\`, then \`kill <PID>\`; never pkill / killall.
69
+ - Never sleep longer than 60s at once; for longer waits use stepped probes, sentinel files, or completion notices.
70
+ - Run services that must outlive the turn (dev servers, watchers) under pm2/nohup outside the job registry, and say which ones are still running when you finish.`
71
+
72
+ const DELEGATION_HEAD = `## Delegation
73
+ - Subagents cannot see this conversation: state the goal, write scope, deliverable, and verification, and give real file paths for prior outputs.
74
+ - Opening reconnaissance: when starting non-trivial work in an unfamiliar or large codebase, immediately dispatch fast-tier scouts in parallel (structure, relevant modules, conventions, test entry points), all in one message; the main agent reads only the instruction files that define the task. A scout prompt needs only goal, boundaries, and deliverable.
75
+ - Delegate read-only, parallelizable, stateless investigation by default instead of trying it in the main thread first; when a new unknown appears mid-task and 1–2 rounds of digging do not converge, delegate it.
76
+ - Implementation chunks with non-overlapping write scopes can run in parallel; keep linear work, tightly coupled changes, and debugging that needs live state in the main thread.`
77
+
78
+ const FANOUT_PTC = '- For repeated same-shaped work, dispatch all subagents inside one run_code program, then collect them with wait_subagent.'
79
+
80
+ const DELEGATION_TAIL = `- When you need a subagent's result, wait patiently with wait_subagent. Do not poll or prod it with send_message; send a message only to give it genuinely new information or a correction.
81
+ - The main agent owns integration, review, and final verification. Subagent reports are leads, not evidence: check the actual changes yourself.
82
+ - When a subagent ends in an error or empty result, read why, fix the cause, and re-dispatch once; take over in the main thread only if it fails again without an external blocker, and say why.`
83
+
84
+ const EXIT_GUARD = `## Exit guard
85
+ - If subagents or background commands are still running when the turn would end, the system injects a reminder and the turn continues. Collect them with wait_subagent / job_output, stop unneeded ones with job_kill, then finish.`
86
+
87
+ /** One tier as model-facing argument text; an unset tier inherits the parent route. */
88
+ export function renderTier(tier) {
89
+ if (!tier?.provider || !tier?.model) return 'omit provider / model / reasoning_effort to inherit the parent route'
90
+ const parts = [`provider: \`${tier.provider}\``, `model: \`${tier.model}\``]
91
+ if (tier.reasoningEffort) parts.push(`reasoning_effort: \`${tier.reasoningEffort}\``)
92
+ return parts.join(' · ')
93
+ }
94
+
95
+ /**
96
+ * Render the full section text.
97
+ * @param config - normalized Apollo config (`exitGuard`, `tiers`).
98
+ * @param options - `{ ptc }` from this row's config.
99
+ * @returns the section markdown.
100
+ */
101
+ export function renderInstructions(config, options = {}) {
102
+ const tiers = `## Subagent model tiers
103
+ - Fast tier (exploration, reconnaissance, mechanical analysis, batch triage): ${renderTier(config.tiers?.fast)}
104
+ - Quality tier (complex implementation, debugging, review, design): ${renderTier(config.tiers?.quality)}
105
+ - Start exploration on the fast tier; when coverage falls short or key questions remain, re-dispatch the unresolved part on the quality tier. Escalating means re-dispatching, not modifying an existing subagent.
106
+ - subagent_fork takes no model choice: it keeps the parent route to reuse the prefix cache.`
107
+ const blocks = [
108
+ PRINCIPLES,
109
+ [DELEGATION_HEAD, ...(options.ptc ? [FANOUT_PTC] : []), DELEGATION_TAIL].join('\n'),
110
+ tiers,
111
+ ]
112
+ if (config.exitGuard) blocks.push(EXIT_GUARD)
113
+ return blocks.join('\n\n')
114
+ }
115
+
116
+ export function apply(ctx, config) {
117
+ const options = { ptc: config?.ptc === true }
118
+ ctx.effect(() => ctx.systemPrompt.section({
119
+ name: 'eng:mode-instructions',
120
+ order: 1,
121
+ interpolate: false,
122
+ text: () => renderInstructions(currentConfig(ctx), options),
123
+ }), 'eng-mode-instructions.section()')
124
+ }
@@ -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,233 @@
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
+ * Values below MIN_TIMEOUT_MS (15 minutes) are raised to it: short polling
29
+ * waits only burned turns, since a finishing child notifies the parent anyway.
30
+ * An id that is not a direct child fails the whole call fast — nobody is
31
+ * waited for — keeping the contract the single-id version had. A child
32
+ * dispatched moments ago may not have started its first turn yet: its status
33
+ * is not `running` and no settlement exists. Returning early with a "not
34
+ * running" note there (the pre-grace behavior) ended the turn before the
35
+ * child even started and forced a re-wait on the next turn — session audit
36
+ * 2f5dd618. Instead, a not-yet-started child gets a startup grace window
37
+ * (START_GRACE_MS): the tool keeps waiting while the child spins up,
38
+ * settles, the optional timeout elapses, or the grace expires without the
39
+ * child ever starting (only then a retryable informational line).
40
+ *
41
+ * Zero imports on purpose: the module loads from the preset directory, where
42
+ * Node's upward node_modules walk never reaches the harness install, so bare
43
+ * package imports would fail. Everything needed comes from the context.
44
+ */
45
+
46
+ export const name = 'tool-wait-subagent'
47
+
48
+ export const inject = ['tools', 'subagents', 'agents']
49
+
50
+ /** Grace window for a just-dispatched child that has not started running yet. */
51
+ const START_GRACE_MS = 30_000
52
+
53
+ /** Floor for a caller-supplied timeout_ms (15 minutes). */
54
+ const MIN_TIMEOUT_MS = 15 * 60_000
55
+
56
+ /** Status polling interval while waiting for a not-yet-started child. */
57
+ const POLL_MS = 250
58
+
59
+ export function apply(ctx) {
60
+ /** childId -> { time, info } — latest settlement observed since mount. */
61
+ const settled = new Map()
62
+ /** childId -> finish(info) callbacks awaiting settlement. */
63
+ const waiters = new Map()
64
+
65
+ ctx.on('subagent/end', (info) => {
66
+ const id = info && info.id
67
+ if (typeof id !== 'string') return
68
+ settled.set(id, { time: Date.now(), info })
69
+ const list = waiters.get(id)
70
+ if (list !== undefined) {
71
+ waiters.delete(id)
72
+ for (const finish of [...list]) finish({ kind: 'settled', info })
73
+ }
74
+ })
75
+
76
+ ctx.tools.register({
77
+ name: 'wait_subagent',
78
+ description: 'Wait for direct subagents (from subagent or subagent_fork) to finish their current turn. Pass every id in subagent_id; all are awaited together and each gets one done line, while its closing message arrives as the settlement notice right after. Use it when your next step needs the results, instead of messaging the child to check on it. Not for bash jobs (use job_output).',
79
+ parameters: {
80
+ type: 'object',
81
+ properties: {
82
+ subagent_id: {
83
+ type: 'array',
84
+ items: { type: 'string' },
85
+ minItems: 1,
86
+ description: 'Durable ids returned by subagent or subagent_fork.',
87
+ },
88
+ timeout_ms: {
89
+ type: 'integer',
90
+ description: 'Optional shared wait window in ms; values below 900000 (15 minutes) are raised to 900000. Omit to wait until every child settles.',
91
+ },
92
+ },
93
+ required: ['subagent_id'],
94
+ },
95
+ output: {
96
+ schema: { type: 'string' },
97
+ render: (_args, value) => [{ type: 'text', text: value }],
98
+ },
99
+ isConcurrencySafe: () => true,
100
+ async execute(args, exec) {
101
+ // Dedupe preserving input order; the schema already enforces a
102
+ // non-empty array, the guard below only covers malformed direct calls.
103
+ const ids = [...new Set(Array.isArray(args.subagent_id) ? args.subagent_id : [])]
104
+ if (ids.length === 0) return 'subagent_id must be a non-empty array of subagent ids.'
105
+ const parent = exec.agent
106
+ if (parent === undefined) throw new Error('wait_subagent requires a calling agent (exec.agent was undefined)')
107
+
108
+ // Verify every id is one of this agent's direct children — fail fast,
109
+ // waiting for nobody (the contract the single-id version had).
110
+ let entries
111
+ try {
112
+ entries = await ctx.subagents.listChildren(parent.id, exec.signal)
113
+ } catch (error) {
114
+ return 'listChildren failed: ' + (error && error.message ? error.message : String(error))
115
+ }
116
+ const known = new Set(entries.map((entry) => entry.id))
117
+ const unknown = ids.filter((id) => !known.has(id))
118
+ if (unknown.length > 0) {
119
+ 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).`
120
+ }
121
+
122
+ const statusOf = (id) => {
123
+ const agent = ctx.agents.get(id)
124
+ return agent === undefined ? 'ready' : agent.status === 'running' ? 'running' : 'idle'
125
+ }
126
+
127
+ /** Settlement line: short done line; content belongs to the framework notice. */
128
+ const doneLine = (id, info) => `subagent ${id} done (${info && info.stopReason ? info.stopReason : 'completed'}); its closing message follows as the settlement notice.`
129
+
130
+ const startedAt = Date.now()
131
+ const capMs = typeof args.timeout_ms === 'number' && args.timeout_ms > 0 ? Math.max(args.timeout_ms, MIN_TIMEOUT_MS) : undefined
132
+
133
+ /**
134
+ * Wait for one child. Resolves with {kind:'settled',info} |
135
+ * {kind:'timedOut'} | {kind:'aborted'} | {kind:'notStarted'}. The
136
+ * per-id machinery (settled fast path, waiter registration, timeout
137
+ * timer, startup-grace poller) is the single-id logic of old,
138
+ * unchanged; Promise.all over ids gives the concurrent batch.
139
+ */
140
+ const waitOne = (id) => {
141
+ // Fast path: the child is not running and a settlement is already on
142
+ // record — resolve immediately (the notice was already delivered when
143
+ // the child settled, so it is in the conversation history).
144
+ if (statusOf(id) !== 'running') {
145
+ const hit = settled.get(id)
146
+ if (hit !== undefined) return Promise.resolve({ kind: 'settled', info: hit.info })
147
+ }
148
+
149
+ return new Promise((resolve) => {
150
+ let done = false
151
+ let timer
152
+ let poller
153
+ const finish = (value) => {
154
+ if (done) return
155
+ done = true
156
+ if (poller !== undefined) clearInterval(poller)
157
+ const list = waiters.get(id)
158
+ if (list !== undefined) {
159
+ const index = list.indexOf(finish)
160
+ if (index >= 0) list.splice(index, 1)
161
+ if (list.length === 0) waiters.delete(id)
162
+ }
163
+ if (timer !== undefined) clearTimeout(timer)
164
+ if (exec.signal !== undefined) exec.signal.removeEventListener('abort', onAbort)
165
+ resolve(value)
166
+ }
167
+ const onAbort = () => finish({ kind: 'aborted' })
168
+
169
+ const list = waiters.get(id) ?? []
170
+ list.push(finish)
171
+ waiters.set(id, list)
172
+ // All ids share startedAt, so one capMs value is one shared window.
173
+ if (capMs !== undefined) timer = setTimeout(() => finish({ kind: 'timedOut' }), capMs)
174
+ if (exec.signal !== undefined) {
175
+ if (exec.signal.aborted) {
176
+ finish({ kind: 'aborted' })
177
+ return
178
+ }
179
+ exec.signal.addEventListener('abort', onAbort, { once: true })
180
+ }
181
+
182
+ // Startup grace: a freshly dispatched child sits between spawn and
183
+ // its first turn (status not `running`, no settlement yet). Keep the
184
+ // wait alive while it spins up; once `running`, only `subagent/end`
185
+ // (the listener above) resolves us. If the grace expires without the
186
+ // child ever starting or settling, fall back to a retryable note
187
+ // instead of the old misleading "is not running" early return.
188
+ poller = setInterval(() => {
189
+ if (done) return
190
+ const hit = settled.get(id)
191
+ if (hit !== undefined) {
192
+ finish({ kind: 'settled', info: hit.info })
193
+ return
194
+ }
195
+ if (statusOf(id) !== 'running' && Date.now() - startedAt >= START_GRACE_MS) {
196
+ finish({ kind: 'notStarted' })
197
+ }
198
+ }, POLL_MS)
199
+
200
+ // Re-check: the child may have settled between the fast path above
201
+ // and waiter registration. Only a settlement AFTER this wait started
202
+ // counts as a live result.
203
+ const hit = settled.get(id)
204
+ if (hit !== undefined && hit.time >= startedAt) finish({ kind: 'settled', info: hit.info })
205
+ })
206
+ }
207
+
208
+ const outcomes = await Promise.all(ids.map((id) => waitOne(id)))
209
+
210
+ // Every waiter shares one abort signal, so an interruption either
211
+ // aborts the whole batch or lands after some children already settled.
212
+ // Collapse the all-aborted case into one line; mixed cases keep
213
+ // per-id lines so the settled ones are not lost.
214
+ if (outcomes.length > 0 && outcomes.every((outcome) => outcome.kind === 'aborted')) {
215
+ return `wait for ${ids.length} subagent${ids.length === 1 ? '' : 's'} was aborted by user interruption; the children may still be running.`
216
+ }
217
+
218
+ const lines = outcomes.map((outcome, index) => {
219
+ const id = ids[index]
220
+ if (outcome.kind === 'aborted') return `wait for subagent ${id} was aborted by user interruption; the child may still be running.`
221
+ 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.`
222
+ if (outcome.kind === 'notStarted') {
223
+ 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.`
224
+ }
225
+ // Real settlement: short done line; the framework's settlement notice
226
+ // (already delivered to the parent inbox before `subagent/end`)
227
+ // carries the closing message — nothing to consume or deduplicate.
228
+ return doneLine(id, outcome.info)
229
+ })
230
+ return lines.join('\n')
231
+ },
232
+ })
233
+ }
@@ -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
+ }