@sqdr2/dsh-fusion 0.3.0 → 0.3.3

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.
@@ -3,11 +3,11 @@ import type { Agent } from '@deepseek-ai/dsh-agent'
3
3
  import { SessionId as NativeSessionId } from '@deepseek-ai/dsh-session'
4
4
  import type { Session } from '@deepseek-ai/dsh-session'
5
5
  import type { ToolExecution } from '@deepseek-ai/dsh-tools'
6
- import type { Role } from '../contracts.js'
6
+ import type { Role, SandboxMode } from '../contracts.js'
7
7
  import type { SqliteFusionStore } from '../task/sqlite-store.js'
8
8
  import type { SessionBinding } from './bindings.js'
9
9
 
10
- type Mode = 'read-only' | 'workspace-write' | 'danger-full-access'
10
+ type Mode = SandboxMode
11
11
 
12
12
  // Mirrors the Host sandbox-policy augmentation (a type-only declaration; the Host
13
13
  // registers the event at runtime). The plugin does not depend on that package.
@@ -36,6 +36,19 @@ interface Intent {
36
36
 
37
37
  export const roleSeparated = (binding: SessionBinding) => binding.profile.workflowPolicy === 'enforced-v3'
38
38
 
39
+ const intentId = (sessionId: string) => `sandbox-intent:${sessionId}`
40
+
41
+ /**
42
+ * The mode the user chose for a confined Fusion conversation, which is the mode
43
+ * the Sidekick runs under. Undefined until Fusion has confined the Lead, or after
44
+ * the conversation left Fusion and its own mode was restored.
45
+ */
46
+ export function readSandboxIntent(store: SqliteFusionStore, sessionId: string): { mode: Mode } | undefined {
47
+ const intent = store.readDocument(intentId(sessionId))?.value as Intent | undefined
48
+ if (!intent || intent.schemaVersion !== 1 || !intent.active) return undefined
49
+ return { mode: intent.mode }
50
+ }
51
+
39
52
  /**
40
53
  * Role separation enforced below the prompt: the Lead's session runs under the
41
54
  * Host's own read-only sandbox (bash and filesystem are refused by the OS
@@ -53,13 +66,12 @@ export class NativeRoleSandbox {
53
66
  return (this.ctx as unknown as { get(name: string): unknown }).get('sandboxPolicy') as SandboxPolicy | undefined
54
67
  }
55
68
 
56
- #id(sessionId: string) { return `sandbox-intent:${sessionId}` }
57
69
  #intent(sessionId: string): Intent | undefined {
58
- return this.store.readDocument(this.#id(sessionId))?.value as Intent | undefined
70
+ return this.store.readDocument(intentId(sessionId))?.value as Intent | undefined
59
71
  }
60
72
  #save(intent: Intent) {
61
- const prior = this.store.readDocument(this.#id(intent.sessionId))
62
- this.store.writeDocument(this.#id(intent.sessionId), prior?.revision ?? 0, { ...intent, ownSeqs: intent.ownSeqs.slice(-32) })
73
+ const prior = this.store.readDocument(intentId(intent.sessionId))
74
+ this.store.writeDocument(intentId(intent.sessionId), prior?.revision ?? 0, { ...intent, ownSeqs: intent.ownSeqs.slice(-32) })
63
75
  }
64
76
  // Session listeners run synchronously inside append, before the seq is known here.
65
77
  #writing = false
@@ -7,7 +7,7 @@ import { requestOutputReservation } from '../context/guard.js'
7
7
  import type { PhysicalRoute, Role } from '../contracts.js'
8
8
  import type { SessionBinding } from './bindings.js'
9
9
  import { digestOf } from '../digest.js'
10
- import { FUSION_SELECTION_LABELS } from '../identity.js'
10
+ import { isFusionSelectionLabel } from '../identity.js'
11
11
  import { fusionToolDiagnostic, projectRoleTools } from './tool-table.js'
12
12
 
13
13
  export interface FusionScopeCallbacks {
@@ -122,7 +122,7 @@ export class NativeFusionScopes {
122
122
  return { ...decision, messages: [...decision.messages.filter(message => {
123
123
  const source = message.source
124
124
  return !(source?.kind === 'model-selection' && source.form === 'notice'
125
- && FUSION_SELECTION_LABELS.some(label => source.summary.endsWith(label)))
125
+ && isFusionSelectionLabel(source.summary))
126
126
  }).map(message =>
127
127
  this.callbacks.projectMessage?.(agent, entry.binding, role, message) ?? message), ...(taskContext ? [taskContext] : [])] }
128
128
  }, { prepend: true }))
@@ -5,30 +5,47 @@ import type { GenerateOptions, LlmModelInfo, LlmResolvedModelInfo, ModelModality
5
5
  import { SessionId } from '@deepseek-ai/dsh-session'
6
6
  import type {} from '@deepseek-ai/dsh-api-session-controller/types'
7
7
  import type { PhysicalRoute } from '../contracts.js'
8
- import { FUSION_MODEL, FUSION_PROVIDER, isCurrentFusionSelection, isFusionSelection } from '../identity.js'
8
+ import { FUSION_MODEL, FUSION_PROVIDER, isCurrentFusionSelection, isFusionModel, isFusionSelection } from '../identity.js'
9
+ import { modelOfProfileId } from './pairs.js'
9
10
  import type { ResolvedProfile } from '../profile/resolve.js'
10
11
  import type { FusionCoordinator } from './coordinator.js'
11
12
 
12
13
  export { FUSION_MODEL, FUSION_PROVIDER, isFusionSelection }
13
14
 
14
- /** Resolves the configured Lead's declared input modalities; undefined means unconfigured or unverifiable. */
15
- export type LeadInputModalities = () => Promise<readonly ModelModality[] | undefined>
15
+ /** One combo as the native menu needs it: its id, its label and its Lead's modalities. */
16
+ export interface FusionModelEntry {
17
+ readonly id: string
18
+ readonly name: string
19
+ readonly description?: string
20
+ readonly inputModalities: readonly ModelModality[]
21
+ }
22
+
23
+ /** Read every combo this Host currently advertises; empty means unconfigured. */
24
+ export type FusionModels = () => Promise<readonly FusionModelEntry[]>
25
+
26
+ const textOnly = [...new Set<ModelModality>(['text'])]
16
27
 
17
28
  /** Catalog-only local adapter; the native request is physically routed before dispatch. */
18
29
  export class FusionCatalogAdapter extends LlmAdapter {
19
- constructor(private readonly leadInputModalities?: LeadInputModalities) { super() }
30
+ constructor(private readonly models?: FusionModels) { super() }
20
31
  override providerInfo(provider: string) { return { id: provider, name: 'Fusion' } }
21
32
  override async listModels(provider: string): Promise<readonly LlmModelInfo[]> {
22
- // The Lead is the only role that reads user content, so the entry admits
23
- // exactly what the configured Lead admits; text-only until it resolves.
24
- const lead = (await this.leadInputModalities?.()) ?? []
25
- return [{ provider, id: FUSION_MODEL, name: 'Fusion · 自动',
26
- description: 'Lead 与 Worker 协作。在设置 → Fusion 中配置模型。',
27
- inputModalities: [...new Set<ModelModality>(['text', ...lead])] }]
33
+ const declared = await this.models?.().catch(() => []) ?? []
34
+ // The Lead is the only role that reads user content, so an entry admits
35
+ // exactly what its own Lead admits; text-only until it resolves. An
36
+ // installation with no combo yet still offers the historical entry, so the
37
+ // menu can explain what to configure instead of hiding Fusion entirely.
38
+ const entries = declared.length ? declared
39
+ : [{ id: FUSION_MODEL, name: 'Fusion · 自动', inputModalities: textOnly }]
40
+ return entries.map(entry => ({ provider, id: entry.id, name: entry.name,
41
+ description: entry.description ?? 'Lead 与 Worker 协作。在设置 → Fusion 中配置模型。',
42
+ inputModalities: [...new Set<ModelModality>(['text', ...entry.inputModalities])] }))
28
43
  }
29
44
  override async resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> {
30
- if (provider !== FUSION_PROVIDER || model !== FUSION_MODEL) throw new Error('Unknown local Fusion selection')
31
- return (await this.listModels(provider))[0]!
45
+ if (provider !== FUSION_PROVIDER || !isFusionModel(model)) throw new Error('Unknown local Fusion selection')
46
+ const declared = (await this.listModels(provider)).find(entry => entry.id === model)
47
+ if (!declared) throw new Error('Unknown local Fusion selection')
48
+ return declared
32
49
  }
33
50
  override async *stream(_options: GenerateOptions): AsyncIterable<StreamChunk> {
34
51
  throw new Error('Fusion 尚未就绪。请前往设置 → Fusion 配置 Lead 和 Worker。')
@@ -38,30 +55,57 @@ export class FusionCatalogAdapter extends LlmAdapter {
38
55
  /** Public native lifecycle integration. No model-menu replacement or Host patch. */
39
56
  export function installNativeFusionSelection(ctx: Context, input: {
40
57
  coordinator(): FusionCoordinator | undefined
41
- profile(): ResolvedProfile | undefined
58
+ /** The frozen profile of one combo, or undefined when it cannot be used yet. */
59
+ profile(model: string): ResolvedProfile | undefined
60
+ /** Why one combo cannot run, for the message a blocked session shows. */
61
+ reason?(model: string): string | undefined
42
62
  selection(agent: Agent): PhysicalRoute | undefined
43
63
  }): () => void {
44
64
  const pending = new Map<string, Promise<void>>()
45
65
  const failures = new Map<string, string>()
46
66
  const desired = new Map<string, PhysicalRoute>()
47
67
  const selection = (agent: Agent) => desired.get(agent.id) ?? input.selection(agent)
48
- const selected = (agent: Agent) => input.coordinator()?.bindings.read(agent.id)?.binding.selected === true
68
+ const binding = (agent: Agent) => input.coordinator()?.bindings.read(agent.id)?.binding
69
+ const selected = (agent: Agent) => binding(agent)?.selected === true
70
+ /** The combo a selected session is frozen to, or undefined when it runs no Fusion task. */
71
+ const runningCombo = (agent: Agent): string | undefined => {
72
+ const current = binding(agent)
73
+ return current?.selected ? modelOfProfileId(current.profile.id) ?? FUSION_MODEL : undefined
74
+ }
75
+ const blocked = (model: string) => input.reason?.(model) ?? '请先在设置 → Fusion 配置 Lead 和 Worker,再继续此会话。'
49
76
  const change = (agent: Agent) => {
50
77
  if (agent.session.header.parentSession || pending.has(agent.id)) return
51
- const coordinator = input.coordinator(), profile = input.profile(), route = selection(agent)
78
+ const coordinator = input.coordinator(), route = selection(agent)
52
79
  if (!coordinator) return
53
80
  if (isFusionSelection(route)) {
54
- if (!selected(agent) && profile) coordinator.selectBeforeAssembly(agent, profile)
81
+ const model = route?.model ?? FUSION_MODEL
82
+ const profile = input.profile(model), running = runningCombo(agent)
83
+ if (running !== undefined && running !== model) {
84
+ // Another combo was picked inside this conversation. Each combo freezes
85
+ // its own pair, so the drain is the same as leaving Fusion: the next
86
+ // turn starts a new task under the newly chosen combo.
87
+ const transition = (async () => {
88
+ if (agent.status !== 'idle') await coordinator.pause(agent)
89
+ await coordinator.clear(agent)
90
+ if (profile) coordinator.selectBeforeAssembly(agent, profile)
91
+ else failures.set(agent.id, blocked(model))
92
+ })().catch(error => { failures.set(agent.id, String(error)) }).finally(() => { pending.delete(agent.id) })
93
+ pending.set(agent.id, transition)
94
+ return
95
+ }
96
+ if (running === undefined) {
97
+ if (profile) coordinator.selectBeforeAssembly(agent, profile)
98
+ else failures.set(agent.id, blocked(model))
99
+ }
55
100
  if (selected(agent)) {
56
101
  // A saved Host default need not emit model/selection. Persist that
57
102
  // already-chosen intent before physical headers reach the native menu's
58
103
  // projection, otherwise it starts displaying the Lead as the selection.
59
- // A session that named the entry before the rename keeps its task, but
60
- // the route it stored no longer has an adapter; rewrite the intent so
61
- // the next assembly dispatches under the registered name.
104
+ // A session that named the combo before a rename keeps its task, but the
105
+ // route it stored no longer has an adapter; rewrite the spelling only.
62
106
  const intent = agent.session.snapshotEvents().findLast(event => event.type === 'model/selection')
63
107
  if (!isCurrentFusionSelection(intent?.data)) agent.session.append('model/selection', {
64
- provider: FUSION_PROVIDER, model: FUSION_MODEL,
108
+ provider: FUSION_PROVIDER, model,
65
109
  })
66
110
  failures.delete(agent.id)
67
111
  }
@@ -101,7 +145,7 @@ export function installNativeFusionSelection(ctx: Context, input: {
101
145
  // A newly installed exact-Agent interceptor can wrap this listener;
102
146
  // its physical route is applied after this continuation returns.
103
147
  // The catalog adapter itself cannot dispatch any upstream request.
104
- if (!selected(agent)) throw new Error('请先在设置 → Fusion 配置 Lead 和 Worker,再继续此会话。')
148
+ if (!selected(agent)) throw new Error(blocked(route?.model ?? FUSION_MODEL))
105
149
  } else if (selected(agent)) throw new Error('先停止当前 Fusion 任务,再切换模型。')
106
150
  return config
107
151
  }, { prepend: true }),
@@ -109,12 +109,25 @@ export function workerToolBriefProblem(store: SqliteFusionStore, taskId: TaskId,
109
109
  const message = native ?? (transport === undefined ? undefined
110
110
  : events?.findLast(event => event.type === 'assistant/message'
111
111
  && event.data.message.content.some(block => block.type === 'tool-call' && block.name === transport)))
112
- if (!message || message.type !== 'assistant/message' || message.data.interrupted) return 'Worker tool requires a complete native model request'
113
- const row = store.readDocument(`worker-request:${exec.agent!.id}:${message.data.turn}:${message.data.step}`)?.value as {
112
+ type WorkerRequestRow = {
114
113
  schemaVersion?: number; taskId?: string; sessionId?: string; turn?: number; step?: number; briefRevision?: number; workOrderId?: string
115
- } | undefined
116
- if (row?.schemaVersion !== 1 || row.taskId !== taskId || row.sessionId !== exec.agent!.id
117
- || row.turn !== message.data.turn || row.step !== message.data.step) return 'Worker tool has no captured brief identity'
114
+ }
115
+ let row: WorkerRequestRow | undefined
116
+ if (message && message.type === 'assistant/message' && !message.data.interrupted) {
117
+ row = store.readDocument(`worker-request:${exec.agent!.id}:${message.data.turn}:${message.data.step}`)?.value as WorkerRequestRow | undefined
118
+ if (row?.schemaVersion !== 1 || row.taskId !== taskId || row.sessionId !== exec.agent!.id
119
+ || row.turn !== message.data.turn || row.step !== message.data.step) return 'Worker tool has no captured brief identity'
120
+ } else if (transport !== undefined) {
121
+ const ids = store.listDocumentIds(`worker-request:${exec.agent!.id}:`)
122
+ const rows = ids
123
+ .map(id => store.readDocument(id)?.value as WorkerRequestRow | undefined)
124
+ .filter((r): r is NonNullable<WorkerRequestRow> => Boolean(r && r.schemaVersion === 1 && r.taskId === taskId && r.sessionId === exec.agent!.id))
125
+ rows.sort((a, b) => (a.turn ?? 0) - (b.turn ?? 0) || (a.step ?? 0) - (b.step ?? 0))
126
+ row = rows.at(-1)
127
+ if (!row) return 'Worker tool has no captured brief identity'
128
+ } else {
129
+ return 'Worker tool requires a complete native model request'
130
+ }
118
131
  if (row.briefRevision !== revision) return 'A newer Lead brief superseded this request; process it before further tools or a report'
119
132
  const order = store.load(taskId)?.currentWorkOrder
120
133
  if (row.workOrderId !== order?.id && (row.workOrderId || order?.mode)) return 'A newer work order superseded this request; process its plan before further tools'
@@ -0,0 +1,178 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import { bi } from '../bilingual.js'
3
+ import { FUSION_MODEL, isFusionModel } from '../identity.js'
4
+ import { keepaliveChoice } from '../profile/keepalive.js'
5
+ import { compactorChoice } from '../profile/compactor.js'
6
+ import type { SqliteFusionStore } from '../task/sqlite-store.js'
7
+ import type { PairChoice, SettingsCatalog } from './settings.js'
8
+ import { modelProfileFromChoice, validatePairChoice } from './settings.js'
9
+
10
+ /**
11
+ * One saved combo: the model id the native menu advertises, the label beside
12
+ * it, and the pair that id freezes. The id is the durable identity — a session
13
+ * that selected it keeps its own frozen copy — so renaming never moves a combo.
14
+ */
15
+ export interface PairEntry {
16
+ readonly id: string
17
+ readonly name: string
18
+ readonly pair: PairChoice
19
+ }
20
+
21
+ /** The saved combos, in menu order. `auto` is the historical first combo. */
22
+ export interface PairsSnapshot {
23
+ readonly revision: number
24
+ readonly pairs: readonly PairEntry[]
25
+ /** True when these came from a pre-combo `settings:profile` document. */
26
+ readonly migrated: boolean
27
+ }
28
+
29
+ /** Where the combo list lives; `settings:profile` stays readable for migration. */
30
+ export const PAIRS_DOCUMENT = 'settings:pairs'
31
+ const LEGACY_PROFILE_DOCUMENT = 'settings:profile'
32
+
33
+ /** Every menu entry costs one row in the native picker, so the list stays short. */
34
+ export const MAX_PAIRS = 6
35
+ export const MAX_PAIR_NAME = 40
36
+ /** The migrated first combo keeps the label the historical entry had. */
37
+ export const DEFAULT_PAIR_NAME = '自动'
38
+
39
+ export const profileIdOf = (model: string): string => `fusion-${model}`
40
+
41
+ /** The combo a frozen profile belongs to, or undefined for a non-combo profile. */
42
+ export function modelOfProfileId(id: string): string | undefined {
43
+ const model = id.startsWith('fusion-') ? id.slice('fusion-'.length) : undefined
44
+ return isFusionModel(model) ? model : undefined
45
+ }
46
+
47
+ const newModel = (taken: readonly string[]): string => {
48
+ for (let attempt = 0; attempt < 100; attempt += 1) {
49
+ const model = `pair-${randomUUID().replace(/-/g, '').slice(0, 8)}`
50
+ if (!taken.includes(model)) return model
51
+ }
52
+ throw new Error(bi('无法生成新的组合标识', 'Could not allocate a new combo id'))
53
+ }
54
+
55
+ function object(value: unknown): Record<string, unknown> | undefined {
56
+ return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : undefined
57
+ }
58
+
59
+ function route(value: unknown): PairChoice['lead'] | undefined {
60
+ const item = object(value)
61
+ if (!item || typeof item.provider !== 'string' || !item.provider.trim() || typeof item.model !== 'string' || !item.model.trim()) return undefined
62
+ return { provider: item.provider, model: item.model,
63
+ ...(typeof item.reasoningEffort === 'string' && item.reasoningEffort.trim() ? { reasoningEffort: item.reasoningEffort } : {}) }
64
+ }
65
+
66
+ /**
67
+ * Read one stored pair without the Host catalog: a combo whose model has since
68
+ * been retired must stay visible (and editable) instead of hiding the list.
69
+ */
70
+ function structuralPair(value: unknown): PairChoice | undefined {
71
+ const raw = object(value)
72
+ const lead = route(raw?.lead), worker = route(raw?.worker)
73
+ if (!raw || !lead || !worker) return undefined
74
+ const compactorRoute = route(object(raw.compactor)?.route)
75
+ const maxOutputTokens = object(raw.compactor)?.maxOutputTokens
76
+ let cacheKeepalive: PairChoice['cacheKeepalive']
77
+ try { cacheKeepalive = keepaliveChoice(raw.cacheKeepalive) } catch { cacheKeepalive = undefined }
78
+ let compactor: PairChoice['compactor']
79
+ try { compactor = compactorRoute ? compactorChoice({ route: compactorRoute, maxOutputTokens }) : undefined } catch { compactor = undefined }
80
+ return { lead, worker, ...(compactor === undefined ? {} : { compactor }), ...(cacheKeepalive === undefined ? {} : { cacheKeepalive }) }
81
+ }
82
+
83
+ /** A pre-combo profile document as the choice it was saved from. */
84
+ export function choiceFromStoredProfile(value: unknown): PairChoice | undefined {
85
+ const raw = object(value)
86
+ const pair = structuralPair(raw)
87
+ if (!raw || !pair) return undefined
88
+ if (raw.interactionMode === 'model-like') return pair
89
+ // Legacy profiles keep explicit per-role output limits in `context`.
90
+ const context = object(raw.context)
91
+ const outputTokens = Object.fromEntries((['lead', 'worker'] as const)
92
+ .map(role => [role, object(context?.[role])?.reserveOutputTokens] as const)
93
+ .filter(([, tokens]) => Number.isSafeInteger(tokens) && Number(tokens) > 0 && Number(tokens) !== 8_000)
94
+ .map(([role, tokens]) => [role, Number(tokens)]))
95
+ return Object.keys(outputTokens).length ? { ...pair, outputTokens } : pair
96
+ }
97
+
98
+ function parsePairs(value: unknown): PairEntry[] | undefined {
99
+ const raw = object(value)
100
+ if (!raw || raw.schemaVersion !== 1 || !Array.isArray(raw.pairs)) return undefined
101
+ const seen = new Set<string>()
102
+ return raw.pairs.flatMap((item): PairEntry[] => {
103
+ const entry = object(item)
104
+ const pair = structuralPair(entry?.pair)
105
+ if (!entry || !pair || typeof entry.id !== 'string' || !isFusionModel(entry.id) || seen.has(entry.id)) return []
106
+ if (typeof entry.name !== 'string' || !entry.name.trim() || entry.name.length > MAX_PAIR_NAME) return []
107
+ seen.add(entry.id)
108
+ return [{ id: entry.id, name: entry.name, pair }]
109
+ })
110
+ }
111
+
112
+ /** The saved combos; a pre-combo installation migrates to one `auto` combo on read. */
113
+ export function readPairs(store: SqliteFusionStore): PairsSnapshot {
114
+ const document = store.readDocument(PAIRS_DOCUMENT)
115
+ if (document) {
116
+ const pairs = parsePairs(document.value)
117
+ if (!pairs?.length) throw new Error(bi('组合配置已损坏,请在 Fusion 页面重新保存', 'The combo list is unreadable; save it again from the Fusion page'))
118
+ return { revision: document.revision, pairs, migrated: false }
119
+ }
120
+ const legacy = choiceFromStoredProfile(store.readDocument(LEGACY_PROFILE_DOCUMENT)?.value)
121
+ // Revision 0 lets the first save create the document without a conflict.
122
+ return legacy ? { revision: 0, pairs: [{ id: FUSION_MODEL, name: DEFAULT_PAIR_NAME, pair: legacy }], migrated: true }
123
+ : { revision: 0, pairs: [], migrated: false }
124
+ }
125
+
126
+ export function pairName(value: unknown, fallback: string): string {
127
+ if (typeof value !== 'string') return fallback
128
+ const name = value.trim()
129
+ if (!name) return fallback
130
+ if (name.length > MAX_PAIR_NAME) throw new Error(bi(`组合名称最多 ${MAX_PAIR_NAME} 个字符`, `A combo name is at most ${MAX_PAIR_NAME} characters`))
131
+ return name
132
+ }
133
+
134
+ /**
135
+ * Validate the list a settings action proposes. Ids are assigned here, never by
136
+ * the client, so a stale tab cannot name a combo that is already frozen
137
+ * elsewhere; reordering only moves the menu rows.
138
+ */
139
+ export function validatePairs(value: unknown, catalog: SettingsCatalog, existing: readonly PairEntry[]): PairEntry[] {
140
+ if (!Array.isArray(value) || !value.length) throw new Error(bi('请至少保留一个组合', 'Keep at least one combo'))
141
+ if (value.length > MAX_PAIRS) throw new Error(bi(`最多保存 ${MAX_PAIRS} 个组合`, `At most ${MAX_PAIRS} combos can be saved`))
142
+ const known = new Set(existing.map(pair => pair.id))
143
+ const taken = new Set<string>()
144
+ const names = new Set<string>()
145
+ return value.map((item, index) => {
146
+ const raw = object(item)
147
+ if (!raw) throw new Error(bi('组合配置无效', 'Invalid combo entry'))
148
+ // A frozen session keeps its own copy, so an id may be renamed or reordered
149
+ // freely; a new row (or a duplicate) gets an id this Host has never issued.
150
+ // `auto` is the reserved historical id, so the first combo may claim it even
151
+ // on a fresh installation: selections made before combos existed still name it.
152
+ const requested = typeof raw.id === 'string' ? raw.id : ''
153
+ const id = (known.has(requested) || requested === FUSION_MODEL) && !taken.has(requested) ? requested : newModel([...taken])
154
+ taken.add(id)
155
+ const name = pairName(raw.name, index === 0 ? DEFAULT_PAIR_NAME : `组合 ${index + 1}`)
156
+ if (names.has(name)) throw new Error(bi(`组合名称 "${name}" 重复`, `The combo name "${name}" is used twice`))
157
+ names.add(name)
158
+ return { id, name, pair: validatePairChoice(raw.pair, catalog) }
159
+ })
160
+ }
161
+
162
+ export function pairsDocument(pairs: readonly PairEntry[]): Record<string, unknown> {
163
+ return { schemaVersion: 1, pairs: pairs.map(({ id, name, pair }) => ({ id, name, pair })) }
164
+ }
165
+
166
+ /**
167
+ * Persist the list under one compare-and-swap revision. The first combo is
168
+ * mirrored into the pre-combo `settings:profile` document so an older tab (or a
169
+ * downgrade) still finds the pair it knows about.
170
+ */
171
+ export function writePairs(store: SqliteFusionStore, expectedRevision: number, pairs: readonly PairEntry[]): number {
172
+ const mirror = pairs.find(pair => pair.id === FUSION_MODEL) ?? pairs[0]!
173
+ const legacy = store.readDocument(LEGACY_PROFILE_DOCUMENT)
174
+ return store.writeDocuments([
175
+ { id: PAIRS_DOCUMENT, expectedRevision, value: pairsDocument(pairs) },
176
+ { id: LEGACY_PROFILE_DOCUMENT, expectedRevision: legacy?.revision ?? 0, value: modelProfileFromChoice(mirror.pair, profileIdOf(mirror.id)) },
177
+ ])[0]!
178
+ }
@@ -66,19 +66,23 @@ export function validatePairChoice(value: unknown, catalog: SettingsCatalog): Pa
66
66
  /** New sessions can opt into this Worker cap. Frozen sessions keep their saved profile. */
67
67
  export const WORKER_IMPLEMENTATION_OUTPUT_TOKENS = 128_000
68
68
 
69
- export function profileFromChoice(pair: PairChoice): Record<string, unknown> {
69
+ export function profileFromChoice(pair: PairChoice, id = 'fusion-auto'): Record<string, unknown> {
70
70
  const { outputTokens, ...routes } = pair
71
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',
72
+ return { schemaVersion: 1, id, version, enabled: true, quality: 'experimental',
73
73
  ...routes, workerUpgradePath: [], dataPolicyId: 'native-host-workspace-policy', evidenceCampaignIds: [],
74
74
  ...(outputTokens === undefined ? {} : { context: Object.fromEntries(Object.entries(outputTokens)
75
75
  .map(([role, reserveOutputTokens]) => [role, { reserveOutputTokens }])) }) }
76
76
  }
77
77
 
78
- /** Product defaults; explicit legacy/benchmark profiles retain their old contract. */
79
- export function modelProfileFromChoice(pair: PairChoice): Record<string, unknown> {
78
+ /**
79
+ * Product defaults; explicit legacy/benchmark profiles retain their old contract.
80
+ * `id` names the combo; the first one keeps the historical `fusion-auto` so a
81
+ * profile frozen before combos existed keeps its identity.
82
+ */
83
+ export function modelProfileFromChoice(pair: PairChoice, id = 'fusion-auto'): Record<string, unknown> {
80
84
  const { outputTokens: _output, ...native } = pair
81
- return { ...profileFromChoice(native), version: '0.2.0-enforced-v3', interactionMode: 'model-like', workflowPolicy: 'enforced-v3' }
85
+ return { ...profileFromChoice(native, id), version: '0.2.0-enforced-v3', interactionMode: 'model-like', workflowPolicy: 'enforced-v3' }
82
86
  }
83
87
 
84
88
  /** Keep existing default settings stable; expose explicit larger/smaller limits on reload. */
@@ -1,4 +1,6 @@
1
1
  import { readModelControl } from './native-model-control.js'
2
+ import { readSandboxIntent, roleSeparated } from './native-role-sandbox.js'
3
+ import type { Phase, Role } from '../contracts.js'
2
4
  import type { TaskState } from '../task/state.js'
3
5
  import type { SqliteFusionStore } from '../task/sqlite-store.js'
4
6
  import type { StatusIndex } from '../task/status-index.js'
@@ -27,6 +29,18 @@ const EN: Record<string, string> = {
27
29
  }
28
30
  const english = (text: string | null | undefined) => text == null ? text : EN[text] ?? text
29
31
 
32
+ /**
33
+ * The role working right now: an in-flight request names it exactly (requests read
34
+ * newest first), and between requests the phase does ('DIRECT' is the Lead writing).
35
+ */
36
+ function activeRole(requests: readonly { role: Role; purpose: string; outcome: string }[], phase: Phase): Role | null {
37
+ const running = requests.find(request => request.purpose === 'conversation' && request.outcome === 'entered')
38
+ if (running) return running.role
39
+ if (phase === 'WORKER_RUNNING' || phase === 'REWORK') return 'worker'
40
+ if (phase === 'PLANNING' || phase === 'REVIEWING' || phase === 'DIRECT') return 'lead'
41
+ return null
42
+ }
43
+
30
44
  export function taskStage(state: TaskState, checking: boolean, workerStepLimitReached = false, workerQuotaExhausted = false): Pick<NonNullable<FusionStatus['task']>, 'stage' | 'detail' | 'attention'> {
31
45
  const control = state.control
32
46
  const value = (stage: string, detail: string | null = null, attention = false) => ({ stage, detail, attention })
@@ -81,7 +95,8 @@ function readStatus(store: SqliteFusionStore, sessionId: string, index: StatusIn
81
95
  const { checking, ...facts } = index.read(taskId)
82
96
  const runtime = store.readDocument(`runtime:${taskId}`)?.value as { schemaVersion?: number; taskId?: string;
83
97
  workerStepStop?: { workOrderId?: string; requests: number; limit: number };
84
- workerFailure?: { workOrderId?: string; childId?: string; category?: string } } | undefined
98
+ workerFailure?: { workOrderId?: string; childId?: string; category?: string };
99
+ takeover?: boolean } | undefined
85
100
  const stop = runtime?.workerStepStop
86
101
  const workerStepLimitReached = runtime?.schemaVersion === 1 && runtime.taskId === taskId && Boolean(stop
87
102
  && state.currentWorkOrder && stop.workOrderId === state.currentWorkOrder.id && stop.limit > 0 && stop.requests >= stop.limit)
@@ -98,10 +113,18 @@ function readStatus(store: SqliteFusionStore, sessionId: string, index: StatusIn
98
113
  const pendingDelivery = modelControl?.operation && ['prepared', 'dispatching'].includes(modelControl.operation.state)
99
114
  ? { stage: '等待检查', detail: '继续消息的投递结果尚未确认。检查会话后使用 /fusion recover 提交检查记录。', attention: true } : {}
100
115
  const stage = { ...taskStage(state, checking, workerStepLimitReached, workerQuotaExhausted), ...modelStage, ...pendingDelivery }
116
+ // The composer's own permission control follows the conversation (Lead) session,
117
+ // where Fusion's read-only confinement matches no configured preset. Report the
118
+ // per-role modes so the UI can name the permission of the role that is working.
119
+ const intent = roleSeparated(binding) ? readSandboxIntent(store, sessionId) : undefined
120
+ const takeover = runtime?.schemaVersion === 1 && runtime.taskId === taskId && runtime.takeover === true
121
+ const permissions = intent && { lead: takeover ? intent.mode : 'read-only' as const,
122
+ worker: intent.mode, active: activeRole(facts.requests, state.phase) }
101
123
  return { ...base, task: { id: taskId, revision: state.revision, ...stage, stageEn: english(stage.stage)!,
102
124
  detailEn: 'detailEn' in stage && stage.detailEn ? stage.detailEn : english(stage.detail) ?? null, ...(modelControl ? { modelControl } : {}), verification: state.verification,
103
125
  workerId: state.acceptedChild ?? binding.workerId ?? null,
104
126
  models: { lead: modelControl?.routes.lead ?? binding.profile.lead, worker: modelControl?.routes.worker ?? binding.profile.worker, compactor: binding.profile.compactor?.route ?? null },
127
+ ...(permissions ? { permissions } : {}),
105
128
  ...facts,
106
129
  automatedChecks: Array.isArray((runtime as { checks?: unknown[] } | undefined)?.checks) && runtime?.taskId === taskId
107
130
  ? (runtime as { checks: unknown[] }).checks.length : null,
package/src/identity.ts CHANGED
@@ -33,12 +33,30 @@ export const PLUGIN_NAME = 'dsh-fusion'
33
33
  /** The plugin id and package name this project used before the rename. */
34
34
  export const LEGACY_PLUGIN_NAME = 'dsh-model-fusion'
35
35
 
36
- /** Registered provider route for the Fusion model entry. */
36
+ /** Registered provider route for the Fusion model entries. */
37
37
  export const FUSION_PROVIDER = 'dsh-fusion'
38
38
 
39
- /** The single model id the Fusion entry advertises. */
39
+ /**
40
+ * The model id of the combo every installation starts with.
41
+ *
42
+ * It is a pair id like any other; keeping this exact spelling for the first
43
+ * combo means a durable `model/selection` written before combos existed still
44
+ * names a combo that exists.
45
+ */
40
46
  export const FUSION_MODEL = 'auto'
41
47
 
48
+ /**
49
+ * The model ids this provider advertises: `auto` plus one generated
50
+ * `pair-xxxxxxxx` per additional combo. The browser bundle inlines this module,
51
+ * so the namespace shape is fixed here rather than read from a settings
52
+ * document at import time.
53
+ */
54
+ const FUSION_MODEL_PATTERN = /^(?:auto|pair-[a-z0-9]{6,12})$/
55
+
56
+ /** Whether a model id names a Fusion combo on this provider. */
57
+ export const isFusionModel = (model: string | undefined): boolean =>
58
+ typeof model === 'string' && FUSION_MODEL_PATTERN.test(model)
59
+
42
60
  /**
43
61
  * The provider route this plugin registered before it was renamed from
44
62
  * `dsh-model-fusion`. Sessions created then still name it in their durable
@@ -51,26 +69,31 @@ export const LEGACY_FUSION_PROVIDER = 'dsh-model-fusion'
51
69
  export const isFusionProvider = (provider: string | undefined): boolean =>
52
70
  provider === FUSION_PROVIDER || provider === LEGACY_FUSION_PROVIDER
53
71
 
54
- /** Whether a route names the Fusion entry, under either name. */
72
+ /** Whether a route names a Fusion combo, under either provider spelling. */
55
73
  export const isFusionSelection = (route: { provider?: string; model?: string } | undefined): boolean =>
56
- isFusionProvider(route?.provider) && route?.model === FUSION_MODEL
74
+ isFusionProvider(route?.provider) && isFusionModel(route?.model)
57
75
 
58
76
  /**
59
- * How a native model-switch notice names this entry (`provider/model`).
77
+ * How a native model-switch notice names a combo: `provider/model`, or the bare
78
+ * model when both sides share this provider (switching from one combo to
79
+ * another).
60
80
  *
61
- * Both spellings are listed: a session that switched to Fusion before the
62
- * rename carries the old one in its durable log, and the notice is the same
63
- * non-change on every Fusion step either way.
81
+ * The notice is a pseudo-change: it compares the virtual menu route with the
82
+ * physical Lead route that actually dispatches, so it would otherwise repeat on
83
+ * every Fusion step. Both provider spellings are accepted because a session
84
+ * that switched to Fusion before the rename carries the old one in its durable
85
+ * log; any combo id matches, since combos are not physical models.
64
86
  */
65
- export const FUSION_SELECTION_LABELS = [
66
- `${FUSION_PROVIDER}/${FUSION_MODEL}`,
67
- `${LEGACY_FUSION_PROVIDER}/${FUSION_MODEL}`,
68
- ] as const
87
+ const FUSION_SELECTION_LABEL_PATTERN = /(?:(?:dsh-fusion|dsh-model-fusion)\/|→ )(?:auto|pair-[a-z0-9]{6,12})$/
88
+
89
+ /** Whether a switch-notice summary describes a Fusion combo as either side. */
90
+ export const isFusionSelectionLabel = (summary: string | undefined): boolean =>
91
+ typeof summary === 'string' && FUSION_SELECTION_LABEL_PATTERN.test(summary)
69
92
 
70
93
  /**
71
- * Whether a route already names the entry under its current name. A durable
72
- * intent that fails this is rewritten before the next request is assembled;
73
- * the legacy spelling is a read-side alias, not a route that still dispatches.
94
+ * Whether a route already names a combo under its current provider spelling. A
95
+ * durable intent that fails this is rewritten before the next request is
96
+ * assembled; the legacy spelling is a read-side alias, not a dispatching route.
74
97
  */
75
98
  export const isCurrentFusionSelection = (route: { provider?: string; model?: string } | undefined): boolean =>
76
- route?.provider === FUSION_PROVIDER && route.model === FUSION_MODEL
99
+ route?.provider === FUSION_PROVIDER && isFusionModel(route.model)
package/src/status.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { ControlView } from './host/native-model-control.js'
2
- import type { PhysicalRoute, Role, TokenMeasurement, VerificationLevel } from './contracts.js'
2
+ import type { PhysicalRoute, Role, SandboxMode, TokenMeasurement, VerificationLevel } from './contracts.js'
3
3
 
4
4
  /** Bounded, read-only UI projection. No prompts, tool arguments or credentials. */
5
5
  export interface FusionStatus {
@@ -20,6 +20,22 @@ export interface FusionStatus {
20
20
  workerId: string | null
21
21
  modelControl?: ControlView
22
22
  models: { lead: PhysicalRoute; worker: PhysicalRoute; compactor: PhysicalRoute | null }
23
+ /**
24
+ * Role-separated sandbox modes, present while Fusion confines the Lead. The
25
+ * composer's own permission control follows the conversation (Lead) session,
26
+ * so the UI shows these instead of the derived preset name.
27
+ */
28
+ permissions?: {
29
+ /**
30
+ * What the Lead's own work is confined to. The session is widened only for the
31
+ * checks Fusion runs itself and for the Lead during a Host-unlocked takeover.
32
+ */
33
+ lead: SandboxMode
34
+ /** What the Sidekick runs under: the mode the user chose for this conversation. */
35
+ worker: SandboxMode
36
+ /** The role working right now; null while the task waits. */
37
+ active: Role | null
38
+ }
23
39
  usage: {
24
40
  calls: number
25
41
  finalCalls: number