@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
@@ -0,0 +1,198 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { dirname } from 'node:path'
3
+ import { normalizeConfig } from './config.js'
4
+ import * as codex from './cli/codex.js'
5
+ import * as claude from './cli/claude.js'
6
+ import { readJsonLines, sanitizeError } from './cli/jsonl.js'
7
+
8
+ export const CLI_ROUTES = Object.freeze({ codex: 'apollo-cli-codex', claude: 'apollo-cli-claude' })
9
+ const protocols = { codex, claude }
10
+ const NO_RETRIES = Object.freeze({ mode: 'normal', maxRetries: 0, retryableCodes: [], initialDelayMs: 500, maxDelayMs: 500, jitterRatio: 0 })
11
+
12
+ export function enabledIn(config, agent) {
13
+ const scope = normalizeConfig(config).subagents
14
+ let preset = agent?.session.header.agentPreset
15
+ try {
16
+ const projections = agent?.ctx?.get?.('sessionProjections')
17
+ if (projections) preset = projections.stateOf(agent.session, 'agentPreset') ?? preset
18
+ } catch { /* Compositions without the optional preset registry use the header. */ }
19
+ return scope.global || (scope.eng && ['eng', 'eng-noask'].includes(preset))
20
+ }
21
+
22
+ /** Exact checks are repeated before every CLI turn, including cold resume. */
23
+ export function selectCli(config, cli, model, effort) {
24
+ const backend = normalizeConfig(config).subagents[cli]
25
+ if (!backend?.enabled) throw new Error(`Apollo ${cli} backend is disabled; enable it in Plugins → Apollo`)
26
+ const modelId = model ?? backend.defaultModel
27
+ const selected = backend.models.find((candidate) => candidate.id === modelId)
28
+ if (!selected) throw new Error(`Apollo ${cli} model "${modelId}" is not allowed; check the backend model list`)
29
+ const selectedEffort = effort ?? selected.defaultEffort
30
+ if (!selectedEffort || !selected.efforts.includes(selectedEffort)) {
31
+ throw new Error(`Apollo ${cli} effort "${selectedEffort ?? ''}" is not allowed for model "${modelId}"`)
32
+ }
33
+ return { ...backend, model: modelId, effort: selectedEffort }
34
+ }
35
+
36
+ /** Only new user deliveries go to a native CLI that already owns its history. */
37
+ export function pendingMessages(messages, delivered) {
38
+ const seen = new Set(delivered)
39
+ const pending = []
40
+ for (const message of messages) {
41
+ if (message.role !== 'user') continue
42
+ const id = message.id ?? createHash('sha256').update(JSON.stringify(message.content)).digest('hex')
43
+ if (seen.has(id)) continue
44
+ const blocks = typeof message.content === 'string' ? [{ type: 'text', text: message.content }] : message.content
45
+ if (!Array.isArray(blocks) || blocks.some((block) => block.type !== 'text')) {
46
+ throw new Error('Apollo CLI subagents currently accept text messages only')
47
+ }
48
+ const text = blocks.map((block) => block.text).join('\n')
49
+ pending.push({ id, text })
50
+ seen.add(id)
51
+ }
52
+ return pending
53
+ }
54
+
55
+ /** One adapter instance owns only one CLI; DSH owns the containing Agent loop. */
56
+ export function createCliAdapter(ctx, config, sessions, cli) {
57
+ const route = CLI_ROUTES[cli]
58
+ const active = new Set()
59
+ const running = new Set()
60
+ const settling = new Set()
61
+ const lifetime = new AbortController()
62
+ let disposed = false
63
+ const adapter = {
64
+ providerInfo: () => ({ id: route, name: `Apollo ${cli === 'codex' ? 'Codex' : 'Claude'} CLI (subagents)` }),
65
+ providerRetryPolicy: () => NO_RETRIES,
66
+ imageRequestPricing: () => undefined,
67
+ async listModels() {
68
+ const backend = normalizeConfig(config).subagents[cli]
69
+ return backend.enabled ? backend.models.map((model) => ({ provider: route, id: model.id, name: model.id, inputModalities: ['text'] })) : []
70
+ },
71
+ async resolveModel(provider, model) {
72
+ const backend = normalizeConfig(config).subagents[cli]
73
+ const selected = backend.models.find((candidate) => candidate.id === model)
74
+ if (provider !== route || !backend.enabled || !selected) throw new Error(`Apollo ${cli} model "${model}" is unavailable`)
75
+ if (selected.efforts.length === 0) throw new Error(`Apollo ${cli} model "${model}" has no allowed reasoning efforts; configure them in Plugins → Apollo`)
76
+ return {
77
+ provider: route, id: model, name: model, inputModalities: ['text'],
78
+ reasoning: { efforts: selected.efforts.map((id) => ({ id, name: id })), ...(selected.defaultEffort ? { defaultEffort: selected.defaultEffort } : {}) },
79
+ }
80
+ },
81
+ async prepareCall(provider, model, signal) {
82
+ signal?.throwIfAborted()
83
+ return { model: await adapter.resolveModel(provider, model), stream: (options) => adapter.stream(options) }
84
+ },
85
+ async *stream(options) {
86
+ if (disposed) throw new Error('Apollo CLI adapter has been unloaded')
87
+ const id = options.sessionId
88
+ const agent = id === undefined ? undefined : ctx.agents.get(id)
89
+ let state = id === undefined ? undefined : sessions.get(id)
90
+ if (!agent || !state || state.cli !== cli || agent.session.header.parentSession !== state.parentId
91
+ || agent.session.header.origin !== 'subagent' || agent.session.header.isSeeded) {
92
+ throw new Error('Apollo CLI routes are reserved for subagent-created continuable sessions')
93
+ }
94
+ if (options.provider !== route || options.model !== state.model || options.reasoningEffort !== state.effort) {
95
+ throw new Error('Apollo CLI session model and effort are fixed at creation')
96
+ }
97
+ if (running.has(id)) throw new Error('Apollo CLI session already has an active turn')
98
+ const current = selectCli(config, cli, state.model, state.effort)
99
+ const pending = pendingMessages(options.messages, state.delivered)
100
+ if (pending.length === 0) throw new Error('Apollo CLI turn has no new user message to deliver')
101
+ if (state.started && !state.remoteId) throw new Error('Apollo CLI session creation was interrupted before its identity was saved; create a new subagent')
102
+ const signal = AbortSignal.any([lifetime.signal, ...options.signal ? [options.signal] : []])
103
+ signal.throwIfAborted()
104
+ running.add(id)
105
+ let settled
106
+ const completion = new Promise((resolve) => { settled = resolve })
107
+ settling.add(completion)
108
+ const prior = structuredClone(state)
109
+ let stateWritten = false
110
+ let handle
111
+ let done
112
+ try {
113
+ const prompt = [
114
+ ...pending.map((message) => message.text),
115
+ `Apollo CLI bridge: return your answer as text; DSH forwards the final output automatically. Harness-only messaging tools are unavailable here. This turn uses ${cli} permission mode "${current.permissionMode}" from Apollo settings. Embedded DSH runtime snapshots describe the host tools; CLI permissions are controlled by these CLI settings.`,
116
+ ].join('\n\n')
117
+ const { argv, input } = protocols[cli].buildCommand({
118
+ ...state, profile: state.profile || undefined, remoteId: state.remoteId, prompt, permissionMode: current.permissionMode,
119
+ })
120
+ // Record attempted delivery before I/O. CLI turns are never retried automatically.
121
+ state = { ...state, started: true, delivered: [...state.delivered, ...pending.map((message) => message.id)] }
122
+ await sessions.put(state)
123
+ stateWritten = true
124
+ signal.throwIfAborted()
125
+ const subprocess = agent.ctx.get('subprocess')
126
+ if (!subprocess) throw new Error('Apollo CLI subagents require the managed subprocess service')
127
+ const env = state.cliHome ? { [cli === 'codex' ? 'CODEX_HOME' : 'CLAUDE_CONFIG_DIR']: state.cliHome } : undefined
128
+ if (cli === 'claude' && state.claudeDefaultHome && env) {
129
+ env[process.platform === 'win32' ? 'USERPROFILE' : 'HOME'] = dirname(state.cliHome)
130
+ env.CLAUDE_CONFIG_DIR = undefined // Managed subprocess tombstone preserves native default/keychain lookup.
131
+ }
132
+ handle = subprocess.spawn({
133
+ argv, cwd: state.cwd,
134
+ stdio: { stdin: { data: input }, stdout: 'pipe', stderr: { maxBytes: 4096 } },
135
+ graceMs: 1500, signal,
136
+ ...(env === undefined ? {} : { env }),
137
+ })
138
+ active.add(handle)
139
+ // Observe spawn errors immediately while stdout is being consumed.
140
+ done = handle.done.then((outcome) => ({ outcome }), (error) => ({ error }))
141
+ if (!handle.stdout) throw new Error('Apollo CLI did not expose its JSON event stream')
142
+ const parser = protocols[cli].createParser()
143
+ let protocolError
144
+ for await (const event of readJsonLines(handle.stdout)) {
145
+ const update = parser.push(event)
146
+ if (update?.error) protocolError = update.error
147
+ if (update?.remoteId) {
148
+ if (state.remoteId && state.remoteId !== update.remoteId) throw new Error('CLI resumed a different session; refusing to replace its identity')
149
+ if (!state.remoteId) {
150
+ state = { ...state, remoteId: update.remoteId }
151
+ await sessions.put(state)
152
+ }
153
+ }
154
+ signal.throwIfAborted()
155
+ }
156
+ const result = await done
157
+ signal.throwIfAborted()
158
+ if (result.error) throw new Error(`Apollo ${cli} process could not start: ${result.error.code ?? result.error.name ?? 'spawn error'}`)
159
+ if (result.outcome.exitCode !== 0) {
160
+ const detail = protocolError || handle.collected?.stderr?.readFrom(0).text
161
+ throw new Error(`Apollo ${cli} exited with ${result.outcome.exitCode ?? result.outcome.signal}; check CLI authentication, profile and permissions${detail ? `: ${sanitizeError(detail)}` : ''}`)
162
+ }
163
+ const output = parser.finish()
164
+ if (!state.remoteId) throw new Error(`Apollo ${cli} completed without a resumable session id`)
165
+ if (output.text) {
166
+ yield { type: 'block-start', index: 0, blockType: 'text' }
167
+ yield { type: 'text-delta', index: 0, text: output.text }
168
+ yield { type: 'block-end', index: 0, block: { type: 'text', text: output.text } }
169
+ }
170
+ if (output.usage) yield { type: 'usage', usage: output.usage }
171
+ yield { type: 'finish', reason: { kind: 'stop' } }
172
+ } finally {
173
+ try {
174
+ if (handle) {
175
+ handle.terminate()
176
+ await done
177
+ await handle.waitForExit()
178
+ } else if (stateWritten) {
179
+ // No process was published, so no native CLI consumed this delivery.
180
+ await sessions.put(prior)
181
+ }
182
+ } finally {
183
+ if (handle) active.delete(handle)
184
+ running.delete(id)
185
+ settling.delete(completion)
186
+ settled()
187
+ }
188
+ }
189
+ },
190
+ async dispose() {
191
+ disposed = true
192
+ lifetime.abort(new Error('Apollo CLI adapter unloaded'))
193
+ for (const handle of active) handle.terminate()
194
+ await Promise.allSettled([...settling])
195
+ },
196
+ }
197
+ return adapter
198
+ }
@@ -0,0 +1,69 @@
1
+ import { createHash } from 'node:crypto'
2
+
3
+ /** Durable CLI identities live outside the compactable conversation surface. */
4
+ export async function openCliSessions(ctx) {
5
+ const profile = ctx.get('profileContext')
6
+ const namespace = createHash('sha256').update(profile?.dir ?? 'default').digest('hex').slice(0, 16)
7
+ const backend = ctx.storage.backend.get('json')
8
+ if (!backend?.kv) throw new Error('Apollo CLI subagents require the JSON storage backend')
9
+ const unit = await backend.kv.open({
10
+ name: `apollo_cli_${namespace}`, version: 1, tables: ['sessions'], hasGlobal: false, layout: 'per-record',
11
+ })
12
+ try {
13
+ const snapshot = await unit.loadAll()
14
+ return new CliSessions(unit, snapshot.tables.sessions ?? {})
15
+ } catch (error) {
16
+ await unit.close()
17
+ throw error
18
+ }
19
+ }
20
+
21
+ export class CliSessions {
22
+ constructor(unit, records = {}) {
23
+ this.unit = unit
24
+ this.records = new Map(Object.entries(records))
25
+ this.writes = Promise.resolve()
26
+ this.closed = false
27
+ }
28
+
29
+ get(id) {
30
+ const value = this.records.get(id)
31
+ if (value === undefined) return undefined
32
+ if (value?.version !== 1 || !['codex', 'claude'].includes(value.cli) || value.id !== id
33
+ || typeof value.parentId !== 'string' || typeof value.cwd !== 'string'
34
+ || typeof value.model !== 'string' || typeof value.effort !== 'string'
35
+ || typeof value.executable !== 'string' || !Array.isArray(value.delivered)) {
36
+ throw new Error(`Apollo CLI session ${id} has invalid stored state`)
37
+ }
38
+ return structuredClone(value)
39
+ }
40
+
41
+ async put(record) {
42
+ if (!/^[a-zA-Z0-9_-]+$/.test(record.id)) throw new Error('Invalid Apollo CLI session id')
43
+ const detached = structuredClone(record)
44
+ await this.enqueue(async () => {
45
+ await this.unit.putRecord('sessions', detached.id, detached)
46
+ this.records.set(detached.id, detached)
47
+ })
48
+ }
49
+
50
+ async delete(id) {
51
+ await this.enqueue(async () => {
52
+ await this.unit.deleteRecord('sessions', id)
53
+ this.records.delete(id)
54
+ })
55
+ }
56
+
57
+ enqueue(write) {
58
+ if (this.closed) return Promise.reject(new Error('Apollo CLI session store is closed'))
59
+ const pending = this.writes.then(write)
60
+ this.writes = pending.catch(() => {})
61
+ return pending
62
+ }
63
+
64
+ async close() {
65
+ this.closed = true
66
+ await this.writes
67
+ await this.unit.close()
68
+ }
69
+ }