@sqdr2/dsh-fusion 0.3.2 → 0.3.4

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.
@@ -0,0 +1,15 @@
1
+ import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store';
2
+ import type { ModelDirectoryState } from '@deepseek-ai/dsh-client-ui-model-selection/client';
3
+ export interface FusionPermissionProps {
4
+ sessionId: string;
5
+ directory: ObservableSnapshot<ModelDirectoryState>;
6
+ load(): void;
7
+ }
8
+ /**
9
+ * The permission of the model working now. The composer's native permission control
10
+ * belongs to the conversation (Lead) session, which Fusion confines to read-only, and
11
+ * that confinement matches no configured preset — so the control reads Custom while a
12
+ * task runs. This additive chip names the role and its mode, so the tool row never
13
+ * shows an unexplained Custom alone.
14
+ */
15
+ export declare function FusionPermission({ sessionId, directory, load }: FusionPermissionProps): import("react").JSX.Element | null;
@@ -1,5 +1,13 @@
1
1
  import type { PhysicalRoute } from '../contracts.js';
2
- import type { SettingsCatalog } from '../host/settings.js';
2
+ import type { PairChoice, SettingsCatalog } from '../host/settings.js';
3
+ /** One saved combo as the page shows it: its id, its label and its pair. */
4
+ export interface SavedPairView {
5
+ id: string;
6
+ name: string;
7
+ pair: PairChoice;
8
+ authorized: boolean;
9
+ reason?: string;
10
+ }
3
11
  export declare function ModelChoice({ role, route, catalog, onChange }: {
4
12
  role: 'Lead' | 'Sidekick' | 'compactor';
5
13
  route?: PhysicalRoute;
@@ -4,6 +4,10 @@ interface Snapshot {
4
4
  status?: FusionStatus;
5
5
  failed: boolean;
6
6
  }
7
- /** One in-flight read per mounted session; collapsed hints still discover new attention states. */
8
- export declare function useFusionStatus(sessionId: string, expandedTask: string | undefined, attentionOnly?: boolean): Snapshot | undefined;
7
+ /**
8
+ * One in-flight read per mounted session; collapsed hints still discover new attention states.
9
+ * `live` asks for the fast cadence whenever the task is running, for a view whose whole
10
+ * purpose is the current stage (the role and permission of the model working now).
11
+ */
12
+ export declare function useFusionStatus(sessionId: string, expandedTask: string | undefined, attentionOnly?: boolean, live?: boolean): Snapshot | undefined;
9
13
  export {};
@@ -37,6 +37,8 @@ export type RunSelection = {
37
37
  requestedVersion?: string;
38
38
  };
39
39
  export type Role = 'lead' | 'worker';
40
+ /** A Host sandbox mode, spelled as the `sandbox/mode` session event writes it. */
41
+ export type SandboxMode = 'read-only' | 'workspace-write' | 'danger-full-access';
40
42
  export type Phase = 'READY' | 'PLANNING' | 'DIRECT' | 'WORKER_RUNNING' | 'REVIEWING' | 'REWORK' | 'WAITING_APPROVAL' | 'WAITING_USER' | 'WAITING_BUDGET' | 'RECOVERING' | 'PAUSED' | 'STOPPING' | 'NEEDS_DECISION' | 'FAILED' | 'COMPLETED' | 'CANCELLED';
41
43
  export type VerificationLevel = 'unverified' | 'partial' | 'verified';
42
44
  export type ExecutionIntent = 'DIRECT' | 'DELEGATE';
@@ -93,11 +93,21 @@ type Route = {
93
93
  provider: string;
94
94
  model: string;
95
95
  };
96
- /** One row of the settings page: what applies, why, what the defaults say, and what was observed. */
97
- export declare function cacheView(policy: CachePolicy, pair?: {
98
- lead: Route;
99
- worker: Route;
100
- }, pairChoice?: {
96
+ /** A saved combo's routes, plus its optional per-role keepalive choice. */
97
+ export interface CachePairRoutes {
98
+ readonly lead: Route;
99
+ readonly worker: Route;
100
+ readonly cacheKeepalive?: {
101
+ lead?: boolean | 'auto';
102
+ worker?: boolean | 'auto';
103
+ };
104
+ }
105
+ /**
106
+ * One row of the settings page: what applies, why, what the defaults say, and
107
+ * what was observed. Every saved combo contributes its routes, so a second
108
+ * combo's models are listed and labelled even before a session uses them.
109
+ */
110
+ export declare function cacheView(policy: CachePolicy, pair?: CachePairRoutes | readonly CachePairRoutes[], pairChoice?: {
101
111
  lead?: boolean | 'auto';
102
112
  worker?: boolean | 'auto';
103
113
  }): {
@@ -106,7 +116,7 @@ export declare function cacheView(policy: CachePolicy, pair?: {
106
116
  role: Role | undefined;
107
117
  mode: CacheMode;
108
118
  intervalSeconds: number;
109
- modeSource: "user" | "role" | "route" | "family" | "generic" | "pair";
119
+ modeSource: "user" | "role" | "route" | "pair" | "family" | "generic";
110
120
  intervalSource: "user" | "route" | "family" | "generic" | "learned";
111
121
  setting: ModelCacheSetting;
112
122
  defaults: {
@@ -1,10 +1,10 @@
1
1
  import type { Context } from '@deepseek-ai/cordis';
2
2
  import type { Agent } from '@deepseek-ai/dsh-agent';
3
3
  import type { ToolExecution } from '@deepseek-ai/dsh-tools';
4
- import type { Role } from '../contracts.js';
4
+ import type { Role, SandboxMode } from '../contracts.js';
5
5
  import type { SqliteFusionStore } from '../task/sqlite-store.js';
6
6
  import type { SessionBinding } from './bindings.js';
7
- type Mode = 'read-only' | 'workspace-write' | 'danger-full-access';
7
+ type Mode = SandboxMode;
8
8
  declare module '@deepseek-ai/dsh-session/types' {
9
9
  interface SessionEventMap {
10
10
  'sandbox/mode': {
@@ -14,6 +14,14 @@ declare module '@deepseek-ai/dsh-session/types' {
14
14
  }
15
15
  }
16
16
  export declare const roleSeparated: (binding: SessionBinding) => boolean;
17
+ /**
18
+ * The mode the user chose for a confined Fusion conversation, which is the mode
19
+ * the Sidekick runs under. Undefined until Fusion has confined the Lead, or after
20
+ * the conversation left Fusion and its own mode was restored.
21
+ */
22
+ export declare function readSandboxIntent(store: SqliteFusionStore, sessionId: string): {
23
+ mode: Mode;
24
+ } | undefined;
17
25
  /**
18
26
  * Role separation enforced below the prompt: the Lead's session runs under the
19
27
  * Host's own read-only sandbox (bash and filesystem are refused by the OS
@@ -7,12 +7,19 @@ import { FUSION_MODEL, FUSION_PROVIDER, isFusionSelection } from '../identity.js
7
7
  import type { ResolvedProfile } from '../profile/resolve.js';
8
8
  import type { FusionCoordinator } from './coordinator.js';
9
9
  export { FUSION_MODEL, FUSION_PROVIDER, isFusionSelection };
10
- /** Resolves the configured Lead's declared input modalities; undefined means unconfigured or unverifiable. */
11
- export type LeadInputModalities = () => Promise<readonly ModelModality[] | undefined>;
10
+ /** One combo as the native menu needs it: its id, its label and its Lead's modalities. */
11
+ export interface FusionModelEntry {
12
+ readonly id: string;
13
+ readonly name: string;
14
+ readonly description?: string;
15
+ readonly inputModalities: readonly ModelModality[];
16
+ }
17
+ /** Read every combo this Host currently advertises; empty means unconfigured. */
18
+ export type FusionModels = () => Promise<readonly FusionModelEntry[]>;
12
19
  /** Catalog-only local adapter; the native request is physically routed before dispatch. */
13
20
  export declare class FusionCatalogAdapter extends LlmAdapter {
14
- private readonly leadInputModalities?;
15
- constructor(leadInputModalities?: LeadInputModalities | undefined);
21
+ private readonly models?;
22
+ constructor(models?: FusionModels | undefined);
16
23
  providerInfo(provider: string): {
17
24
  id: string;
18
25
  name: string;
@@ -24,6 +31,9 @@ export declare class FusionCatalogAdapter extends LlmAdapter {
24
31
  /** Public native lifecycle integration. No model-menu replacement or Host patch. */
25
32
  export declare function installNativeFusionSelection(ctx: Context, input: {
26
33
  coordinator(): FusionCoordinator | undefined;
27
- profile(): ResolvedProfile | undefined;
34
+ /** The frozen profile of one combo, or undefined when it cannot be used yet. */
35
+ profile(model: string): ResolvedProfile | undefined;
36
+ /** Why one combo cannot run, for the message a blocked session shows. */
37
+ reason?(model: string): string | undefined;
28
38
  selection(agent: Agent): PhysicalRoute | undefined;
29
39
  }): () => void;
@@ -0,0 +1,47 @@
1
+ import type { SqliteFusionStore } from '../task/sqlite-store.js';
2
+ import type { PairChoice, SettingsCatalog } from './settings.js';
3
+ /**
4
+ * One saved combo: the model id the native menu advertises, the label beside
5
+ * it, and the pair that id freezes. The id is the durable identity — a session
6
+ * that selected it keeps its own frozen copy — so renaming never moves a combo.
7
+ */
8
+ export interface PairEntry {
9
+ readonly id: string;
10
+ readonly name: string;
11
+ readonly pair: PairChoice;
12
+ }
13
+ /** The saved combos, in menu order. `auto` is the historical first combo. */
14
+ export interface PairsSnapshot {
15
+ readonly revision: number;
16
+ readonly pairs: readonly PairEntry[];
17
+ /** True when these came from a pre-combo `settings:profile` document. */
18
+ readonly migrated: boolean;
19
+ }
20
+ /** Where the combo list lives; `settings:profile` stays readable for migration. */
21
+ export declare const PAIRS_DOCUMENT = "settings:pairs";
22
+ /** Every menu entry costs one row in the native picker, so the list stays short. */
23
+ export declare const MAX_PAIRS = 6;
24
+ export declare const MAX_PAIR_NAME = 40;
25
+ /** The migrated first combo keeps the label the historical entry had. */
26
+ export declare const DEFAULT_PAIR_NAME = "\u81EA\u52A8";
27
+ export declare const profileIdOf: (model: string) => string;
28
+ /** The combo a frozen profile belongs to, or undefined for a non-combo profile. */
29
+ export declare function modelOfProfileId(id: string): string | undefined;
30
+ /** A pre-combo profile document as the choice it was saved from. */
31
+ export declare function choiceFromStoredProfile(value: unknown): PairChoice | undefined;
32
+ /** The saved combos; a pre-combo installation migrates to one `auto` combo on read. */
33
+ export declare function readPairs(store: SqliteFusionStore): PairsSnapshot;
34
+ export declare function pairName(value: unknown, fallback: string): string;
35
+ /**
36
+ * Validate the list a settings action proposes. Ids are assigned here, never by
37
+ * the client, so a stale tab cannot name a combo that is already frozen
38
+ * elsewhere; reordering only moves the menu rows.
39
+ */
40
+ export declare function validatePairs(value: unknown, catalog: SettingsCatalog, existing: readonly PairEntry[]): PairEntry[];
41
+ export declare function pairsDocument(pairs: readonly PairEntry[]): Record<string, unknown>;
42
+ /**
43
+ * Persist the list under one compare-and-swap revision. The first combo is
44
+ * mirrored into the pre-combo `settings:profile` document so an older tab (or a
45
+ * downgrade) still finds the pair it knows about.
46
+ */
47
+ export declare function writePairs(store: SqliteFusionStore, expectedRevision: number, pairs: readonly PairEntry[]): number;
@@ -28,12 +28,31 @@ export declare function savedLeadRoute(value: unknown): {
28
28
  provider: string;
29
29
  model: string;
30
30
  } | undefined;
31
+ /** The roles a saved pair names, in the order the settings page shows them. */
32
+ export type PairRole = 'lead' | 'worker' | 'compactor';
33
+ /**
34
+ * The routes a saved choice still names that the current catalog no longer
35
+ * offers: a provider that was renamed or removed, or a model that was retired.
36
+ *
37
+ * Reading a saved combo tolerates a stale route so the row stays visible and
38
+ * editable, but saving one does not. Without this, the page can only say that
39
+ * *some* model is gone, which is unusable when the stale route is not the one
40
+ * being edited — the user then cannot tell which combo to fix.
41
+ */
42
+ export declare function missingRoutes(value: Partial<PairChoice> | undefined, catalog: SettingsCatalog): {
43
+ role: PairRole;
44
+ route: PhysicalRoute;
45
+ }[];
31
46
  /** Configuration chooses registered physical models; it cannot create adapters. */
32
- export declare function validatePairChoice(value: unknown, catalog: SettingsCatalog): PairChoice;
47
+ export declare function validatePairChoice(value: unknown, catalog: SettingsCatalog, combo?: string): PairChoice;
33
48
  /** New sessions can opt into this Worker cap. Frozen sessions keep their saved profile. */
34
49
  export declare const WORKER_IMPLEMENTATION_OUTPUT_TOKENS = 128000;
35
- export declare function profileFromChoice(pair: PairChoice): Record<string, unknown>;
36
- /** Product defaults; explicit legacy/benchmark profiles retain their old contract. */
37
- export declare function modelProfileFromChoice(pair: PairChoice): Record<string, unknown>;
50
+ export declare function profileFromChoice(pair: PairChoice, id?: string): Record<string, unknown>;
51
+ /**
52
+ * Product defaults; explicit legacy/benchmark profiles retain their old contract.
53
+ * `id` names the combo; the first one keeps the historical `fusion-auto` so a
54
+ * profile frozen before combos existed keeps its identity.
55
+ */
56
+ export declare function modelProfileFromChoice(pair: PairChoice, id?: string): Record<string, unknown>;
38
57
  /** Keep existing default settings stable; expose explicit larger/smaller limits on reload. */
39
58
  export declare function choiceFromProfile(profile: PairProfile): PairChoice;
@@ -27,10 +27,18 @@ export declare const PACKAGE_NAME = "@sqdr2/dsh-fusion";
27
27
  export declare const PLUGIN_NAME = "dsh-fusion";
28
28
  /** The plugin id and package name this project used before the rename. */
29
29
  export declare const LEGACY_PLUGIN_NAME = "dsh-model-fusion";
30
- /** Registered provider route for the Fusion model entry. */
30
+ /** Registered provider route for the Fusion model entries. */
31
31
  export declare const FUSION_PROVIDER = "dsh-fusion";
32
- /** The single model id the Fusion entry advertises. */
32
+ /**
33
+ * The model id of the combo every installation starts with.
34
+ *
35
+ * It is a pair id like any other; keeping this exact spelling for the first
36
+ * combo means a durable `model/selection` written before combos existed still
37
+ * names a combo that exists.
38
+ */
33
39
  export declare const FUSION_MODEL = "auto";
40
+ /** Whether a model id names a Fusion combo on this provider. */
41
+ export declare const isFusionModel: (model: string | undefined) => boolean;
34
42
  /**
35
43
  * The provider route this plugin registered before it was renamed from
36
44
  * `dsh-model-fusion`. Sessions created then still name it in their durable
@@ -40,23 +48,17 @@ export declare const FUSION_MODEL = "auto";
40
48
  export declare const LEGACY_FUSION_PROVIDER = "dsh-model-fusion";
41
49
  /** Whether a provider route belongs to this plugin, under either name. */
42
50
  export declare const isFusionProvider: (provider: string | undefined) => boolean;
43
- /** Whether a route names the Fusion entry, under either name. */
51
+ /** Whether a route names a Fusion combo, under either provider spelling. */
44
52
  export declare const isFusionSelection: (route: {
45
53
  provider?: string;
46
54
  model?: string;
47
55
  } | undefined) => boolean;
56
+ /** Whether a switch-notice summary describes a Fusion combo as either side. */
57
+ export declare const isFusionSelectionLabel: (summary: string | undefined) => boolean;
48
58
  /**
49
- * How a native model-switch notice names this entry (`provider/model`).
50
- *
51
- * Both spellings are listed: a session that switched to Fusion before the
52
- * rename carries the old one in its durable log, and the notice is the same
53
- * non-change on every Fusion step either way.
54
- */
55
- export declare const FUSION_SELECTION_LABELS: readonly ["dsh-fusion/auto", "dsh-model-fusion/auto"];
56
- /**
57
- * Whether a route already names the entry under its current name. A durable
58
- * intent that fails this is rewritten before the next request is assembled;
59
- * the legacy spelling is a read-side alias, not a route that still dispatches.
59
+ * Whether a route already names a combo under its current provider spelling. A
60
+ * durable intent that fails this is rewritten before the next request is
61
+ * assembled; the legacy spelling is a read-side alias, not a dispatching route.
60
62
  */
61
63
  export declare const isCurrentFusionSelection: (route: {
62
64
  provider?: string;
@@ -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
  /** Bounded, read-only UI projection. No prompts, tool arguments or credentials. */
4
4
  export interface FusionStatus {
5
5
  schemaVersion: 1;
@@ -23,6 +23,22 @@ export interface FusionStatus {
23
23
  worker: PhysicalRoute;
24
24
  compactor: PhysicalRoute | null;
25
25
  };
26
+ /**
27
+ * Role-separated sandbox modes, present while Fusion confines the Lead. The
28
+ * composer's own permission control follows the conversation (Lead) session,
29
+ * so the UI shows these instead of the derived preset name.
30
+ */
31
+ permissions?: {
32
+ /**
33
+ * What the Lead's own work is confined to. The session is widened only for the
34
+ * checks Fusion runs itself and for the Lead during a Host-unlocked takeover.
35
+ */
36
+ lead: SandboxMode;
37
+ /** What the Sidekick runs under: the mode the user chose for this conversation. */
38
+ worker: SandboxMode;
39
+ /** The role working right now; null while the task waits. */
40
+ active: Role | null;
41
+ };
26
42
  usage: {
27
43
  calls: number;
28
44
  finalCalls: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sqdr2/dsh-fusion",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "registry": "https://registry.npmjs.org/"
@@ -26,6 +26,7 @@
26
26
  "profiles",
27
27
  "dshx.yml",
28
28
  "cordis.patch.yml",
29
+ "CHANGELOG.md",
29
30
  "README.md",
30
31
  "README.en.md",
31
32
  "INSTALL.md",
@@ -65,75 +66,76 @@
65
66
  }
66
67
  },
67
68
  "devDependencies": {
68
- "@types/node": "^22.20.0",
69
- "typescript": "^5.9.2",
70
- "vitest": "^4.1.8",
71
- "@types/react": "~18.3.1",
72
- "@types/react-test-renderer": "18.3.1",
73
- "react": "^18.3.1",
74
- "react-test-renderer": "18.3.1",
75
- "semver": "^7.7.2",
76
- "yaml": "^2.8.0",
77
69
  "@deepseek-ai/cordis": "^4.0.4",
78
- "@deepseek-ai/schemastery": "^3.18.4",
79
- "@deepseek-ai/dsh-agent": "0.2.0-rc.2",
80
- "@deepseek-ai/dsh-llm": "0.2.0-rc.2",
81
- "@deepseek-ai/dsh-session": "0.2.0-rc.2",
82
- "@deepseek-ai/dsh-session-query": "0.2.0-rc.2",
83
- "@deepseek-ai/dsh-subagent": "0.2.0-rc.2",
84
- "@deepseek-ai/dsh-tools": "0.2.0-rc.2",
85
- "@deepseek-ai/dsh-system-prompt": "0.2.0-rc.2",
86
- "@deepseek-ai/dsh-api-session-controller": "0.2.0-rc.2",
87
- "@deepseek-ai/dsh-commands": "0.2.0-rc.2",
88
- "@deepseek-ai/dsh-host-webserver": "0.2.0-rc.2",
89
- "@deepseek-ai/dsh-client-locale": "0.2.0-rc.2",
90
- "@deepseek-ai/dsh-client-ui-settings": "0.2.0-rc.2",
91
- "@deepseek-ai/dsh-client-ui-slots": "0.2.0-rc.2",
92
- "@deepseek-ai/dsh-client-ui-session": "0.2.0-rc.2",
93
- "@deepseek-ai/dsh-client-ui-renderer": "0.2.0-rc.2",
94
- "@deepseek-ai/dsh-client-ui-model-selection": "0.2.0-rc.2",
95
- "@deepseek-ai/dsh-client-ui-conversation": "0.2.0-rc.2",
96
- "@deepseek-ai/dsh-client-ui-tool": "0.2.0-rc.2",
97
- "@deepseek-ai/dsh-client-ui-primitives": "0.2.0-rc.2",
98
- "@deepseek-ai/dsh-client-ui-chat": "0.2.0-rc.2",
99
- "@deepseek-ai/dsh-client-store": "0.2.0-rc.2",
100
- "@deepseek-ai/dsh-agent-default-model": "0.2.0-rc.2",
101
- "@deepseek-ai/dsh-token-meter": "0.2.0-rc.2",
102
- "@deepseek-ai/dsh-compaction": "0.2.0-rc.2",
103
- "@deepseek-ai/dsh-user-approval": "0.2.0-rc.2",
104
- "@deepseek-ai/dsh-jobs": "0.2.0-rc.2",
105
- "@deepseek-ai/dsh-agent-preset-registry": "0.2.0-rc.2",
106
70
  "@deepseek-ai/cordis-plugin-group": ">=1.0.4 <1.1.0-0",
107
71
  "@deepseek-ai/cordis-plugin-include": ">=1.0.9 <1.1.0-0",
108
72
  "@deepseek-ai/cordis-plugin-loader": ">=1.0.5 <1.1.0-0",
73
+ "@deepseek-ai/dsh-agent": "0.2.0-rc.2",
74
+ "@deepseek-ai/dsh-agent-default-model": "0.2.0-rc.2",
75
+ "@deepseek-ai/dsh-agent-preset-registry": "0.2.0-rc.2",
109
76
  "@deepseek-ai/dsh-api-gateway": "0.2.0-rc.2",
77
+ "@deepseek-ai/dsh-api-session-controller": "0.2.0-rc.2",
110
78
  "@deepseek-ai/dsh-attachment": "0.2.0-rc.2",
111
79
  "@deepseek-ai/dsh-client-connection": "0.2.0-rc.2",
112
80
  "@deepseek-ai/dsh-client-file-upload": "0.2.0-rc.2",
81
+ "@deepseek-ai/dsh-client-locale": "0.2.0-rc.2",
82
+ "@deepseek-ai/dsh-client-store": "0.2.0-rc.2",
83
+ "@deepseek-ai/dsh-client-ui-chat": "0.2.0-rc.2",
84
+ "@deepseek-ai/dsh-client-ui-conversation": "0.2.0-rc.2",
85
+ "@deepseek-ai/dsh-client-ui-model-selection": "0.2.0-rc.2",
86
+ "@deepseek-ai/dsh-client-ui-primitives": "0.2.0-rc.2",
87
+ "@deepseek-ai/dsh-client-ui-renderer": "0.2.0-rc.2",
88
+ "@deepseek-ai/dsh-client-ui-session": "0.2.0-rc.2",
89
+ "@deepseek-ai/dsh-client-ui-settings": "0.2.0-rc.2",
90
+ "@deepseek-ai/dsh-client-ui-slots": "0.2.0-rc.2",
91
+ "@deepseek-ai/dsh-client-ui-tool": "0.2.0-rc.2",
92
+ "@deepseek-ai/dsh-commands": "0.2.0-rc.2",
93
+ "@deepseek-ai/dsh-compaction": "0.2.0-rc.2",
113
94
  "@deepseek-ai/dsh-credentials": "0.2.0-rc.2",
114
95
  "@deepseek-ai/dsh-file-reference": "0.2.0-rc.2",
115
96
  "@deepseek-ai/dsh-fs": "0.2.0-rc.2",
116
97
  "@deepseek-ai/dsh-home-paths": "0.2.0-rc.2",
98
+ "@deepseek-ai/dsh-host-webserver": "0.2.0-rc.2",
117
99
  "@deepseek-ai/dsh-invariants": "0.2.0-rc.2",
100
+ "@deepseek-ai/dsh-jobs": "0.2.0-rc.2",
118
101
  "@deepseek-ai/dsh-launch-environment": "0.2.0-rc.2",
102
+ "@deepseek-ai/dsh-llm": "0.2.0-rc.2",
119
103
  "@deepseek-ai/dsh-llm-retry": "0.2.0-rc.2",
120
104
  "@deepseek-ai/dsh-native-command": "0.2.0-rc.2",
121
105
  "@deepseek-ai/dsh-ptc-runtime": "0.2.0-rc.2",
122
106
  "@deepseek-ai/dsh-sandbox": "0.2.0-rc.2",
123
107
  "@deepseek-ai/dsh-sandbox-policy": "0.2.0-rc.2",
124
108
  "@deepseek-ai/dsh-scope": "0.2.0-rc.2",
109
+ "@deepseek-ai/dsh-session": "0.2.0-rc.2",
110
+ "@deepseek-ai/dsh-session-persistence": "0.2.0-rc.2",
125
111
  "@deepseek-ai/dsh-session-projection": "0.2.0-rc.2",
112
+ "@deepseek-ai/dsh-session-query": "0.2.0-rc.2",
126
113
  "@deepseek-ai/dsh-session-title": "0.2.0-rc.2",
127
114
  "@deepseek-ai/dsh-settings": "0.2.0-rc.2",
128
115
  "@deepseek-ai/dsh-skill": "0.2.0-rc.2",
116
+ "@deepseek-ai/dsh-storage": "0.2.0-rc.2",
117
+ "@deepseek-ai/dsh-storage-domain": "0.2.0-rc.2",
118
+ "@deepseek-ai/dsh-subagent": "0.2.0-rc.2",
119
+ "@deepseek-ai/dsh-system-prompt": "0.2.0-rc.2",
120
+ "@deepseek-ai/dsh-token-meter": "0.2.0-rc.2",
129
121
  "@deepseek-ai/dsh-tool-todo": "0.2.0-rc.2",
122
+ "@deepseek-ai/dsh-tools": "0.2.0-rc.2",
130
123
  "@deepseek-ai/dsh-typert-registry": "0.2.0-rc.2",
124
+ "@deepseek-ai/dsh-user-approval": "0.2.0-rc.2",
131
125
  "@deepseek-ai/dsh-util-time": "0.2.0-rc.2",
132
126
  "@deepseek-ai/dsh-util-workspace-path": "0.2.0-rc.2",
133
127
  "@deepseek-ai/dsh-workspace": "0.2.0-rc.2",
134
- "@deepseek-ai/dsh-session-persistence": "0.2.0-rc.2",
135
- "@deepseek-ai/dsh-storage": "0.2.0-rc.2",
136
- "@deepseek-ai/dsh-storage-domain": "0.2.0-rc.2"
128
+ "@deepseek-ai/schemastery": "^3.18.4",
129
+ "@types/node": "^22.20.0",
130
+ "@types/react": "~18.3.1",
131
+ "@types/react-test-renderer": "18.3.1",
132
+ "esbuild": "^0.28.2",
133
+ "react": "^18.3.1",
134
+ "react-test-renderer": "18.3.1",
135
+ "semver": "^7.7.2",
136
+ "typescript": "^5.9.2",
137
+ "vitest": "^4.1.8",
138
+ "yaml": "^2.8.0"
137
139
  },
138
140
  "engines": {
139
141
  "node": ">=22.14.0",
@@ -142,33 +144,33 @@
142
144
  "packageManager": "pnpm@9.15.9",
143
145
  "peerDependencies": {
144
146
  "@deepseek-ai/cordis": "^4.0.4",
145
- "@deepseek-ai/schemastery": "^3.18.4",
146
147
  "@deepseek-ai/dsh-agent": ">=0.2.0-rc.1 <0.2.1-0 || >=0.2.1-rc.2 <0.3.0-0",
147
- "@deepseek-ai/dsh-llm": ">=0.2.0-rc.1 <0.2.1-0 || >=0.2.1-rc.2 <0.3.0-0",
148
- "@deepseek-ai/dsh-session": ">=0.2.0-rc.1 <0.2.1-0 || >=0.2.1-rc.2 <0.3.0-0",
149
- "@deepseek-ai/dsh-session-query": ">=0.2.0-rc.1 <0.2.1-0 || >=0.2.1-rc.2 <0.3.0-0",
150
- "@deepseek-ai/dsh-subagent": ">=0.2.0-rc.1 <0.2.1-0 || >=0.2.1-rc.2 <0.3.0-0",
151
- "@deepseek-ai/dsh-tools": ">=0.2.0-rc.1 <0.2.1-0 || >=0.2.1-rc.2 <0.3.0-0",
152
- "@deepseek-ai/dsh-system-prompt": ">=0.2.0-rc.1 <0.2.1-0 || >=0.2.1-rc.2 <0.3.0-0",
148
+ "@deepseek-ai/dsh-agent-default-model": ">=0.2.0-rc.1 <0.2.1-0 || >=0.2.1-rc.2 <0.3.0-0",
149
+ "@deepseek-ai/dsh-agent-preset-registry": ">=0.2.0-rc.1 <0.2.1-0 || >=0.2.1-rc.2 <0.3.0-0",
153
150
  "@deepseek-ai/dsh-api-session-controller": ">=0.2.0-rc.1 <0.2.1-0 || >=0.2.1-rc.2 <0.3.0-0",
154
- "@deepseek-ai/dsh-commands": ">=0.2.0-rc.1 <0.2.1-0 || >=0.2.1-rc.2 <0.3.0-0",
155
- "@deepseek-ai/dsh-host-webserver": ">=0.2.0-rc.1 <0.2.1-0 || >=0.2.1-rc.2 <0.3.0-0",
156
151
  "@deepseek-ai/dsh-client-locale": ">=0.2.0-rc.1 <0.2.1-0 || >=0.2.1-rc.2 <0.3.0-0",
152
+ "@deepseek-ai/dsh-client-store": ">=0.2.0-rc.1 <0.2.1-0 || >=0.2.1-rc.2 <0.3.0-0",
153
+ "@deepseek-ai/dsh-client-ui-conversation": ">=0.2.0-rc.1 <0.2.1-0 || >=0.2.1-rc.2 <0.3.0-0",
154
+ "@deepseek-ai/dsh-client-ui-model-selection": ">=0.2.0-rc.1 <0.2.1-0 || >=0.2.1-rc.2 <0.3.0-0",
155
+ "@deepseek-ai/dsh-client-ui-primitives": ">=0.2.0-rc.1 <0.2.1-0 || >=0.2.1-rc.2 <0.3.0-0",
156
+ "@deepseek-ai/dsh-client-ui-renderer": ">=0.2.0-rc.1 <0.2.1-0 || >=0.2.1-rc.2 <0.3.0-0",
157
157
  "@deepseek-ai/dsh-client-ui-settings": ">=0.2.0-rc.1 <0.2.1-0 || >=0.2.1-rc.2 <0.3.0-0",
158
158
  "@deepseek-ai/dsh-client-ui-slots": ">=0.2.0-rc.1 <0.2.1-0 || >=0.2.1-rc.2 <0.3.0-0",
159
- "react": "^18.2.0",
160
- "@deepseek-ai/dsh-client-ui-renderer": ">=0.2.0-rc.1 <0.2.1-0 || >=0.2.1-rc.2 <0.3.0-0",
161
- "@deepseek-ai/dsh-client-ui-model-selection": ">=0.2.0-rc.1 <0.2.1-0 || >=0.2.1-rc.2 <0.3.0-0",
162
- "@deepseek-ai/dsh-client-ui-conversation": ">=0.2.0-rc.1 <0.2.1-0 || >=0.2.1-rc.2 <0.3.0-0",
163
159
  "@deepseek-ai/dsh-client-ui-tool": ">=0.2.0-rc.1 <0.2.1-0 || >=0.2.1-rc.2 <0.3.0-0",
164
- "@deepseek-ai/dsh-client-ui-primitives": ">=0.2.0-rc.1 <0.2.1-0 || >=0.2.1-rc.2 <0.3.0-0",
165
- "@deepseek-ai/dsh-client-store": ">=0.2.0-rc.1 <0.2.1-0 || >=0.2.1-rc.2 <0.3.0-0",
166
- "@deepseek-ai/dsh-agent-default-model": ">=0.2.0-rc.1 <0.2.1-0 || >=0.2.1-rc.2 <0.3.0-0",
167
- "@deepseek-ai/dsh-token-meter": ">=0.2.0-rc.1 <0.2.1-0 || >=0.2.1-rc.2 <0.3.0-0",
160
+ "@deepseek-ai/dsh-commands": ">=0.2.0-rc.1 <0.2.1-0 || >=0.2.1-rc.2 <0.3.0-0",
168
161
  "@deepseek-ai/dsh-compaction": ">=0.2.0-rc.1 <0.2.1-0 || >=0.2.1-rc.2 <0.3.0-0",
169
- "@deepseek-ai/dsh-user-approval": ">=0.2.0-rc.1 <0.2.1-0 || >=0.2.1-rc.2 <0.3.0-0",
162
+ "@deepseek-ai/dsh-host-webserver": ">=0.2.0-rc.1 <0.2.1-0 || >=0.2.1-rc.2 <0.3.0-0",
170
163
  "@deepseek-ai/dsh-jobs": ">=0.2.0-rc.1 <0.2.1-0 || >=0.2.1-rc.2 <0.3.0-0",
171
- "@deepseek-ai/dsh-agent-preset-registry": ">=0.2.0-rc.1 <0.2.1-0 || >=0.2.1-rc.2 <0.3.0-0"
164
+ "@deepseek-ai/dsh-llm": ">=0.2.0-rc.1 <0.2.1-0 || >=0.2.1-rc.2 <0.3.0-0",
165
+ "@deepseek-ai/dsh-session": ">=0.2.0-rc.1 <0.2.1-0 || >=0.2.1-rc.2 <0.3.0-0",
166
+ "@deepseek-ai/dsh-session-query": ">=0.2.0-rc.1 <0.2.1-0 || >=0.2.1-rc.2 <0.3.0-0",
167
+ "@deepseek-ai/dsh-subagent": ">=0.2.0-rc.1 <0.2.1-0 || >=0.2.1-rc.2 <0.3.0-0",
168
+ "@deepseek-ai/dsh-system-prompt": ">=0.2.0-rc.1 <0.2.1-0 || >=0.2.1-rc.2 <0.3.0-0",
169
+ "@deepseek-ai/dsh-token-meter": ">=0.2.0-rc.1 <0.2.1-0 || >=0.2.1-rc.2 <0.3.0-0",
170
+ "@deepseek-ai/dsh-tools": ">=0.2.0-rc.1 <0.2.1-0 || >=0.2.1-rc.2 <0.3.0-0",
171
+ "@deepseek-ai/dsh-user-approval": ">=0.2.0-rc.1 <0.2.1-0 || >=0.2.1-rc.2 <0.3.0-0",
172
+ "@deepseek-ai/schemastery": "^3.18.4",
173
+ "react": "^18.2.0"
172
174
  },
173
175
  "scripts": {
174
176
  "build": "node scripts/build.mjs",
@@ -22,15 +22,22 @@ export function FusionHint({ sessionId, directory, load }: FusionHintProps) {
22
22
  const abort = new AbortController()
23
23
  const refresh = () => {
24
24
  void fetch(`${FUSION_API_PATH}?view=settings`, { credentials: 'same-origin', cache: 'no-store', signal: abort.signal })
25
- .then(async response => { if (!response.ok) throw new Error('unavailable'); return response.json() as Promise<{ pair: unknown; authorized: boolean }> })
26
- .then(state => { if (active) setMessage(!state.pair ? tr(lang, 'Fusion 尚未配置模型,请前往设置 → Fusion 选择 Lead 和 Sidekick。', 'Fusion has no models yet: open Settings → Fusion and choose a Lead and a Sidekick.')
27
- : !state.authorized ? tr(lang, 'Fusion 当前配置未就绪,请前往设置 → Fusion 查看。', 'The Fusion setup is not ready: see Settings → Fusion.') : undefined) })
25
+ .then(async response => { if (!response.ok) throw new Error('unavailable'); return response.json() as Promise<{ pairs: { id: string; authorized: boolean; reason?: string }[] }> })
26
+ .then(state => {
27
+ if (!active) return
28
+ // The hint speaks for this conversation's combo, not for the first one.
29
+ const combo = state.pairs.find(pair => pair.id === selected?.model)
30
+ if (!state.pairs.length) setMessage(tr(lang, 'Fusion 尚未配置模型,请前往设置 → Fusion 选择 Lead 和 Sidekick。', 'Fusion has no models yet: open Settings → Fusion and choose a Lead and a Sidekick.'))
31
+ else if (!combo) setMessage(tr(lang, '当前组合已被删除,请在模型菜单中选择其他组合。', 'This combo was removed; pick another one in the model menu.'))
32
+ else if (!combo.authorized) setMessage(tr(lang, 'Fusion 当前配置未就绪,请前往设置 → Fusion 查看。', 'The Fusion setup is not ready: see Settings → Fusion.'))
33
+ else setMessage(undefined)
34
+ })
28
35
  .catch(() => { if (active) setMessage(tr(lang, '暂时无法确认 Fusion 配置,请前往设置 → Fusion 查看。', 'Cannot confirm the Fusion setup right now: see Settings → Fusion.')) })
29
36
  }
30
37
  refresh()
31
38
  const off = onSettingsChanged(refresh)
32
39
  return () => { active = false; abort.abort(); off() }
33
- }, [fusion, lang])
40
+ }, [fusion, lang, selected?.model])
34
41
  if (!fusion) return null
35
42
  return <>
36
43
  {message && <p role="status" style={{ margin: '8px 12px', color: 'var(--dsw-alias-label-secondary)', fontSize: 12, lineHeight: '18px' }}>{message}</p>}
@@ -0,0 +1,52 @@
1
+ import { useEffect, useSyncExternalStore } from 'react'
2
+ import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
3
+ import type { ModelDirectoryState } from '@deepseek-ai/dsh-client-ui-model-selection/client'
4
+ import { PermissionIconFullAccessRegular, PermissionIconReadOnlyRegular,
5
+ PermissionIconWorkspaceWriteRegular } from '@deepseek-ai/dsh-client-ui-primitives'
6
+ import type { SandboxMode } from '../contracts.js'
7
+ import { isFusionSelection } from '../identity.js'
8
+ import { tr, useLang, type Lang } from './i18n.js'
9
+ import { useFusionStatus } from './status-polling.js'
10
+
11
+ const styles = `
12
+ .fusion-permission{display:inline-flex;align-items:center;gap:4px;height:28px;min-width:0;max-width:220px;padding:0 6px;border-radius:var(--dsw-radius-sm);color:var(--dsw-alias-label-secondary);font-size:13px;line-height:20px;font-weight:500}
13
+ .fusion-permission svg{width:14px;height:14px;flex:none}
14
+ .fusion-permission .fusion-permission-role{flex:none;color:var(--dsw-alias-label-caption)}
15
+ .fusion-permission .fusion-permission-mode{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
16
+ `
17
+
18
+ const modeOf = (lang: Lang, mode: SandboxMode) => mode === 'read-only' ? tr(lang, '仅可查看', 'Read Only')
19
+ : mode === 'workspace-write' ? tr(lang, '工作区内修改', 'Workspace Write') : tr(lang, '完全权限', 'Full access')
20
+ const iconOf = (mode: SandboxMode) => mode === 'read-only' ? <PermissionIconReadOnlyRegular />
21
+ : mode === 'workspace-write' ? <PermissionIconWorkspaceWriteRegular /> : <PermissionIconFullAccessRegular />
22
+
23
+ export interface FusionPermissionProps { sessionId: string; directory: ObservableSnapshot<ModelDirectoryState>; load(): void }
24
+
25
+ /**
26
+ * The permission of the model working now. The composer's native permission control
27
+ * belongs to the conversation (Lead) session, which Fusion confines to read-only, and
28
+ * that confinement matches no configured preset — so the control reads Custom while a
29
+ * task runs. This additive chip names the role and its mode, so the tool row never
30
+ * shows an unexplained Custom alone.
31
+ */
32
+ export function FusionPermission({ sessionId, directory, load }: FusionPermissionProps) {
33
+ const selected = useSyncExternalStore(directory.subscribe, directory.getSnapshot).current
34
+ const fusion = isFusionSelection(selected ?? undefined)
35
+ const lang = useLang()
36
+ // The acting role is the whole point of this chip, so it always wants fresh polls.
37
+ const snapshot = useFusionStatus(sessionId, undefined, false, true)
38
+ useEffect(() => { load() }, [load])
39
+ const permissions = fusion ? snapshot?.status?.task?.permissions : undefined
40
+ if (!permissions) return null
41
+ const role = permissions.active === 'worker' ? 'Sidekick' : 'Lead'
42
+ const mode = permissions.active === 'worker' ? permissions.worker : permissions.lead
43
+ const hint = tr(lang,
44
+ `Fusion 按角色分离权限:Lead ${modeOf(lang, permissions.lead)},Sidekick ${modeOf(lang, permissions.worker)}。输入框的权限控件属于对话会话(Lead),运行期间可能显示 Custom。`,
45
+ `Fusion separates permissions by role: Lead ${modeOf(lang, permissions.lead)}, Sidekick ${modeOf(lang, permissions.worker)}. The permission control belongs to the conversation (Lead) session, so it may read Custom while a task runs.`)
46
+ return <span className="fusion-permission" title={hint} aria-label={`${role} · ${modeOf(lang, mode)}`} data-ud-check="fusion-permission">
47
+ <style>{styles}</style>
48
+ {iconOf(mode)}
49
+ <span className="fusion-permission-role">{role}</span>
50
+ <span className="fusion-permission-mode">{modeOf(lang, mode)}</span>
51
+ </span>
52
+ }