@sqdr2/dsh-fusion 0.0.0-stage → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (213) hide show
  1. package/INSTALL.md +89 -0
  2. package/INSTALL.zh.md +86 -0
  3. package/LICENSE +201 -0
  4. package/README.en.md +193 -0
  5. package/README.md +182 -2
  6. package/RELEASING.md +123 -0
  7. package/RELEASING.zh.md +115 -0
  8. package/contracts/fusion.ts +279 -0
  9. package/contracts/storage.sql +86 -0
  10. package/cordis.patch.yml +3 -0
  11. package/docs/COMPATIBILITY.md +162 -0
  12. package/docs/EVIDENCE.md +141 -0
  13. package/dshx.yml +39 -0
  14. package/lib/client.js +1920 -0
  15. package/lib/client.js.map +1 -0
  16. package/lib/dsh-fusion.js +12664 -0
  17. package/lib/types/activity.d.ts +20 -0
  18. package/lib/types/approval/logical.d.ts +10 -0
  19. package/lib/types/benchmark/naive.d.ts +37 -0
  20. package/lib/types/benchmark/native-run.d.ts +68 -0
  21. package/lib/types/benchmark/output-limits.d.ts +19 -0
  22. package/lib/types/bilingual.d.ts +2 -0
  23. package/lib/types/client/ActivityRows.d.ts +8 -0
  24. package/lib/types/client/FusionCache.d.ts +4 -0
  25. package/lib/types/client/FusionHint.d.ts +9 -0
  26. package/lib/types/client/FusionHistory.d.ts +5 -0
  27. package/lib/types/client/FusionPanelIcon.d.ts +13 -0
  28. package/lib/types/client/FusionRecovery.d.ts +5 -0
  29. package/lib/types/client/FusionSettings.d.ts +9 -0
  30. package/lib/types/client/FusionStatus.d.ts +5 -0
  31. package/lib/types/client/FusionTool.d.ts +4 -0
  32. package/lib/types/client/activity-model.d.ts +14 -0
  33. package/lib/types/client/i18n.d.ts +15 -0
  34. package/lib/types/client/index.d.ts +22 -0
  35. package/lib/types/client/settings-events.d.ts +2 -0
  36. package/lib/types/client/status-polling.d.ts +9 -0
  37. package/lib/types/context/guard.d.ts +21 -0
  38. package/lib/types/context/handoff.d.ts +17 -0
  39. package/lib/types/contracts.d.ts +449 -0
  40. package/lib/types/digest.d.ts +5 -0
  41. package/lib/types/dsh-fusion.d.ts +13 -0
  42. package/lib/types/errors.d.ts +28 -0
  43. package/lib/types/evidence/gate.d.ts +23 -0
  44. package/lib/types/evidence/receipts.d.ts +65 -0
  45. package/lib/types/execution/write-lease.d.ts +42 -0
  46. package/lib/types/host/bindings.d.ts +30 -0
  47. package/lib/types/host/cache-defaults.d.ts +48 -0
  48. package/lib/types/host/cache-policy.d.ts +155 -0
  49. package/lib/types/host/change-evidence.d.ts +38 -0
  50. package/lib/types/host/compaction-routing.d.ts +15 -0
  51. package/lib/types/host/coordinator.d.ts +169 -0
  52. package/lib/types/host/evidence-page.d.ts +13 -0
  53. package/lib/types/host/history.d.ts +88 -0
  54. package/lib/types/host/http.d.ts +4 -0
  55. package/lib/types/host/model-output.d.ts +13 -0
  56. package/lib/types/host/native-activity.d.ts +26 -0
  57. package/lib/types/host/native-approvals.d.ts +29 -0
  58. package/lib/types/host/native-auxiliary.d.ts +17 -0
  59. package/lib/types/host/native-budget.d.ts +33 -0
  60. package/lib/types/host/native-checks.d.ts +104 -0
  61. package/lib/types/host/native-compaction.d.ts +8 -0
  62. package/lib/types/host/native-context.d.ts +36 -0
  63. package/lib/types/host/native-effects.d.ts +75 -0
  64. package/lib/types/host/native-evidence.d.ts +4 -0
  65. package/lib/types/host/native-exploration.d.ts +15 -0
  66. package/lib/types/host/native-keepalive.d.ts +82 -0
  67. package/lib/types/host/native-model-control.d.ts +99 -0
  68. package/lib/types/host/native-on-demand-context.d.ts +39 -0
  69. package/lib/types/host/native-request.d.ts +5 -0
  70. package/lib/types/host/native-review.d.ts +11 -0
  71. package/lib/types/host/native-role-sandbox.d.ts +49 -0
  72. package/lib/types/host/native-scopes.d.ts +49 -0
  73. package/lib/types/host/native-selection.d.ts +29 -0
  74. package/lib/types/host/native-task-context.d.ts +39 -0
  75. package/lib/types/host/native-usage.d.ts +46 -0
  76. package/lib/types/host/native-worker-brief.d.ts +74 -0
  77. package/lib/types/host/native-worker-notices.d.ts +15 -0
  78. package/lib/types/host/native-worker.d.ts +44 -0
  79. package/lib/types/host/native-workflow.d.ts +41 -0
  80. package/lib/types/host/route-transport.d.ts +22 -0
  81. package/lib/types/host/settings.d.ts +39 -0
  82. package/lib/types/host/shell.d.ts +11 -0
  83. package/lib/types/host/status.d.ts +7 -0
  84. package/lib/types/host/subagent-seam.d.ts +10 -0
  85. package/lib/types/host/tool-table.d.ts +35 -0
  86. package/lib/types/host/worker-terminal.d.ts +68 -0
  87. package/lib/types/host/workspace-guard.d.ts +13 -0
  88. package/lib/types/host/workspace.d.ts +52 -0
  89. package/lib/types/identity.d.ts +64 -0
  90. package/lib/types/index.d.ts +34 -0
  91. package/lib/types/message-source.d.ts +29 -0
  92. package/lib/types/profile/compactor.d.ts +5 -0
  93. package/lib/types/profile/keepalive.d.ts +3 -0
  94. package/lib/types/profile/resolve.d.ts +17 -0
  95. package/lib/types/prompts.d.ts +8 -0
  96. package/lib/types/recovery/reconcile.d.ts +28 -0
  97. package/lib/types/review/dispatch.d.ts +20 -0
  98. package/lib/types/review/protocol.d.ts +51 -0
  99. package/lib/types/review/ticket.d.ts +32 -0
  100. package/lib/types/status.d.ts +88 -0
  101. package/lib/types/store-migration.d.ts +29 -0
  102. package/lib/types/task/deletion.d.ts +64 -0
  103. package/lib/types/task/reducer.d.ts +6 -0
  104. package/lib/types/task/sqlite-store.d.ts +71 -0
  105. package/lib/types/task/state.d.ts +51 -0
  106. package/lib/types/task/status-index.d.ts +28 -0
  107. package/lib/types/task/store.d.ts +48 -0
  108. package/lib/types/usage/budget.d.ts +27 -0
  109. package/lib/types/usage/ledger.d.ts +66 -0
  110. package/lib/types/usage/normalize.d.ts +50 -0
  111. package/lib/types/usage/pricing.d.ts +18 -0
  112. package/package.json +178 -4
  113. package/profiles/fusion-auto.example.json +45 -0
  114. package/prompts/compact.md +7 -0
  115. package/prompts/lead.md +42 -0
  116. package/prompts/model-lead.md +13 -0
  117. package/prompts/model-worker.md +5 -0
  118. package/prompts/worker.md +21 -0
  119. package/src/activity.ts +23 -0
  120. package/src/approval/logical.ts +44 -0
  121. package/src/benchmark/naive.ts +216 -0
  122. package/src/benchmark/native-run.ts +215 -0
  123. package/src/benchmark/output-limits.ts +33 -0
  124. package/src/bilingual.ts +2 -0
  125. package/src/client/ActivityRows.tsx +59 -0
  126. package/src/client/FusionCache.tsx +130 -0
  127. package/src/client/FusionHint.tsx +39 -0
  128. package/src/client/FusionHistory.tsx +247 -0
  129. package/src/client/FusionPanelIcon.tsx +39 -0
  130. package/src/client/FusionRecovery.tsx +69 -0
  131. package/src/client/FusionSettings.tsx +205 -0
  132. package/src/client/FusionStatus.tsx +103 -0
  133. package/src/client/FusionTool.tsx +66 -0
  134. package/src/client/activity-model.ts +31 -0
  135. package/src/client/i18n.ts +30 -0
  136. package/src/client/index.ts +50 -0
  137. package/src/client/settings-events.ts +3 -0
  138. package/src/client/status-polling.ts +88 -0
  139. package/src/context/guard.ts +81 -0
  140. package/src/context/handoff.ts +28 -0
  141. package/src/contracts.ts +449 -0
  142. package/src/digest.ts +28 -0
  143. package/src/dsh-fusion.ts +321 -0
  144. package/src/errors.ts +34 -0
  145. package/src/evidence/gate.ts +290 -0
  146. package/src/evidence/receipts.ts +175 -0
  147. package/src/execution/write-lease.ts +187 -0
  148. package/src/host/bindings.ts +91 -0
  149. package/src/host/cache-defaults.ts +103 -0
  150. package/src/host/cache-policy.ts +168 -0
  151. package/src/host/change-evidence.ts +156 -0
  152. package/src/host/compaction-routing.ts +46 -0
  153. package/src/host/coordinator.ts +2160 -0
  154. package/src/host/evidence-page.ts +25 -0
  155. package/src/host/history.ts +114 -0
  156. package/src/host/http.ts +30 -0
  157. package/src/host/model-output.ts +22 -0
  158. package/src/host/native-activity.ts +199 -0
  159. package/src/host/native-approvals.ts +196 -0
  160. package/src/host/native-auxiliary.ts +19 -0
  161. package/src/host/native-budget.ts +112 -0
  162. package/src/host/native-checks.ts +474 -0
  163. package/src/host/native-compaction.ts +34 -0
  164. package/src/host/native-context.ts +204 -0
  165. package/src/host/native-effects.ts +260 -0
  166. package/src/host/native-evidence.ts +31 -0
  167. package/src/host/native-exploration.ts +114 -0
  168. package/src/host/native-keepalive.ts +292 -0
  169. package/src/host/native-model-control.ts +298 -0
  170. package/src/host/native-on-demand-context.ts +74 -0
  171. package/src/host/native-request.ts +12 -0
  172. package/src/host/native-review.ts +51 -0
  173. package/src/host/native-role-sandbox.ts +171 -0
  174. package/src/host/native-scopes.ts +203 -0
  175. package/src/host/native-selection.ts +110 -0
  176. package/src/host/native-task-context.ts +264 -0
  177. package/src/host/native-usage.ts +121 -0
  178. package/src/host/native-worker-brief.ts +122 -0
  179. package/src/host/native-worker-notices.ts +72 -0
  180. package/src/host/native-worker.ts +215 -0
  181. package/src/host/native-workflow.ts +103 -0
  182. package/src/host/route-transport.ts +54 -0
  183. package/src/host/settings.ts +93 -0
  184. package/src/host/shell.ts +13 -0
  185. package/src/host/status.ts +112 -0
  186. package/src/host/subagent-seam.ts +20 -0
  187. package/src/host/tool-table.ts +56 -0
  188. package/src/host/worker-terminal.ts +91 -0
  189. package/src/host/workspace-guard.ts +17 -0
  190. package/src/host/workspace.ts +184 -0
  191. package/src/identity.ts +76 -0
  192. package/src/index.ts +50 -0
  193. package/src/message-source.ts +31 -0
  194. package/src/profile/compactor.ts +26 -0
  195. package/src/profile/keepalive.ts +13 -0
  196. package/src/profile/resolve.ts +131 -0
  197. package/src/prompts.ts +32 -0
  198. package/src/recovery/reconcile.ts +38 -0
  199. package/src/review/dispatch.ts +48 -0
  200. package/src/review/protocol.ts +212 -0
  201. package/src/review/ticket.ts +166 -0
  202. package/src/status.ts +55 -0
  203. package/src/store-migration.ts +65 -0
  204. package/src/task/deletion.ts +105 -0
  205. package/src/task/reducer.ts +679 -0
  206. package/src/task/sqlite-store.ts +386 -0
  207. package/src/task/state.ts +72 -0
  208. package/src/task/status-index.ts +198 -0
  209. package/src/task/store.ts +287 -0
  210. package/src/usage/budget.ts +88 -0
  211. package/src/usage/ledger.ts +218 -0
  212. package/src/usage/normalize.ts +308 -0
  213. package/src/usage/pricing.ts +95 -0
@@ -0,0 +1,215 @@
1
+ import type { Context } from '@deepseek-ai/cordis'
2
+ import type { Agent } from '@deepseek-ai/dsh-agent'
3
+ import { ReasoningEffortId } from '@deepseek-ai/dsh-llm'
4
+ import type { MessageId } from '@deepseek-ai/dsh-llm'
5
+ import { SessionId } from '@deepseek-ai/dsh-session'
6
+ import type {} from '@deepseek-ai/dsh-subagent'
7
+ import type { PhysicalRoute } from '../contracts.js'
8
+ import { requireSubagentMethod } from './subagent-seam.js'
9
+
10
+ export interface NativeWorkerRequest {
11
+ readonly childId: string
12
+ readonly label: string
13
+ /** Model-authored brief from the Lead's native tool call. */
14
+ readonly brief: string
15
+ readonly route: PhysicalRoute
16
+ /** 0.2.0 startContinuable persona. 0.2.1 rejects it; the role scope carries the prompt. */
17
+ readonly persona: string
18
+ /** 0.2.0 start-time tool ceiling. 0.2.1 rejects toolFilter; the role scope narrows tools. */
19
+ readonly allowedTools: readonly string[]
20
+ }
21
+
22
+ export interface NativeWorkerAcceptance {
23
+ readonly childId: string
24
+ readonly messageId: MessageId
25
+ }
26
+
27
+ interface WorkerStartRequest {
28
+ readonly parent: Agent
29
+ readonly prompt: readonly { readonly type: 'text'; readonly text: string }[]
30
+ readonly agentOptions: { readonly provider: string; readonly model: string; readonly reasoningEffort?: ReturnType<typeof ReasoningEffortId> }
31
+ readonly maxDepth: number
32
+ readonly persona?: string
33
+ readonly toolFilter?: { readonly allow: readonly string[] }
34
+ }
35
+
36
+ interface StartedWorker {
37
+ readonly childId: string
38
+ readonly messageId?: MessageId
39
+ }
40
+
41
+ /** Structural view of the two DSH subagent seams. Only the branch that exists is called. */
42
+ interface SubagentSeam {
43
+ startActivation?(spec: {
44
+ readonly provider: string
45
+ readonly label: string
46
+ readonly childId: ReturnType<typeof SessionId>
47
+ readonly delivery: 'parent'
48
+ readonly request: WorkerStartRequest
49
+ readonly signal: AbortSignal
50
+ }): Promise<StartedWorker>
51
+ startContinuable?(spec: {
52
+ readonly provider: string
53
+ readonly label: string
54
+ readonly childId: ReturnType<typeof SessionId>
55
+ readonly request: WorkerStartRequest
56
+ readonly signal: AbortSignal
57
+ }): Promise<StartedWorker>
58
+ drainChildren?(parent: Agent, childIds: readonly ReturnType<typeof SessionId>[]): Promise<void>
59
+ drainContinuableChildren?(parent: Agent, childIds: readonly ReturnType<typeof SessionId>[]): Promise<void>
60
+ }
61
+
62
+ /** Version-pinned public API adapter. It never calls an LLM adapter or runs a shell itself. */
63
+ export class NativeWorkerTransport {
64
+ readonly #flushed = new WeakMap<Agent, number>()
65
+ constructor(private readonly ctx: Context, private readonly provider = 'spawn') {}
66
+
67
+ assertAvailable(): void {
68
+ const provider = this.ctx.subagents.getProvider(this.provider)
69
+ if (!provider?.prepareContinuable || provider.inheritsParentContext !== false) {
70
+ throw new Error(`Fusion requires an independent continuable provider: ${this.provider}`)
71
+ }
72
+ }
73
+
74
+ /** Caller durably reserves the child and operation before entering this method. */
75
+ async start(parent: Agent, request: NativeWorkerRequest, signal: AbortSignal): Promise<NativeWorkerAcceptance> {
76
+ this.assertAvailable()
77
+ signal.throwIfAborted()
78
+ if (this.ctx.agents.get(SessionId(request.childId))) throw new Error('Reserved Worker already exists; reconcile before retrying')
79
+ const prompt = [{ type: 'text' as const, text: request.brief }]
80
+ const agentOptions = {
81
+ provider: request.route.provider,
82
+ model: request.route.model,
83
+ ...(request.route.reasoningEffort === undefined ? {} : { reasoningEffort: ReasoningEffortId(request.route.reasoningEffort) }),
84
+ }
85
+ const maxDepth = (parent.session.header.delegationDepth ?? 0) + 1
86
+ const childId = SessionId(request.childId)
87
+ // `startActivation` is the 0.2.1 name. `delivery: 'parent'` keeps the 0.2.0
88
+ // settlement: it reaches the Lead's inbox, which this transport reconciles
89
+ // from the session log. 0.2.1 rejects `persona` and `toolFilter`; the
90
+ // `fusion-role` section and NativeFusionScopes own both, and the scope is
91
+ // installed at `agent/created` before the child's first request.
92
+ // Otherwise this is 0.2.0 `startContinuable`, persona and toolFilter included.
93
+ const subagents = this.ctx.subagents as SubagentSeam
94
+ const start = requireSubagentMethod(subagents, 'startActivation', 'startContinuable')
95
+ const accepted = start === 'startActivation'
96
+ ? await subagents.startActivation!({
97
+ provider: this.provider, label: request.label, childId, delivery: 'parent',
98
+ request: { parent, prompt, agentOptions, maxDepth }, signal,
99
+ })
100
+ : await subagents.startContinuable!({
101
+ provider: this.provider, label: request.label, childId,
102
+ request: { parent, prompt, persona: request.persona, agentOptions, toolFilter: { allow: [...request.allowedTools] }, maxDepth },
103
+ signal,
104
+ })
105
+ if (accepted.childId !== request.childId) throw new Error('Native Worker identity changed during creation')
106
+ if (!accepted.messageId) throw new Error('Native Worker start did not accept an initial message')
107
+ return { childId: accepted.childId, messageId: accepted.messageId }
108
+ }
109
+
110
+ /** Only Lead-authored feedback is sent through this public model-message API. */
111
+ async continue(parent: Agent, childId: string, feedback: string, signal: AbortSignal): Promise<NativeWorkerAcceptance> {
112
+ signal.throwIfAborted()
113
+ const messageId = await this.ctx.subagents.sendMessage(
114
+ parent, SessionId(childId), [{ type: 'text', text: feedback }], { signal },
115
+ )
116
+ return { childId, messageId }
117
+ }
118
+
119
+ /**
120
+ * Replace an in-flight generation through public cancellation, then deliver
121
+ * to the same durable child. The caller must first exclude live/unknown
122
+ * effectful tools. Otherwise use continue(), which steers at a step boundary.
123
+ */
124
+ async interruptAndContinue(parent: Agent, childId: string, feedback: string, signal: AbortSignal): Promise<NativeWorkerAcceptance> {
125
+ signal.throwIfAborted()
126
+ await this.stop(parent, childId)
127
+ signal.throwIfAborted()
128
+ return this.continue(parent, childId, feedback, signal)
129
+ }
130
+
131
+ /** Interrupt acceptance is insufficient: wait for the exact native Agent to settle and flush. */
132
+ settle(parent: Agent, childId: string, signal: AbortSignal): Promise<Agent>
133
+ /** Yield a blocking tool to native user steering without canceling or releasing the Worker. */
134
+ settle(parent: Agent, childId: string, signal: AbortSignal, yieldToSteering: true): Promise<Agent | undefined>
135
+ async settle(parent: Agent, childId: string, signal: AbortSignal, yieldToSteering = false): Promise<Agent | undefined> {
136
+ const child = this.#requireChild(parent, childId)
137
+ let interruptError: unknown
138
+ let removeSteering = () => {}
139
+ const interrupt = () => {
140
+ try { this.ctx.subagents.interrupt(child.id, { kind: 'ancestor', agent: parent }) }
141
+ catch (error) { interruptError = error }
142
+ }
143
+ signal.addEventListener('abort', interrupt, { once: true })
144
+ if (signal.aborted) interrupt()
145
+ try {
146
+ if (yieldToSteering && !signal.aborted) {
147
+ const steering = new Promise<true>(resolve => {
148
+ const check = () => {
149
+ // Queue/next-turn and plugin context keep their native semantics.
150
+ if (parent.inbox.nextStep.some(message => message.source.kind === 'user')) resolve(true)
151
+ }
152
+ removeSteering = parent.ctx.on('agent/inbox/inserted', ({ agent }) => { if (agent === parent) check() })
153
+ check() // A message may already have arrived during delegation setup.
154
+ })
155
+ const yielded = await Promise.race([child.whenIdle().then(() => false), steering])
156
+ // Cancellation still owns full settlement; an already idle Worker can
157
+ // be flushed normally. The Lead alone authors any revised Worker brief.
158
+ if (yielded && !signal.aborted && child.status !== 'idle') return undefined
159
+ }
160
+ await child.whenIdle()
161
+ if (interruptError) throw interruptError
162
+ if (this.ctx.agents.get(child.id) !== child) throw new Error('Worker residency changed; quiescence requires reconciliation')
163
+ await this.#flushSettled(child)
164
+ return child
165
+ } finally { removeSteering(); signal.removeEventListener('abort', interrupt) }
166
+ }
167
+
168
+ async stop(parent: Agent, childId: string): Promise<Agent> {
169
+ const child = this.#requireChild(parent, childId)
170
+ // Native continuation teardown may already be closing this idle handle.
171
+ // Reuse only our exact prior quiescence+flush proof at the unchanged log seq.
172
+ if (child.status === 'idle' && this.#flushed.get(child) === (child.session.snapshotEvents().at(-1)?.seq ?? 0)) return child
173
+ this.ctx.subagents.interrupt(child.id, { kind: 'ancestor', agent: parent })
174
+ await child.whenIdle()
175
+ if (this.ctx.agents.get(child.id) !== child) throw new Error('Worker residency changed during stop')
176
+ await this.#flushSettled(child)
177
+ return child
178
+ }
179
+
180
+ /**
181
+ * 0.2.1 natural settlement flushes the child, then closes the persistence
182
+ * handle. A stop that loses that race must not treat the closed handle as a
183
+ * lost log. Any other flush failure still means the log is not durable.
184
+ */
185
+ async #flushSettled(child: Agent): Promise<void> {
186
+ try {
187
+ if (!await this.ctx.sessions.flush(child.session)) throw new Error('Worker log persistence unavailable')
188
+ } catch (error) {
189
+ if (!(error instanceof Error) || error.name !== 'SessionHandleClosedError') throw error
190
+ return
191
+ }
192
+ this.#flushed.set(child, child.session.snapshotEvents().at(-1)?.seq ?? 0)
193
+ }
194
+
195
+ /** Native release owns quiescence, persistence and handle disposal across HMR. */
196
+ async release(parent: Agent, childId: string): Promise<void> {
197
+ const child = this.ctx.agents.get(SessionId(childId))
198
+ if (child && child.session.header.parentSession !== parent.id) throw new Error('Cannot release another Lead’s child')
199
+ // 0.2.1 calls this `drainChildren`. 0.2.0 calls it `drainContinuableChildren`.
200
+ const subagents = this.ctx.subagents as SubagentSeam
201
+ const drain = requireSubagentMethod(subagents, 'drainChildren', 'drainContinuableChildren')
202
+ const ids = [SessionId(childId)]
203
+ if (drain === 'drainChildren') await subagents.drainChildren!(parent, ids)
204
+ else await subagents.drainContinuableChildren!(parent, ids)
205
+ if (this.ctx.agents.get(SessionId(childId))) throw new Error('Native Worker release did not prove absence')
206
+ }
207
+
208
+ #requireChild(parent: Agent, childId: string): Agent {
209
+ const child = this.ctx.agents.get(SessionId(childId))
210
+ if (!child || child.session.header.parentSession !== parent.id) {
211
+ throw new Error('Worker is absent or is not this Lead’s child; reconcile its persisted session')
212
+ }
213
+ return child
214
+ }
215
+ }
@@ -0,0 +1,103 @@
1
+ import type { Agent } from '@deepseek-ai/dsh-agent'
2
+ import { createUserMessage } from '@deepseek-ai/dsh-llm'
3
+ import type { ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools'
4
+ import { digestOf } from '../digest.js'
5
+ import type { Role } from '../contracts.js'
6
+ import type { SqliteFusionStore } from '../task/sqlite-store.js'
7
+ import type { SessionBinding } from './bindings.js'
8
+ import { isShellTool } from './shell.js'
9
+ import { FUSION_SOURCE_KIND } from '../message-source.js'
10
+
11
+ export const enforcedWorkflow = (binding: SessionBinding) => binding.profile.workflowPolicy === 'enforced-v1' || binding.profile.workflowPolicy === 'enforced-v2' || binding.profile.workflowPolicy === 'enforced-v3'
12
+ /** v3 keeps every v2 mechanism and adds role separation (see native-role-sandbox). */
13
+ export const adaptiveWorkflow = (binding: SessionBinding) => binding.profile.workflowPolicy === 'enforced-v2' || binding.profile.workflowPolicy === 'enforced-v3'
14
+
15
+ export interface ProgressWindow {
16
+ schemaVersion: 1
17
+ milestone: string
18
+ recent: string[]
19
+ tinyRead?: { path: string; count: number }
20
+ }
21
+ export interface ProgressObservation {
22
+ milestone: string
23
+ fingerprint?: string
24
+ tinyPath?: string
25
+ role: Role
26
+ }
27
+
28
+ /** Results, not model explanations, drive this bounded and persistable detector. */
29
+ export function advanceProgress(previous: ProgressWindow | undefined, observation: ProgressObservation): { window: ProgressWindow; stalled: boolean } {
30
+ const prior = previous?.milestone === observation.milestone ? previous : undefined
31
+ const recent = [...(prior?.recent ?? []), observation.fingerprint ?? ''].slice(-8)
32
+ const tinyRead = observation.tinyPath ? { path: observation.tinyPath,
33
+ count: prior?.tinyRead?.path === observation.tinyPath ? prior.tinyRead.count + 1 : 1 } : undefined
34
+ return { window: { schemaVersion: 1, milestone: observation.milestone, recent, ...(tinyRead ? { tinyRead } : {}) },
35
+ stalled: Boolean(observation.fingerprint && recent.filter(item => item === observation.fingerprint).length >= 3
36
+ || tinyRead && tinyRead.count >= (observation.role === 'lead' ? 12 : 32)) }
37
+ }
38
+
39
+ /** Only public execution results and durable task facts enter the workflow controller. */
40
+ export class NativeWorkflow {
41
+ constructor(readonly store: SqliteFusionStore) {}
42
+
43
+ milestone(binding: SessionBinding): string {
44
+ const state = this.store.load(binding.taskId)!
45
+ const runtime = this.store.readDocument(`runtime:${binding.taskId}`)?.value as { briefRevision?: number; submitted?: unknown; takeover?: boolean } | undefined
46
+ const control = adaptiveWorkflow(binding) ? this.store.readDocument(`model-control:${binding.sessionId}`)?.value as { recoveryEpoch?: number } | undefined : undefined
47
+ return digestOf({ order: state.currentWorkOrder?.id, revision: state.currentWorkOrder?.revision,
48
+ brief: runtime?.briefRevision, submitted: runtime?.submitted, takeover: runtime?.takeover,
49
+ phase: state.phase, report: state.validatedReport, review: state.reviewResult,
50
+ ...(adaptiveWorkflow(binding) ? { recoveryEpoch: control?.recoveryEpoch ?? 0 } : {}) })
51
+ }
52
+
53
+ observe(exec: Readonly<ToolExecution>, result: Readonly<ToolExecutionResult>, binding: SessionBinding, role: Role, read: boolean): boolean {
54
+ // Required recovery pagination and a live job wait must neither spend nor erase the window.
55
+ if (exec.name === 'fusion_read_state' || exec.name === 'job_output') return false
56
+ const id = `workflow-progress:${binding.taskId}:${exec.agent!.id}`, row = this.store.readDocument(id)
57
+ const shell = isShellTool(exec.name) && !result.isError ? result.value as { exitCode?: unknown } : undefined
58
+ const failed = result.isError || typeof shell?.exitCode === 'number' && shell.exitCode !== 0
59
+ const args = exec.arguments as { file_path?: string; path?: string; limit?: number }
60
+ const fingerprint = failed || read || exec.name.startsWith('fusion_')
61
+ ? digestOf({ name: exec.name, args: exec.arguments, error: result.isError, content: result.content }) : undefined
62
+ // Advancing through a file is real progress, however inefficient. Frozen v1
63
+ // retains its old slicing detector; v2 only interrupts repeated outcomes.
64
+ const tinyPath = !adaptiveWorkflow(binding) && !failed && exec.name === 'read' && typeof args.limit === 'number' && args.limit <= 2
65
+ ? args.file_path ?? args.path : undefined
66
+ const next = advanceProgress(row?.value as ProgressWindow | undefined, {
67
+ milestone: this.milestone(binding), fingerprint, tinyPath, role,
68
+ })
69
+ this.store.writeDocument(id, row?.revision ?? 0, next.window)
70
+ return next.stalled
71
+ }
72
+
73
+ /** One local replanning opportunity, not an immediate request for user intervention. */
74
+ repairProgress(agent: Agent, binding: SessionBinding, role: Role): boolean {
75
+ if (!adaptiveWorkflow(binding)) return false
76
+ const id = `workflow-replan:${binding.taskId}:${agent.id}:${this.milestone(binding)}`
77
+ if (this.store.readDocument(id)) return false
78
+ this.store.writeDocument(id, 0, { schemaVersion: 1, role, at: new Date().toISOString() })
79
+ const progressId = `workflow-progress:${binding.taskId}:${agent.id}`, row = this.store.readDocument(progressId)
80
+ this.store.writeDocument(progressId, row?.revision ?? 0, { schemaVersion: 1, milestone: this.milestone(binding), recent: [] })
81
+ agent.send(createUserMessage({ source: { kind: FUSION_SOURCE_KIND, form: 'notice', summary: 'Fusion 检测到重复结果,正在调整执行方式' },
82
+ content: [{ type: 'text', text: 'The runtime detected repeated unchanged tool results. Use the evidence already collected and change the approach instead of repeating those calls. Continue the current task; delegate broad work if useful. Another unchanged cycle will pause this role without discarding progress.' }] }), 'next-step', false)
83
+ return true
84
+ }
85
+
86
+ denyEffect(binding: SessionBinding): string {
87
+ const id = `workflow-effect-denied:${binding.taskId}`
88
+ if (!this.store.readDocument(id)) this.store.writeDocument(id, 0, { schemaVersion: 1, taskId: binding.taskId })
89
+ return 'FUSION_LEAD_READ_ONLY: execution belongs to the Sidekick. Use fusion_delegate for workspace changes or commands; fusion_explore for investigation. Lead takeover requires a current implementation report and a recorded rework review.'
90
+ }
91
+
92
+ deniedEffect(binding: SessionBinding): boolean { return Boolean(this.store.readDocument(`workflow-effect-denied:${binding.taskId}`)) }
93
+
94
+ /** At most one repair generation per role and durable milestone, surviving reloads. */
95
+ repairStop(agent: Agent, binding: SessionBinding, role: Role, instruction: string): boolean {
96
+ const id = `workflow-stop:${binding.taskId}:${agent.id}:${this.milestone(binding)}`
97
+ if (this.store.readDocument(id)) return false
98
+ this.store.writeDocument(id, 0, { schemaVersion: 1, role, at: new Date().toISOString() })
99
+ agent.send(createUserMessage({ source: { kind: FUSION_SOURCE_KIND, form: 'notice', summary: 'Fusion 工作流尚未完成' },
100
+ content: [{ type: 'text', text: instruction }] }), 'next-step', false)
101
+ return true
102
+ }
103
+ }
@@ -0,0 +1,54 @@
1
+ import type { Context } from '@deepseek-ai/cordis'
2
+ import type {} from '@deepseek-ai/dsh-tools'
3
+
4
+ /**
5
+ * Provider routes that run their OWN agent loop instead of answering one model
6
+ * call. Their adapter can only hand a tool call back to this Host under its own
7
+ * transport name, so a caller-driven tool ceiling that lists only the caller's
8
+ * own tools advertises names the route can never emit.
9
+ *
10
+ * `antigravity` is dsh-agy-link: `agy` executes the bridged call inside its own
11
+ * loop, and the adapter reports that step as `agy_tool`, which this Host replays
12
+ * as a native card. Fusion's Worker is exactly such a caller — its ceiling was
13
+ * `workerTools` + `fusion_*`, so the delegation ran as prose and the Host
14
+ * (correctly) diagnosed `WORKER_TOOLS_UNREACHABLE`.
15
+ */
16
+ const ROUTE_TRANSPORT: Readonly<Record<string, string>> = { antigravity: 'agy_tool' }
17
+
18
+ /** Every transport a bridge route may report a step through. */
19
+ const ROUTE_TRANSPORT_NAMES: ReadonlySet<string> = new Set(Object.values(ROUTE_TRANSPORT))
20
+
21
+ /** The transport name this provider route needs, registered or not. */
22
+ export function routeTransportName(provider: string): string | undefined {
23
+ return ROUTE_TRANSPORT[provider]
24
+ }
25
+
26
+ /**
27
+ * Whether `name` is a bridge-route transport. Such a tool only replays a step
28
+ * the route already executed in its own loop: it is presentation, not an effect
29
+ * this Host authorizes, bills or holds a write lease for.
30
+ */
31
+ export function isRouteTransportName(name: string): boolean {
32
+ return ROUTE_TRANSPORT_NAMES.has(name)
33
+ }
34
+
35
+ /**
36
+ * The transport this route needs AND this Host can actually accept. An
37
+ * unregistered name is skipped, because a call naming it is rejected as
38
+ * `unknown tool` — the adapter must never invent one either.
39
+ *
40
+ * The lookup is the GLOBAL view on purpose. A bridge registers its transport at
41
+ * the host layer, while an agent scope's own restriction hides inherited tools:
42
+ * reading through the Worker's restricted scope would report "absent" on the
43
+ * very next refresh and shrink the ceiling that had just been widened.
44
+ */
45
+ export function registeredRouteTransport(ctx: Context, provider: string): string | undefined {
46
+ const name = routeTransportName(provider)
47
+ if (name === undefined) return undefined
48
+ return ctx.tools.get(name) === undefined ? undefined : name
49
+ }
50
+
51
+ /** Add the route's transport to a capability ceiling without duplicating a configured entry. */
52
+ export function withRouteTransport(ceiling: readonly string[], transport: string | undefined): readonly string[] {
53
+ return transport === undefined || ceiling.includes(transport) ? ceiling : [...ceiling, transport]
54
+ }
@@ -0,0 +1,93 @@
1
+ import { bi } from '../bilingual.js'
2
+ import type { PairProfile, PhysicalRoute } from '../contracts.js'
3
+ import { keepaliveChoice } from '../profile/keepalive.js'
4
+ import { compactorChoice } from '../profile/compactor.js'
5
+ import { isFusionProvider } from '../identity.js'
6
+
7
+ export interface PairChoice {
8
+ lead: PhysicalRoute
9
+ worker: PhysicalRoute
10
+ outputTokens?: Partial<Record<'lead' | 'worker', number>>
11
+ cacheKeepalive?: PairProfile['cacheKeepalive']
12
+ compactor?: PairProfile['compactor']
13
+ }
14
+ export interface SettingsCatalog {
15
+ groups: readonly { id: string; name: string; models: readonly {
16
+ id: string; name: string; reasoning?: { efforts: readonly { id: string; name: string }[]; defaultEffort?: string }
17
+ }[] }[]
18
+ }
19
+
20
+ /** Saved Lead route from a raw settings or profile document, or undefined when unconfigured or pointed at Fusion itself. */
21
+ export function savedLeadRoute(value: unknown): { provider: string; model: string } | undefined {
22
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined
23
+ const route = (value as Record<string, unknown>).lead
24
+ if (!route || typeof route !== 'object' || Array.isArray(route)) return undefined
25
+ const provider = (route as Record<string, unknown>).provider, model = (route as Record<string, unknown>).model
26
+ if (typeof provider !== 'string' || typeof model !== 'string' || isFusionProvider(provider)) return undefined
27
+ return { provider, model }
28
+ }
29
+
30
+ /** Configuration chooses registered physical models; it cannot create adapters. */
31
+ export function validatePairChoice(value: unknown, catalog: SettingsCatalog): PairChoice {
32
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(bi('请选择 Lead 和 Worker', 'Choose a Lead and a Sidekick'))
33
+ const raw = value as Record<string, unknown>
34
+ const route = (role: string, candidate: unknown): PhysicalRoute => {
35
+ const item = candidate as Record<string, unknown> | undefined
36
+ if (!item || typeof item.provider !== 'string' || typeof item.model !== 'string') throw new Error(bi(`请选择 ${role} 模型`, `Choose the ${role} model`))
37
+ if (isFusionProvider(item.provider)) throw new Error(bi('Fusion 必须选择真实模型', 'Fusion needs real models, not Fusion itself'))
38
+ const group = catalog.groups.find(group => group.id === item.provider)
39
+ const model = group?.models.find(model => model.id === item.model)
40
+ if (!model) throw new Error(bi(`${role} 模型已不可用,请刷新后重选`, `The ${role} model is no longer available; refresh and choose again`))
41
+ if (item.reasoningEffort !== undefined && (typeof item.reasoningEffort !== 'string'
42
+ || !model.reasoning?.efforts.some(effort => effort.id === item.reasoningEffort))) {
43
+ throw new Error(bi(`${role} 不支持所选推理强度`, `${role} does not support the selected reasoning level`))
44
+ }
45
+ return { provider: item.provider, model: item.model,
46
+ ...(item.reasoningEffort === undefined ? {} : { reasoningEffort: item.reasoningEffort as string }) }
47
+ }
48
+ const cacheKeepalive = keepaliveChoice(raw.cacheKeepalive)
49
+ const compactor = compactorChoice(raw.compactor)
50
+ let outputTokens: PairChoice['outputTokens']
51
+ if (raw.outputTokens !== undefined) {
52
+ const value = raw.outputTokens
53
+ if (!value || typeof value !== 'object' || Array.isArray(value)
54
+ || Object.entries(value).some(([role, tokens]) => !['lead', 'worker'].includes(role)
55
+ || !Number.isSafeInteger(tokens) || Number(tokens) < 1 || Number(tokens) > 128_000)) {
56
+ throw new Error(bi('每次响应上限必须是 1–128000 的整数 Token 数', 'The per-response limit must be an integer of 1–128000 tokens'))
57
+ }
58
+ outputTokens = { ...value } as PairChoice['outputTokens']
59
+ }
60
+ return { lead: route('lead', raw.lead), worker: route('worker', raw.worker),
61
+ ...(outputTokens === undefined ? {} : { outputTokens }),
62
+ ...(cacheKeepalive === undefined ? {} : { cacheKeepalive }),
63
+ ...(compactor === undefined ? {} : { compactor: { ...compactor, route: route('压缩 · Compaction', compactor.route) } }) }
64
+ }
65
+
66
+ /** New sessions can opt into this Worker cap. Frozen sessions keep their saved profile. */
67
+ export const WORKER_IMPLEMENTATION_OUTPUT_TOKENS = 128_000
68
+
69
+ export function profileFromChoice(pair: PairChoice): Record<string, unknown> {
70
+ const { outputTokens, ...routes } = pair
71
+ const version = outputTokens?.worker === WORKER_IMPLEMENTATION_OUTPUT_TOKENS ? '0.1.0-worker-128k' : '0.1.0-faithful'
72
+ return { schemaVersion: 1, id: 'fusion-auto', version, enabled: true, quality: 'experimental',
73
+ ...routes, workerUpgradePath: [], dataPolicyId: 'native-host-workspace-policy', evidenceCampaignIds: [],
74
+ ...(outputTokens === undefined ? {} : { context: Object.fromEntries(Object.entries(outputTokens)
75
+ .map(([role, reserveOutputTokens]) => [role, { reserveOutputTokens }])) }) }
76
+ }
77
+
78
+ /** Product defaults; explicit legacy/benchmark profiles retain their old contract. */
79
+ export function modelProfileFromChoice(pair: PairChoice): Record<string, unknown> {
80
+ const { outputTokens: _output, ...native } = pair
81
+ return { ...profileFromChoice(native), version: '0.2.0-enforced-v3', interactionMode: 'model-like', workflowPolicy: 'enforced-v3' }
82
+ }
83
+
84
+ /** Keep existing default settings stable; expose explicit larger/smaller limits on reload. */
85
+ export function choiceFromProfile(profile: PairProfile): PairChoice {
86
+ const outputTokens = Object.fromEntries((['lead', 'worker'] as const)
87
+ .filter(role => profile.context[role].reserveOutputTokens !== 8_000)
88
+ .map(role => [role, profile.context[role].reserveOutputTokens]))
89
+ return { lead: profile.lead, worker: profile.worker,
90
+ ...(profile.interactionMode !== 'model-like' && Object.keys(outputTokens).length ? { outputTokens } : {}),
91
+ ...(profile.compactor === undefined ? {} : { compactor: profile.compactor }),
92
+ ...(profile.cacheKeepalive === undefined ? {} : { cacheKeepalive: profile.cacheKeepalive }) }
93
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * The native shell tool the Host mounts on this platform. cordis.patch.yml
3
+ * disables tool-bash on win32 and mounts tool-pwsh instead; elsewhere the
4
+ * bash tool is the native shell. Safety gates must apply to whichever shell
5
+ * tool issued the call, so checks use {@link isShellTool}, while defaults
6
+ * and probes use {@link nativeShellTool} — the tool that actually exists.
7
+ */
8
+ export const nativeShellTool: 'bash' | 'pwsh' = process.platform === 'win32' ? 'pwsh' : 'bash'
9
+
10
+ export const isShellTool = (name: string): name is 'bash' | 'pwsh' => name === 'bash' || name === 'pwsh'
11
+
12
+ /** Command text the prompt layer names for the platform's native shell. */
13
+ export const nativeShellDescription = process.platform === 'win32' ? 'pwsh' : 'bash'
@@ -0,0 +1,112 @@
1
+ import { readModelControl } from './native-model-control.js'
2
+ import type { TaskState } from '../task/state.js'
3
+ import type { SqliteFusionStore } from '../task/sqlite-store.js'
4
+ import type { StatusIndex } from '../task/status-index.js'
5
+ import type { FusionStatus } from '../status.js'
6
+ import { BindingRepository } from './bindings.js'
7
+
8
+ /** English copies of the stage and detail texts; the client shows them when DSH runs in English. */
9
+ const EN: Record<string, string> = {
10
+ '正在停止': 'Stopping', '正在等待已启动的操作收尾。': 'Waiting for started operations to finish.',
11
+ '等待检查': 'Needs inspection', '请先核对中断的操作,再使用 /fusion recover 提交检查记录。': 'Check the interrupted operations, then record the inspection with /fusion recover.',
12
+ '已取消': 'Cancelled', '等待确认': 'Waiting for approval', '请在 DSH 的权限提示中处理待确认操作。': 'Handle the pending approval in DSH\'s permission prompt.',
13
+ '等待额度': 'Waiting for quota', '请前往设置 → Fusion 查看额度,处理后使用 /fusion resume。': 'See Settings → Fusion for the quota, then use /fusion resume.',
14
+ '已暂停': 'Paused', '使用 /fusion status 查看状态,确认后可用 /fusion resume 继续。': 'Check /fusion status, then continue with /fusion resume.',
15
+ '完成': 'Done', '验证': 'Verifying',
16
+ 'Worker 模型服务提示额度已耗尽,当前任务和文件已保留。恢复额度后发送消息继续。': 'The Sidekick provider reported exhausted quota; the task and files are kept. Send a message to continue once quota is back.',
17
+ '等待反馈': 'Needs your input', 'Worker 已达到本任务的执行次数上限,现有结果已保留。请查看会话中的未完成说明。': 'The Sidekick reached this task\'s step limit; its results are kept. See the unfinished notes in the conversation.',
18
+ '就绪': 'Ready', '探索尚未完成,请查看会话中的阻塞说明;补充信息后会继续原 Worker。': 'Exploration is unfinished: see the blocker in the conversation; the same Sidekick continues once you add the information.',
19
+ '分析': 'Analysing', '执行': 'Working', '修改': 'Revising', '审查': 'Reviewing',
20
+ '请处理 DSH 的待确认操作。': 'Handle the pending DSH approval.', '请查看会话中的问题或阻塞说明。': 'See the question or blocker in the conversation.',
21
+ '请前往设置 → Fusion 查看调用额度。': 'See Settings → Fusion for the request quota.', '请查看 /fusion status 并核对中断的操作。': 'Check /fusion status and the interrupted operations.',
22
+ '确认任务状态后,使用 /fusion resume 继续。': 'Confirm the task state, then continue with /fusion resume.',
23
+ '未完成': 'Unfinished', '请查看会话中的错误和 /fusion status。': 'See the error in the conversation and /fusion status.',
24
+ '完成状态尚未确认,请查看 /fusion status。': 'Completion is unconfirmed; see /fusion status.',
25
+ '需要调整做法': 'Needs a different approach', '等待模型': 'Waiting for the model',
26
+ '继续消息的投递结果尚未确认。检查会话后使用 /fusion recover 提交检查记录。': 'Delivery of the continuation is unconfirmed. Check the conversation, then use /fusion recover.',
27
+ }
28
+ const english = (text: string | null | undefined) => text == null ? text : EN[text] ?? text
29
+
30
+ export function taskStage(state: TaskState, checking: boolean, workerStepLimitReached = false, workerQuotaExhausted = false): Pick<NonNullable<FusionStatus['task']>, 'stage' | 'detail' | 'attention'> {
31
+ const control = state.control
32
+ const value = (stage: string, detail: string | null = null, attention = false) => ({ stage, detail, attention })
33
+ if (control.mode === 'stop-requested') return value('正在停止', '正在等待已启动的操作收尾。', true)
34
+ if (control.recovering || control.outcomeUnknown) return value('等待检查', '请先核对中断的操作,再使用 /fusion recover 提交检查记录。', true)
35
+ if (control.mode === 'cancelled') return value('已取消')
36
+ if (control.pendingApprovalIds.length) return value('等待确认', '请在 DSH 的权限提示中处理待确认操作。', true)
37
+ if (control.budgetBlocked) return value('等待额度', '请前往设置 → Fusion 查看额度,处理后使用 /fusion resume。', true)
38
+ if (control.mode === 'paused') return value('已暂停', '使用 /fusion status 查看状态,确认后可用 /fusion resume 继续。', true)
39
+ if (control.mode === 'completed' && state.phase === 'COMPLETED') return value('完成')
40
+ if (checking) return value('验证')
41
+ if (workerQuotaExhausted && ['WORKER_RUNNING', 'REWORK', 'PLANNING', 'NEEDS_DECISION'].includes(state.phase)) {
42
+ return value('等待额度', 'Worker 模型服务提示额度已耗尽,当前任务和文件已保留。恢复额度后发送消息继续。', true)
43
+ }
44
+ if (workerStepLimitReached && ['WORKER_RUNNING', 'REWORK', 'PLANNING', 'NEEDS_DECISION'].includes(state.phase)) {
45
+ return value('等待反馈', 'Worker 已达到本任务的执行次数上限,现有结果已保留。请查看会话中的未完成说明。', true)
46
+ }
47
+ switch (state.phase) {
48
+ case 'READY': return value('就绪')
49
+ case 'PLANNING': return state.currentWorkOrder?.mode === 'explore' && state.exploration && state.exploration.status !== 'completed'
50
+ ? value('等待反馈', '探索尚未完成,请查看会话中的阻塞说明;补充信息后会继续原 Worker。', true) : value('分析')
51
+ case 'DIRECT': case 'WORKER_RUNNING': return value('执行')
52
+ case 'REWORK': return value('修改')
53
+ case 'REVIEWING': return value('审查')
54
+ case 'WAITING_APPROVAL': return value('等待确认', '请处理 DSH 的待确认操作。', true)
55
+ case 'WAITING_USER': case 'NEEDS_DECISION': return value('等待反馈', '请查看会话中的问题或阻塞说明。', true)
56
+ case 'WAITING_BUDGET': return value('等待额度', '请前往设置 → Fusion 查看调用额度。', true)
57
+ case 'RECOVERING': return value('等待检查', '请查看 /fusion status 并核对中断的操作。', true)
58
+ case 'PAUSED': return value('已暂停', '确认任务状态后,使用 /fusion resume 继续。', true)
59
+ case 'STOPPING': return value('正在停止', null, true)
60
+ case 'FAILED': return value('未完成', '请查看会话中的错误和 /fusion status。', true)
61
+ case 'CANCELLED': return value('已取消')
62
+ case 'COMPLETED': return value('等待检查', '完成状态尚未确认,请查看 /fusion status。', true)
63
+ }
64
+ }
65
+
66
+ /** Reads existing durable facts only. Polling never constructs a runtime or calls a model. */
67
+ export function readFusionStatus(store: SqliteFusionStore, sessionId: string): FusionStatus {
68
+ return store.statusSnapshot(index => readStatus(store, sessionId, index))
69
+ }
70
+
71
+ export function readFusionStatusAsync(store: SqliteFusionStore, sessionId: string, signal?: AbortSignal): Promise<FusionStatus> {
72
+ return store.statusSnapshotAsync(index => readStatus(store, sessionId, index), signal)
73
+ }
74
+
75
+ function readStatus(store: SqliteFusionStore, sessionId: string, index: StatusIndex): FusionStatus {
76
+ const binding = new BindingRepository(store).read(sessionId)?.binding
77
+ const base: FusionStatus = { schemaVersion: 1, sessionId, selected: Boolean(binding?.selected), observedAt: new Date().toISOString() }
78
+ if (!binding?.selected) return base
79
+ const taskId = binding.taskId, state = store.load(taskId)
80
+ if (!state || state.parent !== sessionId || state.profileDigest !== binding.profile.digest) throw new Error('Fusion 任务记录不一致,请检查存档')
81
+ const { checking, ...facts } = index.read(taskId)
82
+ const runtime = store.readDocument(`runtime:${taskId}`)?.value as { schemaVersion?: number; taskId?: string;
83
+ workerStepStop?: { workOrderId?: string; requests: number; limit: number };
84
+ workerFailure?: { workOrderId?: string; childId?: string; category?: string } } | undefined
85
+ const stop = runtime?.workerStepStop
86
+ const workerStepLimitReached = runtime?.schemaVersion === 1 && runtime.taskId === taskId && Boolean(stop
87
+ && state.currentWorkOrder && stop.workOrderId === state.currentWorkOrder.id && stop.limit > 0 && stop.requests >= stop.limit)
88
+ const failure = runtime?.workerFailure
89
+ const workerQuotaExhausted = runtime?.schemaVersion === 1 && runtime.taskId === taskId && Boolean(failure
90
+ && state.currentWorkOrder && failure.workOrderId === state.currentWorkOrder.id && failure.childId === state.acceptedChild
91
+ && failure.category === 'quota-exhausted')
92
+ const modelControl = readModelControl(store, binding)
93
+ const waiting = modelControl && Object.entries(modelControl.waits).filter(([, wait]) => wait?.taskId === taskId)
94
+ const modelStage = waiting?.length && state.control.mode !== 'completed' && state.control.mode !== 'cancelled' && !state.control.budgetBlocked && !checking && !state.control.recovering && !state.control.outcomeUnknown && !state.control.pendingApprovalIds.length
95
+ ? { stage: waiting.some(([, wait]) => ['NO_PROGRESS', 'WORKFLOW_INCOMPLETE'].includes(wait!.code)) ? '需要调整做法' : '等待模型',
96
+ detail: `${waiting.map(([role]) => role === 'lead' ? 'Lead' : 'Sidekick').join('、')} 已暂停。请查看原因后继续。`,
97
+ detailEn: `${waiting.map(([role]) => role === 'lead' ? 'Lead' : 'Sidekick').join(' and ')} paused. See the reason, then continue.`, attention: true } : {}
98
+ const pendingDelivery = modelControl?.operation && ['prepared', 'dispatching'].includes(modelControl.operation.state)
99
+ ? { stage: '等待检查', detail: '继续消息的投递结果尚未确认。检查会话后使用 /fusion recover 提交检查记录。', attention: true } : {}
100
+ const stage = { ...taskStage(state, checking, workerStepLimitReached, workerQuotaExhausted), ...modelStage, ...pendingDelivery }
101
+ return { ...base, task: { id: taskId, revision: state.revision, ...stage, stageEn: english(stage.stage)!,
102
+ detailEn: 'detailEn' in stage && stage.detailEn ? stage.detailEn : english(stage.detail) ?? null, ...(modelControl ? { modelControl } : {}), verification: state.verification,
103
+ workerId: state.acceptedChild ?? binding.workerId ?? null,
104
+ models: { lead: modelControl?.routes.lead ?? binding.profile.lead, worker: modelControl?.routes.worker ?? binding.profile.worker, compactor: binding.profile.compactor?.route ?? null },
105
+ ...facts,
106
+ automatedChecks: Array.isArray((runtime as { checks?: unknown[] } | undefined)?.checks) && runtime?.taskId === taskId
107
+ ? (runtime as { checks: unknown[] }).checks.length : null,
108
+ pendingApprovals: state.control.pendingApprovalIds.length,
109
+ recovering: state.control.recovering,
110
+ outcomeUnknown: state.control.outcomeUnknown,
111
+ } }
112
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Runtime detection for the DSH subagent seam.
3
+ * 0.2.1-rc.2 renamed continuable start/drain and removed `ctx.subagents.start`.
4
+ * 0.2.0-rc.2 still has the old names. Callers branch on the function that is
5
+ * actually present. They do not read a version string.
6
+ */
7
+
8
+ export const supportedDshVersions = '0.2.0-rc.2 and 0.2.1-rc.2'
9
+
10
+ export function hasStartActivation(subagents: object): boolean {
11
+ return typeof (subagents as { startActivation?: unknown }).startActivation === 'function'
12
+ }
13
+
14
+ /** Prefer `primary` (the 0.2.1 name) when both exist. */
15
+ export function requireSubagentMethod(subagents: object, primary: string, fallback: string): string {
16
+ const api = subagents as Record<string, unknown>
17
+ if (typeof api[primary] === 'function') return primary
18
+ if (typeof api[fallback] === 'function') return fallback
19
+ throw new Error(`Fusion supports DeepSeek Harness ${supportedDshVersions}; this host has neither subagents.${primary} nor subagents.${fallback}`)
20
+ }