@zhin.js/runtime 1.1.4 → 1.1.6

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.
@@ -1,6 +1,7 @@
1
- import type { RuntimeConfigDocument } from '@zhin.js/plugin-runtime';
2
- import type { PluginId } from '@zhin.js/plugin-runtime';
1
+ import type { RuntimeConfigDocument, PluginId } from '@zhin.js/plugin-runtime';
2
+ import { type ErrorObject } from 'ajv/dist/2020.js';
3
3
  import type { ProjectGraph } from './project-graph.js';
4
+ import type { ModuleRuntime } from './module-runtime.js';
4
5
  export declare const HOST_CONFIG_SCHEMA: {
5
6
  $schema: string;
6
7
  type: string;
@@ -72,6 +73,36 @@ export declare const HOST_CONFIG_SCHEMA: {
72
73
  description: string;
73
74
  "x-descriptionZh": string;
74
75
  properties: {
76
+ decisions: {
77
+ type: string;
78
+ additionalProperties: boolean;
79
+ required: string[];
80
+ description: string;
81
+ "x-descriptionZh": string;
82
+ properties: {
83
+ provider: {
84
+ type: string;
85
+ pattern: string;
86
+ description: string;
87
+ "x-descriptionZh": string;
88
+ };
89
+ skills: {
90
+ $ref: string;
91
+ };
92
+ tools: {
93
+ $ref: string;
94
+ };
95
+ memory: {
96
+ $ref: string;
97
+ };
98
+ agents: {
99
+ $ref: string;
100
+ };
101
+ approval: {
102
+ $ref: string;
103
+ };
104
+ };
105
+ };
75
106
  workroom: {
76
107
  type: string;
77
108
  additionalProperties: boolean;
@@ -175,6 +206,20 @@ export declare const HOST_CONFIG_SCHEMA: {
175
206
  description: string;
176
207
  "x-descriptionZh": string;
177
208
  };
209
+ ttlMs: {
210
+ type: string;
211
+ minimum: number;
212
+ default: number;
213
+ description: string;
214
+ "x-descriptionZh": string;
215
+ };
216
+ maxQueuedTurns: {
217
+ type: string;
218
+ minimum: number;
219
+ default: number;
220
+ description: string;
221
+ "x-descriptionZh": string;
222
+ };
178
223
  };
179
224
  };
180
225
  execSecurity: {
@@ -189,25 +234,202 @@ export declare const HOST_CONFIG_SCHEMA: {
189
234
  description: string;
190
235
  "x-descriptionZh": string;
191
236
  };
192
- execApprovalMode: {
237
+ masterShell: {
193
238
  type: string;
194
239
  enum: string[];
240
+ default: string;
195
241
  description: string;
196
242
  "x-descriptionZh": string;
197
243
  };
198
- subagentExecApprovalMode: {
244
+ toolBudget: {
245
+ type: string;
246
+ description: string;
247
+ "x-descriptionZh": string;
248
+ properties: {
249
+ extendBy: {
250
+ type: string;
251
+ minimum: number;
252
+ default: number;
253
+ description: string;
254
+ "x-descriptionZh": string;
255
+ };
256
+ progressWindow: {
257
+ type: string;
258
+ minimum: number;
259
+ default: number;
260
+ description: string;
261
+ "x-descriptionZh": string;
262
+ };
263
+ maxTotalIterations: {
264
+ type: string;
265
+ minimum: number;
266
+ default: number;
267
+ description: string;
268
+ "x-descriptionZh": string;
269
+ };
270
+ maxSubagentTotalIterations: {
271
+ type: string;
272
+ minimum: number;
273
+ default: number;
274
+ description: string;
275
+ "x-descriptionZh": string;
276
+ };
277
+ maxWallClockMs: {
278
+ type: string;
279
+ minimum: number;
280
+ default: number;
281
+ description: string;
282
+ "x-descriptionZh": string;
283
+ };
284
+ maxTotalTokens: {
285
+ type: string;
286
+ minimum: number;
287
+ default: number;
288
+ description: string;
289
+ "x-descriptionZh": string;
290
+ };
291
+ failureStreakLimit: {
292
+ type: string;
293
+ minimum: number;
294
+ default: number;
295
+ description: string;
296
+ "x-descriptionZh": string;
297
+ };
298
+ repeatLimit: {
299
+ type: string;
300
+ minimum: number;
301
+ default: number;
302
+ description: string;
303
+ "x-descriptionZh": string;
304
+ };
305
+ autoContinue: {
306
+ type: string;
307
+ enum: string[];
308
+ default: string;
309
+ description: string;
310
+ "x-descriptionZh": string;
311
+ };
312
+ maxContinuations: {
313
+ type: string;
314
+ minimum: number;
315
+ default: number;
316
+ description: string;
317
+ "x-descriptionZh": string;
318
+ };
319
+ progressUpdates: {
320
+ type: string;
321
+ enum: string[];
322
+ default: string;
323
+ description: string;
324
+ "x-descriptionZh": string;
325
+ };
326
+ progressMinIntervalMs: {
327
+ type: string;
328
+ minimum: number;
329
+ default: number;
330
+ description: string;
331
+ "x-descriptionZh": string;
332
+ };
333
+ progressMaxUpdates: {
334
+ type: string;
335
+ minimum: number;
336
+ default: number;
337
+ description: string;
338
+ "x-descriptionZh": string;
339
+ };
340
+ };
341
+ };
342
+ injectionGuard: {
343
+ type: string;
344
+ description: string;
345
+ "x-descriptionZh": string;
346
+ properties: {
347
+ confirmHighRisk: {
348
+ type: string;
349
+ enum: string[];
350
+ default: string;
351
+ description: string;
352
+ "x-descriptionZh": string;
353
+ };
354
+ outboundCheck: {
355
+ type: string;
356
+ enum: string[];
357
+ default: string;
358
+ description: string;
359
+ "x-descriptionZh": string;
360
+ };
361
+ trustedOutputTools: {
362
+ type: string;
363
+ items: {
364
+ type: string;
365
+ };
366
+ default: never[];
367
+ description: string;
368
+ "x-descriptionZh": string;
369
+ };
370
+ };
371
+ };
372
+ usageQuota: {
373
+ type: string;
374
+ description: string;
375
+ "x-descriptionZh": string;
376
+ properties: {
377
+ windowMs: {
378
+ type: string;
379
+ minimum: number;
380
+ default: number;
381
+ description: string;
382
+ "x-descriptionZh": string;
383
+ };
384
+ perUserTokens: {
385
+ type: string;
386
+ minimum: number;
387
+ default: number;
388
+ description: string;
389
+ "x-descriptionZh": string;
390
+ };
391
+ perGroupTokens: {
392
+ type: string;
393
+ minimum: number;
394
+ default: number;
395
+ description: string;
396
+ "x-descriptionZh": string;
397
+ };
398
+ perGroupRequestsPerMinute: {
399
+ type: string;
400
+ minimum: number;
401
+ default: number;
402
+ description: string;
403
+ "x-descriptionZh": string;
404
+ };
405
+ exemptOwner: {
406
+ type: string;
407
+ default: boolean;
408
+ description: string;
409
+ "x-descriptionZh": string;
410
+ };
411
+ };
412
+ };
413
+ subagentTurnMode: {
199
414
  type: string;
200
415
  enum: string[];
416
+ default: string;
201
417
  description: string;
202
418
  "x-descriptionZh": string;
203
419
  };
204
- workerExecApprovalMode: {
420
+ execApprovalMode: {
421
+ type: string;
422
+ enum: string[];
423
+ description: string;
424
+ "x-descriptionZh": string;
425
+ };
426
+ subagentExecApprovalMode: {
205
427
  type: string;
206
428
  enum: string[];
207
429
  description: string;
208
430
  "x-descriptionZh": string;
209
431
  };
210
- taskExecApprovalMode: {
432
+ workerExecApprovalMode: {
211
433
  type: string;
212
434
  enum: string[];
213
435
  description: string;
@@ -564,6 +786,44 @@ export declare const HOST_CONFIG_SCHEMA: {
564
786
  "x-descriptionZh": string;
565
787
  };
566
788
  };
789
+ $defs: {
790
+ decisionTask: {
791
+ type: string;
792
+ additionalProperties: boolean;
793
+ required: string[];
794
+ properties: {
795
+ mode: {
796
+ type: string;
797
+ enum: string[];
798
+ };
799
+ timeoutMs: {
800
+ type: string;
801
+ minimum: number;
802
+ maximum: number;
803
+ };
804
+ minConfidence: {
805
+ type: string;
806
+ minimum: number;
807
+ maximum: number;
808
+ };
809
+ topK: {
810
+ type: string;
811
+ minimum: number;
812
+ maximum: number;
813
+ };
814
+ maxCandidates: {
815
+ type: string;
816
+ minimum: number;
817
+ maximum: number;
818
+ };
819
+ maxSelections: {
820
+ type: string;
821
+ minimum: number;
822
+ maximum: number;
823
+ };
824
+ };
825
+ };
826
+ };
567
827
  };
568
828
  /** Host configuration keys consumed by Runtime composition and Host installers. */
569
829
  export declare const HOST_CONFIG_KEYS: readonly string[];
@@ -572,6 +832,7 @@ export interface ComposedConfig {
572
832
  readonly effectiveSchema: JsonSchema;
573
833
  readonly document: RuntimeConfigDocument;
574
834
  readonly views: ReadonlyMap<PluginId, unknown>;
835
+ readonly ownSchemas: ReadonlyMap<PluginId, JsonSchema>;
575
836
  }
576
837
  export declare class ConfigSchemaCollisionError extends Error {
577
838
  readonly plugin: PluginId;
@@ -588,8 +849,15 @@ export declare class ConfigValidationError extends Error {
588
849
  }
589
850
  export declare class ConfigComposer {
590
851
  private readonly inactivePluginInstanceKeys;
591
- constructor(inactivePluginInstanceKeys?: readonly string[]);
852
+ private readonly modules?;
853
+ constructor(inactivePluginInstanceKeys?: readonly string[], modules?: Pick<ModuleRuntime, "load"> | undefined);
592
854
  compose(graph: ProjectGraph, input?: RuntimeConfigDocument,
593
855
  /** Source config file name, annotated on ConfigValidationError. */
594
856
  source?: string): Promise<ComposedConfig>;
595
857
  }
858
+ /** Reuses Runtime validation/defaults without mutating the caller's configuration. */
859
+ export declare function parsePluginConfigSchema(schema: JsonSchema, input: unknown): Readonly<{
860
+ value: unknown;
861
+ errors: readonly ErrorObject[];
862
+ }>;
863
+ export declare function validatePluginConfigSchema(schema: JsonSchema, input: unknown): readonly ErrorObject[];
@@ -1,7 +1,6 @@
1
- import { readFile } from 'node:fs/promises';
2
- import { join } from 'node:path';
3
1
  import Ajv2020 from 'ajv/dist/2020.js';
4
2
  import hostConfigSchema from './host-config-schema.json' with { type: 'json' };
3
+ import { readPluginConfigSchema } from './plugin-config-schema.js';
5
4
  function deepFreeze(value) {
6
5
  if (value && typeof value === 'object' && !Object.isFrozen(value)) {
7
6
  for (const nested of Object.values(value))
@@ -37,8 +36,10 @@ export class ConfigValidationError extends Error {
37
36
  }
38
37
  export class ConfigComposer {
39
38
  inactivePluginInstanceKeys;
40
- constructor(inactivePluginInstanceKeys = []) {
39
+ modules;
40
+ constructor(inactivePluginInstanceKeys = [], modules) {
41
41
  this.inactivePluginInstanceKeys = inactivePluginInstanceKeys;
42
+ this.modules = modules;
42
43
  }
43
44
  async compose(graph, input = {},
44
45
  /** Source config file name, annotated on ConfigValidationError. */
@@ -46,17 +47,18 @@ export class ConfigComposer {
46
47
  // Effective schemas include child namespaces for whole-tree validation;
47
48
  // ownSchemas retain each package's private configuration contract.
48
49
  const ownSchemas = new Map();
49
- const rootOwn = await readOwnSchema(graph.root);
50
+ const rootOwn = await readOwnSchema(graph.root, this.modules);
50
51
  ownSchemas.set(graph.root.id, rootOwn);
51
52
  const childSchemas = await Promise.all(graph.root.children.map(async (child) => [
52
53
  child.instanceKey,
53
- await composeNode(child, ownSchemas),
54
+ await composeNode(child, ownSchemas, this.modules),
54
55
  ]));
55
56
  // Host-level keys (`http`, `database`, `ai`, `mcp`, `a2a`, `speech`,
56
57
  // `htmlRenderer`, `assistant`, `log_level`) are consumed by CLI Root installers /
57
58
  // start-command, not Plugin ConfigViews.
58
59
  const effectiveSchema = Object.freeze({
59
60
  type: 'object',
61
+ $defs: HOST_CONFIG_SCHEMA.$defs,
60
62
  additionalProperties: false,
61
63
  properties: {
62
64
  ...HOST_CONFIG_SCHEMA.properties,
@@ -73,17 +75,7 @@ export class ConfigComposer {
73
75
  },
74
76
  });
75
77
  const document = structuredClone(input);
76
- const ajv = new Ajv2020({
77
- allErrors: true,
78
- useDefaults: true,
79
- strict: true,
80
- // 当前内置 schema 无需 union(log_level 的 ['string','number'] 是 type 数组,
81
- // 不触发 allowUnionTypes);保留此项是面向未来插件 schema 可能出现的
82
- // anyOf/oneOf 标量 union,避免届时 Ajv strict 模式直接报错。
83
- allowUnionTypes: true,
84
- });
85
- ajv.addKeyword({ keyword: 'x-descriptionZh', schemaType: 'string' });
86
- ajv.addKeyword({ keyword: 'x-keyPlaceholder', schemaType: 'string' });
78
+ const ajv = configurationValidator();
87
79
  const validate = ajv.compile(effectiveSchema);
88
80
  if (!validate(document)) {
89
81
  throw new ConfigValidationError(formatErrors(validate.errors ?? []), source);
@@ -97,6 +89,7 @@ export class ConfigComposer {
97
89
  effectiveSchema,
98
90
  document: Object.freeze(document),
99
91
  views,
92
+ ownSchemas,
100
93
  });
101
94
  }
102
95
  }
@@ -152,8 +145,8 @@ function injectFrameworkRoleSchema(properties) {
152
145
  }),
153
146
  });
154
147
  }
155
- async function composeNode(node, ownSchemas) {
156
- const own = await readOwnSchema(node);
148
+ async function composeNode(node, ownSchemas, modules) {
149
+ const own = await readOwnSchema(node, modules);
157
150
  ownSchemas.set(node.id, own);
158
151
  const properties = { ...schemaProperties(own) };
159
152
  // Adapter plugins (schemas declaring array-typed `endpoints`) get
@@ -166,24 +159,14 @@ async function composeNode(node, ownSchemas) {
166
159
  if (Object.hasOwn(properties, child.instanceKey)) {
167
160
  throw new ConfigSchemaCollisionError(node.id, child.instanceKey);
168
161
  }
169
- properties[child.instanceKey] = withDefault(await composeNode(child, ownSchemas));
162
+ properties[child.instanceKey] = withDefault(await composeNode(child, ownSchemas, modules));
170
163
  }
171
164
  return Object.freeze({ ...own, properties });
172
165
  }
173
- async function readOwnSchema(node) {
174
- const file = join(node.package.root, 'schema.json');
175
- let value;
176
- try {
177
- value = JSON.parse(await readFile(file, 'utf8'));
178
- }
179
- catch (error) {
180
- if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
181
- value = { type: 'object', additionalProperties: false, properties: {} };
182
- }
183
- else {
184
- throw error;
185
- }
186
- }
166
+ async function readOwnSchema(node, modules) {
167
+ const file = `${node.package.name} config`;
168
+ const value = await readPluginConfigSchema(node.package, modules)
169
+ ?? { type: 'object', additionalProperties: false, properties: {} };
187
170
  if (!value || typeof value !== 'object' || Array.isArray(value)) {
188
171
  throw new TypeError(`${file} must contain a JSON Schema object`);
189
172
  }
@@ -203,7 +186,7 @@ async function readOwnSchema(node) {
203
186
  if (Object.hasOwn(properties, 'commandNamespace')) {
204
187
  throw new ConfigSchemaCollisionError(node.id, 'commandNamespace');
205
188
  }
206
- return Object.freeze({
189
+ return deepFreeze({
207
190
  $id: `urn:zhin:plugin-config:${encodeURIComponent(String(node.id))}`,
208
191
  type: 'object',
209
192
  additionalProperties: false,
@@ -274,3 +257,32 @@ function formatErrors(errors) {
274
257
  return base;
275
258
  });
276
259
  }
260
+ /** Reuses Runtime validation/defaults without mutating the caller's configuration. */
261
+ export function parsePluginConfigSchema(schema, input) {
262
+ const validate = configurationValidator().compile({
263
+ type: 'object',
264
+ ...(schema.$defs ? { $defs: schema.$defs } : {}),
265
+ properties: { candidate: schema },
266
+ });
267
+ const candidate = { candidate: structuredClone(input === undefined ? schema.default ?? {} : input) };
268
+ const valid = validate(candidate);
269
+ return Object.freeze({ value: candidate.candidate, errors: valid ? [] : structuredClone(validate.errors ?? []) });
270
+ }
271
+ export function validatePluginConfigSchema(schema, input) {
272
+ return parsePluginConfigSchema(schema, input).errors;
273
+ }
274
+ function configurationValidator() {
275
+ const ajv = new Ajv2020({
276
+ allErrors: true,
277
+ useDefaults: true,
278
+ strict: true,
279
+ // 当前内置 schema 无需 union(log_level 的 ['string','number'] 是 type 数组,
280
+ // 不触发 allowUnionTypes);保留此项是面向未来插件 schema 可能出现的
281
+ // anyOf/oneOf 标量 union,避免届时 Ajv strict 模式直接报错。
282
+ allowUnionTypes: true,
283
+ });
284
+ ajv.addKeyword({ keyword: 'x-descriptionZh', schemaType: 'string' });
285
+ ajv.addKeyword({ keyword: 'x-keyPlaceholder', schemaType: 'string' });
286
+ ajv.addKeyword({ keyword: 'x-zhin-config', schemaType: 'object' });
287
+ return ajv;
288
+ }
@@ -4,6 +4,6 @@ export declare class GenerationAssets {
4
4
  private constructor();
5
5
  static create(scopeDisposers: Iterable<readonly [PluginId, Dispose]>, projectionDisposers: ReadonlyMap<FeatureId, Dispose>): GenerationAssets;
6
6
  replaceProjections(features: Iterable<FeatureId>, projectionDisposers: ReadonlyMap<FeatureId, Dispose>): GenerationAssets;
7
- replaceScopes(scopeOrder: readonly PluginId[], replacements: ReadonlyMap<PluginId, Dispose>, projectionDisposers: ReadonlyMap<FeatureId, Dispose>): GenerationAssets;
7
+ replaceScopes(scopeOrder: readonly PluginId[], replacements: ReadonlyMap<PluginId, Dispose>, projectedFeatures: Iterable<FeatureId>, projectionDisposers: ReadonlyMap<FeatureId, Dispose>): GenerationAssets;
8
8
  dispose(): Promise<void>;
9
9
  }
@@ -48,7 +48,7 @@ export class GenerationAssets {
48
48
  }
49
49
  return new GenerationAssets([...this.#scopeLifetimes.keys()], this.#scopeLifetimes, lifetimes);
50
50
  }
51
- replaceScopes(scopeOrder, replacements, projectionDisposers) {
51
+ replaceScopes(scopeOrder, replacements, projectedFeatures, projectionDisposers) {
52
52
  const owners = new Set(scopeOrder);
53
53
  for (const owner of replacements.keys()) {
54
54
  if (!owners.has(owner))
@@ -64,10 +64,14 @@ export class GenerationAssets {
64
64
  throw new Error(`Cannot retain unknown Plugin Scope: ${owner}`);
65
65
  lifetimes.set(owner, lifetime);
66
66
  }
67
- const projectionLifetimes = new Map([...projectionDisposers].map(([feature, dispose]) => [
68
- feature,
69
- new SharedLifetime(dispose),
70
- ]));
67
+ const projectionLifetimes = new Map(this.#projectionLifetimes);
68
+ for (const feature of projectedFeatures) {
69
+ const dispose = projectionDisposers.get(feature);
70
+ if (dispose)
71
+ projectionLifetimes.set(feature, new SharedLifetime(dispose));
72
+ else
73
+ projectionLifetimes.delete(feature);
74
+ }
71
75
  return new GenerationAssets(scopeOrder, lifetimes, projectionLifetimes);
72
76
  }
73
77
  dispose() {
@@ -46,6 +46,21 @@
46
46
  "description": "Providers, Agents, sessions, memory, tools, and execution security.",
47
47
  "x-descriptionZh": "Provider、Agent、会话、记忆、工具与执行安全策略。",
48
48
  "properties": {
49
+ "decisions": {
50
+ "type": "object",
51
+ "additionalProperties": false,
52
+ "required": ["provider"],
53
+ "description": "Optional structured-decision service. Missing tasks are off; use shadow before active.",
54
+ "x-descriptionZh": "可选的结构化决策服务。未配置的任务关闭;建议先用 shadow 验证。",
55
+ "properties": {
56
+ "provider": { "type": "string", "pattern": "^root(/[a-z0-9][a-z0-9-]*)+$", "description": "Exact enabled DecisionProvider Plugin owner, such as root/typesafe.", "x-descriptionZh": "提供已启用 DecisionProvider 的精确插件身份,例如 root/typesafe。" },
57
+ "skills": { "$ref": "#/$defs/decisionTask" },
58
+ "tools": { "$ref": "#/$defs/decisionTask" },
59
+ "memory": { "$ref": "#/$defs/decisionTask" },
60
+ "agents": { "$ref": "#/$defs/decisionTask" },
61
+ "approval": { "$ref": "#/$defs/decisionTask" }
62
+ }
63
+ },
49
64
  "workroom": {
50
65
  "type": "object",
51
66
  "additionalProperties": true,
@@ -106,8 +121,22 @@
106
121
  "groupMode": {
107
122
  "type": "string",
108
123
  "enum": ["supersede", "fifo"],
109
- "description": "Replace an older queued group turn, or process all turns in arrival order.",
110
- "x-descriptionZh": "覆盖较早的群聊排队回合,或按到达顺序处理全部回合。"
124
+ "description": "supersede (default): a sender's new message replaces their own unanswered turn; fifo: every message waits in arrival order. Other members' messages always queue.",
125
+ "x-descriptionZh": "supersede(默认):同一发起人的新消息会替换他自己尚未回复的回合;fifo:全部按到达顺序排队。其他成员的消息总是排队。"
126
+ },
127
+ "ttlMs": {
128
+ "type": "integer",
129
+ "minimum": 0,
130
+ "default": 180000,
131
+ "description": "A queued turn that waited longer than this is dropped with a short notice. 0 disables expiry.",
132
+ "x-descriptionZh": "排队超过该时长的回合会被丢弃并简短提示;0 表示不过期。"
133
+ },
134
+ "maxQueuedTurns": {
135
+ "type": "integer",
136
+ "minimum": 0,
137
+ "default": 5,
138
+ "description": "Turns allowed to wait behind the running turn of one conversation; further messages get a short busy reply.",
139
+ "x-descriptionZh": "同一会话中允许排在当前回合之后的回合数;超出时回复一句“正在忙”。"
111
140
  }
112
141
  }
113
142
  },
@@ -123,10 +152,65 @@
123
152
  "description": "Command allowlist preset used outside full mode.",
124
153
  "x-descriptionZh": "非 full 模式使用的命令白名单预设。"
125
154
  },
155
+ "masterShell": {
156
+ "type": "string",
157
+ "enum": ["off", "isolated"],
158
+ "default": "off",
159
+ "description": "Shell access for the master in IM private chats. off: no bash in IM turns. isolated: master DMs run bash inside a Docker container (workspace writable, no network, development allowlist, approval for anything else); fails closed when Docker is unavailable.",
160
+ "x-descriptionZh": "IM 私聊中 master 的 Shell 权限。off:IM 会话不提供 bash;isolated:master 私聊在 Docker 容器内执行 bash(工作区可写、无网络、development 白名单、其余命令需审批),宿主无 Docker 时直接失败。"
161
+ },
162
+ "toolBudget": {
163
+ "type": "object",
164
+ "description": "Progress-aware tool-call budget. maxIterations is a soft budget extended while the turn makes progress; hard caps always apply.",
165
+ "x-descriptionZh": "进度感知的工具调用预算。maxIterations 为软上限,回合有进展时自动延长;硬上限始终生效。",
166
+ "properties": {
167
+ "extendBy": { "type": "integer", "minimum": 0, "default": 10, "description": "Steps added per extension when progressing; 0 disables.", "x-descriptionZh": "有进展时每次延长的步数;0 关闭。" },
168
+ "progressWindow": { "type": "integer", "minimum": 0, "default": 5, "description": "Progress must have happened within this many recent steps.", "x-descriptionZh": "最近多少步内有进展才延长。" },
169
+ "maxTotalIterations": { "type": "integer", "minimum": 0, "default": 40, "description": "Hard step cap per turn segment.", "x-descriptionZh": "每段回合的硬步数上限。" },
170
+ "maxSubagentTotalIterations": { "type": "integer", "minimum": 0, "default": 30, "description": "Hard step cap for an in-turn sub-agent.", "x-descriptionZh": "回合内子 Agent 的硬步数上限。" },
171
+ "maxWallClockMs": { "type": "integer", "minimum": 0, "default": 600000, "description": "Wall-clock cap for the whole turn in ms; 0 disables.", "x-descriptionZh": "整个回合的时间上限(毫秒);0 关闭。" },
172
+ "maxTotalTokens": { "type": "integer", "minimum": 0, "default": 0, "description": "Token cap for the whole turn; 0 disables.", "x-descriptionZh": "整个回合的 Token 上限;0 关闭。" },
173
+ "failureStreakLimit": { "type": "integer", "minimum": 0, "default": 3, "description": "Consecutive tool failures that stop extensions.", "x-descriptionZh": "连续失败多少次后不再延长。" },
174
+ "repeatLimit": { "type": "integer", "minimum": 0, "default": 2, "description": "Consecutive identical repeated calls that stop extensions.", "x-descriptionZh": "连续重复相同调用多少次后不再延长。" },
175
+ "autoContinue": { "type": "string", "enum": ["off", "master_dm", "on"], "default": "master_dm", "description": "Auto-continue a progressing turn that hit its budget: off, only in the master's private chat, or always. Continuations reuse the same turn (same policy and approvals).", "x-descriptionZh": "回合有进展但预算用尽时自动续跑:off 关闭;master_dm 仅主人私聊;on 总是。续跑沿用同一回合的策略与审批。" },
176
+ "maxContinuations": { "type": "integer", "minimum": 0, "default": 2, "description": "Maximum automatic continuations per turn.", "x-descriptionZh": "每回合最多自动续跑次数。" },
177
+ "progressUpdates": { "type": "string", "enum": ["off", "private", "all"], "default": "private", "description": "Brief natural-language progress updates during long turns: off, private chats only, or all chats (groups opt in).", "x-descriptionZh": "长回合中的简短进度更新:off 关闭;private 仅私聊;all 包括群聊(群聊需显式开启)。" },
178
+ "progressMinIntervalMs": { "type": "integer", "minimum": 0, "default": 45000, "description": "Minimum time between progress updates in ms.", "x-descriptionZh": "两次进度更新的最小间隔(毫秒)。" },
179
+ "progressMaxUpdates": { "type": "integer", "minimum": 0, "default": 3, "description": "Maximum progress updates per turn; 0 disables.", "x-descriptionZh": "每回合最多进度更新次数;0 关闭。" }
180
+ }
181
+ },
182
+ "injectionGuard": {
183
+ "type": "object",
184
+ "description": "Prompt-injection defences. After lower-trust content (tool results, web pages, files, sub-agent reports, background messages) enters a turn, high-risk actions (shell, messaging other conversations, config changes, scheduling) need approval; replies are checked for system-prompt, secret and other-session memory leaks.",
185
+ "x-descriptionZh": "注入防护。外部内容(工具结果、网页、文件、子 agent 报告、群聊背景消息)进入回合后,高危动作(执行命令、向其他会话发消息、改配置、定时任务)必须审批;出站回复检查系统提示、密钥与其他会话记忆泄露。",
186
+ "properties": {
187
+ "confirmHighRisk": { "type": "string", "enum": ["external_content", "off"], "default": "external_content", "description": "external_content: high-risk actions after external content always ask for approval (denied in unattended turns). off: disabled.", "x-descriptionZh": "external_content:读取外部内容后的高危动作一律审批(无人值守回合直接拒绝);off:关闭。" },
188
+ "outboundCheck": { "type": "string", "enum": ["redact", "off"], "default": "redact", "description": "redact: mask leaked system-prompt lines, secrets and other sessions' memory in replies and outbound tool messages. off: disabled.", "x-descriptionZh": "redact:在回复和外发工具消息中屏蔽泄露的系统提示、密钥和其他会话记忆;off:关闭。" },
189
+ "trustedOutputTools": { "type": "array", "items": { "type": "string" }, "default": [], "description": "Tools whose results do not count as external content.", "x-descriptionZh": "结果不算外部内容的工具。" }
190
+ }
191
+ },
192
+ "usageQuota": {
193
+ "type": "object",
194
+ "description": "Per-user and per-group token quotas and a per-group rate limit. All limits are off (0) by default; the owner is exempt by default. Counters are kept in memory.",
195
+ "x-descriptionZh": "按用户 / 按群的 token 配额与群速率限制。默认全部关闭(0),主人默认不受限;计数保存在内存中。",
196
+ "properties": {
197
+ "windowMs": { "type": "integer", "minimum": 60000, "default": 86400000, "description": "Quota window in ms.", "x-descriptionZh": "配额周期(毫秒)。" },
198
+ "perUserTokens": { "type": "integer", "minimum": 0, "default": 0, "description": "Tokens one user may use per window; 0 = unlimited.", "x-descriptionZh": "每个用户每周期可用的 token;0 不限。" },
199
+ "perGroupTokens": { "type": "integer", "minimum": 0, "default": 0, "description": "Tokens one group may use per window; 0 = unlimited.", "x-descriptionZh": "每个群每周期可用的 token;0 不限。" },
200
+ "perGroupRequestsPerMinute": { "type": "integer", "minimum": 0, "default": 0, "description": "Turns one group may start per minute; 0 = unlimited.", "x-descriptionZh": "每个群每分钟可发起的回合数;0 不限。" },
201
+ "exemptOwner": { "type": "boolean", "default": true, "description": "The owner is never limited.", "x-descriptionZh": "主人不受限制。" }
202
+ }
203
+ },
204
+ "subagentTurnMode": {
205
+ "type": "string",
206
+ "enum": ["off", "sync"],
207
+ "default": "off",
208
+ "description": "Sub-agents in IM turns. off: no spawn_task. sync: spawn_task runs a sub-agent inside the current turn with at most the parent's permissions (never wider; no bash unless the parent could run it) and approvals routed to the same conversation.",
209
+ "x-descriptionZh": "IM 回合中的子 Agent。off:不提供 spawn_task;sync:spawn_task 在当前回合内同步运行子 Agent,权限不超过父回合(父回合不能运行 bash 时子 Agent 也没有),审批发往同一会话。"
210
+ },
126
211
  "execApprovalMode": { "type": "string", "enum": ["ask", "bypass", "auto"], "description": "Approval policy for main Agent commands.", "x-descriptionZh": "主 Agent 命令的审批策略。" },
127
212
  "subagentExecApprovalMode": { "type": "string", "enum": ["ask", "bypass", "auto"], "description": "Approval policy for sub-Agent commands.", "x-descriptionZh": "子 Agent 命令的审批策略。" },
128
213
  "workerExecApprovalMode": { "type": "string", "enum": ["ask", "bypass", "auto"], "description": "Approval policy for worker commands.", "x-descriptionZh": "Worker 命令的审批策略。" },
129
- "taskExecApprovalMode": { "type": "string", "enum": ["ask", "bypass", "auto"], "description": "Approval policy for task commands.", "x-descriptionZh": "Task 命令的审批策略。" },
130
214
  "toolExecution": {
131
215
  "type": "string",
132
216
  "enum": ["parallel", "sequential", "tiered"],
@@ -264,5 +348,20 @@
264
348
  "assistant": { "type": "object", "additionalProperties": true, "description": "Scheduled jobs, event ingress, and failure notifications.", "x-descriptionZh": "调度任务、事件入口和失败通知。" },
265
349
  "log_level": { "type": ["string", "number"], "description": "Runtime log level.", "x-descriptionZh": "Runtime 日志级别。" },
266
350
  "plugin": { "type": "object", "description": "Root Plugin configuration; replaced by its project schema during composition.", "x-descriptionZh": "Root Plugin 配置;组合时由项目 Schema 替换。" }
351
+ },
352
+ "$defs": {
353
+ "decisionTask": {
354
+ "type": "object",
355
+ "additionalProperties": false,
356
+ "required": ["mode"],
357
+ "properties": {
358
+ "mode": { "type": "string", "enum": ["off", "shadow", "active"] },
359
+ "timeoutMs": { "type": "integer", "minimum": 1, "maximum": 2147483647 },
360
+ "minConfidence": { "type": "number", "minimum": 0, "maximum": 1 },
361
+ "topK": { "type": "integer", "minimum": 1, "maximum": 9007199254740991 },
362
+ "maxCandidates": { "type": "integer", "minimum": 1, "maximum": 9007199254740991 },
363
+ "maxSelections": { "type": "integer", "minimum": 1, "maximum": 9007199254740991 }
364
+ }
365
+ }
267
366
  }
268
367
  }
package/lib/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from './config-composer.js';
2
+ export { readPluginConfigSchema } from './plugin-config-schema.js';
2
3
  export * from './config-patch-planner.js';
3
4
  export * from './package-contract.js';
4
5
  export * from './environment.js';
package/lib/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from './config-composer.js';
2
+ export { readPluginConfigSchema } from './plugin-config-schema.js';
2
3
  export * from './config-patch-planner.js';
3
4
  export * from './package-contract.js';
4
5
  export * from './environment.js';
@@ -27,6 +27,7 @@ export class NativeDevelopmentModuleRuntime {
27
27
  #pendingDependencies;
28
28
  #watchRoots;
29
29
  #revision = 0;
30
+ #entryRevisions = new Map();
30
31
  #closed = false;
31
32
  constructor(options) {
32
33
  ensureTypeScriptSpecifierRemap();
@@ -43,7 +44,9 @@ export class NativeDevelopmentModuleRuntime {
43
44
  const analysis = this.#pendingDependencies?.get(normalized)
44
45
  ?? await this.#dependencies.analyze(normalized);
45
46
  const url = pathToFileURL(normalized);
46
- url.searchParams.set('zhin-generation', String(this.#revision));
47
+ if (!this.#entryRevisions.has(normalized))
48
+ this.#entryRevisions.set(normalized, this.#revision);
49
+ url.searchParams.set('zhin-generation', String(this.#entryRevisions.get(normalized)));
47
50
  let loaded;
48
51
  try {
49
52
  loaded = await import(url.href);
@@ -61,8 +64,12 @@ export class NativeDevelopmentModuleRuntime {
61
64
  return loaded;
62
65
  }
63
66
  invalidate(source) {
67
+ const impacted = this.#dependencies.affectedSources(source);
64
68
  this.#dependencies.invalidate(source);
65
69
  this.#revision += 1;
70
+ for (const entry of new Set([normalizeSourcePath(source), ...impacted])) {
71
+ this.#entryRevisions.set(entry, this.#revision);
72
+ }
66
73
  }
67
74
  affectedSources(source) {
68
75
  return this.#dependencies.affectedSources(source);
@@ -86,6 +93,11 @@ export class NativeDevelopmentModuleRuntime {
86
93
  // complete project-local import closure.
87
94
  if (this.affectedSources(normalized).length > 1)
88
95
  return false;
96
+ // An already loaded ESM entry has the same revisioned import closure as a
97
+ // capability entry. Its manifest may name plugin.ts or any other filename;
98
+ // Runtime ownership, rather than directory spelling, chooses its reload.
99
+ if (this.#entryRevisions.has(normalized) && isExecutableSource(normalized))
100
+ return false;
89
101
  const parts = relative(packageRoot, normalized).split(sep);
90
102
  const capability = parts.findIndex((part) => capabilityRoots.has(part));
91
103
  if (capability < 0)
@@ -134,6 +146,11 @@ export class NativeDevelopmentModuleRuntime {
134
146
  }
135
147
  this.#pendingDependencies = undefined;
136
148
  this.#dependencies.retain(sources);
149
+ const retained = new Set(sources.map(normalizeSourcePath));
150
+ for (const entry of this.#entryRevisions.keys()) {
151
+ if (!retained.has(entry))
152
+ this.#entryRevisions.delete(entry);
153
+ }
137
154
  }
138
155
  rollbackGeneration() {
139
156
  this.#pendingDependencies = undefined;
@@ -170,6 +187,7 @@ export class NativeDevelopmentModuleRuntime {
170
187
  this.#watchers.clear();
171
188
  this.#pendingDependencies = undefined;
172
189
  this.#dependencies.clear();
190
+ this.#entryRevisions.clear();
173
191
  await this.#tsxLoader?.close?.();
174
192
  }
175
193
  #assertOpen() {
@@ -0,0 +1,6 @@
1
+ import type { ModuleRuntime } from './module-runtime.js';
2
+ import type { ResolvedPackage } from './package-resolver.js';
3
+ /** One source of truth per package: executable declaration or JSON Schema. */
4
+ export declare function readPluginConfigSchema(pkg: ResolvedPackage, modules?: Pick<ModuleRuntime, 'load'>): Promise<Readonly<Record<string, unknown>> | null>;
5
+ /** Detached declaration metadata for preflight and custom-resolver startup views. */
6
+ export declare function immutableConfigSchema(schema: Readonly<Record<string, unknown>>): Readonly<Record<string, unknown>>;
@@ -0,0 +1,44 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { resolve } from 'node:path';
3
+ /** One source of truth per package: executable declaration or JSON Schema. */
4
+ export async function readPluginConfigSchema(pkg, modules) {
5
+ const file = resolve(pkg.root, 'schema.json');
6
+ let fileSchema;
7
+ try {
8
+ fileSchema = JSON.parse(await readFile(file, 'utf8'));
9
+ }
10
+ catch (error) {
11
+ if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT'))
12
+ throw error;
13
+ }
14
+ // Isolated plugin code never executes in the composition process.
15
+ const manifest = pkg.packageJson.zhin;
16
+ let declaration;
17
+ if (modules && manifest.type === 'plugin' && manifest.runtime !== 'isolated') {
18
+ const module = await modules.load(resolve(pkg.root, manifest.entry));
19
+ declaration = module.default?.config;
20
+ }
21
+ if (declaration && fileSchema !== undefined) {
22
+ throw new TypeError(`${pkg.name} declares both plugin config and schema.json; choose one configuration schema`);
23
+ }
24
+ const schema = declaration ? declaration.toJsonSchema() : fileSchema;
25
+ if (schema === undefined)
26
+ return null;
27
+ if (!schema || typeof schema !== 'object' || Array.isArray(schema)) {
28
+ throw new TypeError(`${pkg.name} configuration must contain a JSON Schema object`);
29
+ }
30
+ return immutableConfigSchema(schema);
31
+ }
32
+ /** Detached declaration metadata for preflight and custom-resolver startup views. */
33
+ export function immutableConfigSchema(schema) {
34
+ const copy = structuredClone(schema);
35
+ const freeze = (value) => {
36
+ if (!value || typeof value !== 'object')
37
+ return;
38
+ for (const child of Object.values(value))
39
+ freeze(child);
40
+ Object.freeze(value);
41
+ };
42
+ freeze(copy);
43
+ return copy;
44
+ }
@@ -6,7 +6,10 @@ import type { PluginGraphNode } from './project-graph.js';
6
6
  import type { IsolatedPluginRuntimePort } from './isolation.js';
7
7
  import { type PrimaryConfig } from './primary-config.js';
8
8
  import type { RuntimeConfigDocument } from '@zhin.js/plugin-runtime';
9
- export type PluginConfigResolver = (node: PluginGraphNode) => unknown;
9
+ export interface PluginConfigResolver {
10
+ (node: PluginGraphNode): unknown;
11
+ schema?(node: PluginGraphNode): Readonly<Record<string, unknown>> | undefined;
12
+ }
10
13
  export interface RootResourceContext {
11
14
  /** Exact shadow generation being assembled; never inferred from a latest snapshot. */
12
15
  readonly generation: number;
@@ -1,5 +1,5 @@
1
1
  import { resolve } from 'node:path';
2
- import { GenerationHandoffStack, Scope, capabilityId, createGenerationAdmissionGate, createToken, PluginDatabaseHost, PluginScheduleHost, databaseHostToken, databaseRootHostToken, featureId, rootPluginId, scheduleHostToken, scheduleRootHostToken, } from '@zhin.js/plugin-runtime';
2
+ import { GenerationHandoffStack, Scope, capabilityId, createGenerationAdmissionGate, createToken, PluginDatabaseHost, PluginOutboundHost, PluginScheduleHost, databaseHostToken, databaseRootHostToken, outboundHostToken, outboundRootHostToken, featureId, rootPluginId, scheduleHostToken, scheduleRootHostToken, } from '@zhin.js/plugin-runtime';
3
3
  import { runtimeEnvironmentToken } from './environment.js';
4
4
  import { EnvStoreFactory, envStoreToken, } from './environment-store.js';
5
5
  import { createPrimaryConfig, primaryConfigToken, } from './primary-config.js';
@@ -98,6 +98,9 @@ export class PluginScopeAssembler {
98
98
  role: node.parent ? 'child' : 'root',
99
99
  });
100
100
  let metadata;
101
+ // Schema composition is Host-owned even when Plugin execution is isolated.
102
+ // Commit the validated JSON declaration with both kinds of Plugin snapshot.
103
+ const configSchema = this.configResolver.schema?.(node);
101
104
  if (manifest.runtime === 'isolated') {
102
105
  if (!node.parent)
103
106
  throw new Error('Root Plugin cannot use runtime: isolated');
@@ -165,9 +168,11 @@ export class PluginScopeAssembler {
165
168
  instanceKey: node.instanceKey,
166
169
  packageName: node.package.name,
167
170
  packageRoot: node.package.root,
171
+ ...(typeof node.package.packageJson.version === 'string' ? { packageVersion: node.package.packageJson.version } : {}),
168
172
  parent: node.parent,
169
173
  children: Object.freeze(node.children.map((child) => child.id)),
170
174
  metadata,
175
+ ...(configSchema ? { configSchema } : {}),
171
176
  }));
172
177
  this.config.set(node.id, config);
173
178
  this.resources.set(node.id, scope.snapshot());
@@ -226,17 +231,25 @@ export class PluginScopeAssembler {
226
231
  }
227
232
  /** Root resources are process-owned; every Plugin Scope receives an owner-bound facade. */
228
233
  installOwnerScopedHosts(scope, owner) {
229
- const database = scope.has(databaseRootHostToken)
230
- ? scope.use(databaseRootHostToken)
234
+ const root = this.scopes.get(rootPluginId());
235
+ if (!root)
236
+ throw new Error('Missing root resource scope');
237
+ // A retired subtree must close its facades even when the parent remains active.
238
+ const admission = createGenerationAdmissionGate();
239
+ if (root.has(outboundRootHostToken)) {
240
+ scope.provide(outboundHostToken, new PluginOutboundHost(owner, root.use(outboundRootHostToken), admission));
241
+ }
242
+ const database = root.has(databaseRootHostToken)
243
+ ? root.use(databaseRootHostToken)
231
244
  : undefined;
232
245
  if (database) {
233
- scope.provide(databaseHostToken, new PluginDatabaseHost(owner, database));
246
+ scope.provide(databaseHostToken, new PluginDatabaseHost(owner, database, admission));
234
247
  }
235
- const schedule = scope.has(scheduleRootHostToken)
236
- ? scope.use(scheduleRootHostToken)
248
+ const schedule = root.has(scheduleRootHostToken)
249
+ ? root.use(scheduleRootHostToken)
237
250
  : undefined;
238
251
  if (schedule) {
239
- scope.provide(scheduleHostToken, new PluginScheduleHost(owner, schedule));
252
+ scope.provide(scheduleHostToken, new PluginScheduleHost(owner, schedule, admission));
240
253
  }
241
254
  }
242
255
  }
@@ -1,4 +1,5 @@
1
1
  import { type ControlErrorHandler, type ConfigDocumentPort, type ConfigPatch, type GenerationCommitListener, type PluginId, type RuntimeConfigDocument, type RuntimeSnapshot, type SnapshotReader } from '@zhin.js/plugin-runtime';
2
+ import { type JsonSchema } from './config-composer.js';
2
3
  import { type RuntimeEnvironment } from './environment.js';
3
4
  import { type EnvironmentLayers, type EnvironmentLayersPort } from './environment-store.js';
4
5
  import type { IsolatedPluginRuntimePort } from './isolation.js';
@@ -27,6 +28,10 @@ export declare class RootRuntime {
27
28
  constructor(options: RootRuntimeOptions);
28
29
  get snapshot(): RuntimeSnapshot;
29
30
  get snapshots(): SnapshotReader;
31
+ /** Committed metadata, or validated startup declarations while first login is pending. */
32
+ get configurationSchemas(): ReadonlyMap<string, JsonSchema>;
33
+ /** Metadata readiness is independent of account login and generation activation. */
34
+ whenConfigurationReady(): Promise<void>;
30
35
  onGenerationCommit(listener: GenerationCommitListener<RuntimeGenerationState>): () => void;
31
36
  get sourceOwnership(): SourceOwnershipIndex;
32
37
  start(): Promise<RuntimeSnapshot>;
@@ -4,6 +4,7 @@ import { isDeepStrictEqual } from 'node:util';
4
4
  import { DisposeStack, GenerationHandoffStack, RootController, ConfigDocumentDivergenceError, createSnapshotView, rootPluginId, } from '@zhin.js/plugin-runtime';
5
5
  import { FeatureCatalog, FeatureDiscovery, } from '@zhin.js/feature-kit';
6
6
  import { ConfigComposer } from './config-composer.js';
7
+ import { immutableConfigSchema } from './plugin-config-schema.js';
7
8
  import { ConfigPatchPlanner } from './config-patch-planner.js';
8
9
  import { defineRuntimeEnvironment } from './environment.js';
9
10
  import { createEnvStore, defineEnvironmentLayers, } from './environment-store.js';
@@ -39,9 +40,17 @@ export class RootRuntime {
39
40
  #isolation;
40
41
  #configPatchTail = Promise.resolve();
41
42
  #stopResult;
43
+ #initialConfigSchemas = new Map();
44
+ #resolveConfigurationReady;
45
+ #rejectConfigurationReady;
46
+ #configurationReady = new Promise((resolve, reject) => {
47
+ this.#resolveConfigurationReady = resolve;
48
+ this.#rejectConfigurationReady = reject;
49
+ });
42
50
  #controller;
43
51
  #disabledPluginInstanceKeys;
44
52
  constructor(options) {
53
+ void this.#configurationReady.catch(() => undefined);
45
54
  this.#projectRoot = normalizeSourcePath(options.projectRoot);
46
55
  this.#modules = options.modules;
47
56
  this.#environment = defineRuntimeEnvironment(options.environment);
@@ -68,6 +77,17 @@ export class RootRuntime {
68
77
  get snapshots() {
69
78
  return this.#controller.snapshots;
70
79
  }
80
+ /** Committed metadata, or validated startup declarations while first login is pending. */
81
+ get configurationSchemas() {
82
+ const snapshot = this.snapshot;
83
+ return snapshot.generation === 0 ? new Map(this.#initialConfigSchemas)
84
+ : new Map([...snapshot.tree.values()].flatMap(node => node.configSchema
85
+ ? [[node.packageRoot, node.configSchema]] : []));
86
+ }
87
+ /** Metadata readiness is independent of account login and generation activation. */
88
+ whenConfigurationReady() {
89
+ return this.#configurationReady;
90
+ }
71
91
  onGenerationCommit(listener) {
72
92
  return this.#controller.onGenerationCommit(listener);
73
93
  }
@@ -75,18 +95,24 @@ export class RootRuntime {
75
95
  return this.#controller.committed.state.ownership;
76
96
  }
77
97
  async start() {
78
- if (this.#environmentSource) {
79
- this.#environmentLayers = defineEnvironmentLayers(await this.#environmentSource.read());
98
+ try {
99
+ if (this.#environmentSource) {
100
+ this.#environmentLayers = defineEnvironmentLayers(await this.#environmentSource.read());
101
+ }
102
+ if (this.#configPort) {
103
+ const snapshot = await this.#configPort.read();
104
+ this.#configSnapshot = snapshot;
105
+ this.#configDocument = structuredClone(snapshot.document);
106
+ }
107
+ const snapshot = await this.#controller.start(async (current, signal) => {
108
+ return (await this.#prepare(current, signal)).generation;
109
+ });
110
+ return snapshot;
80
111
  }
81
- if (this.#configPort) {
82
- const snapshot = await this.#configPort.read();
83
- this.#configSnapshot = snapshot;
84
- this.#configDocument = structuredClone(snapshot.document);
112
+ catch (error) {
113
+ this.#rejectConfigurationReady(error);
114
+ throw error;
85
115
  }
86
- const snapshot = await this.#controller.start(async (current, signal) => {
87
- return (await this.#prepare(current, signal)).generation;
88
- });
89
- return snapshot;
90
116
  }
91
117
  async reload(target = rootPluginId()) {
92
118
  const snapshot = await this.#controller.reload(target, async (current, signal) => {
@@ -118,6 +144,7 @@ export class RootRuntime {
118
144
  return new RootProcessRestartExecutor(this, adapter);
119
145
  }
120
146
  stop() {
147
+ this.#rejectConfigurationReady(new Error('Runtime stopped before configuration initialization'));
121
148
  if (this.#stopResult)
122
149
  return this.#stopResult;
123
150
  const result = (async () => {
@@ -200,7 +227,7 @@ export class RootRuntime {
200
227
  const environmentLayers = environmentChanged && this.#environmentSource
201
228
  ? defineEnvironmentLayers(await this.#environmentSource.read())
202
229
  : this.#environmentLayers;
203
- const inputPlan = await new RuntimeInputPlanner(new ConfigComposer(this.#disabledPluginInstanceKeys)).plan({
230
+ const inputPlan = await new RuntimeInputPlanner(new ConfigComposer(this.#disabledPluginInstanceKeys, this.#modules)).plan({
204
231
  graph: this.#model.graph,
205
232
  environment: this.#environment,
206
233
  previousDocument: requireConfigDocument(this.#configDocument),
@@ -285,18 +312,39 @@ export class RootRuntime {
285
312
  await this.#refreshConfigDocument();
286
313
  const activeDocument = document ?? this.#configDocument;
287
314
  if (this.#configResolver) {
315
+ const customResolver = this.#configResolver;
316
+ const schemas = new Map(graphNodes(graph.root).flatMap(node => {
317
+ const schema = customResolver.schema?.(node);
318
+ return schema ? [[node.id, immutableConfigSchema(schema)]] : [];
319
+ }));
320
+ const configResolver = node => customResolver(node);
321
+ configResolver.schema = node => schemas.get(node.id);
322
+ if (this.snapshot.generation === 0) {
323
+ this.#initialConfigSchemas = new Map(graphNodes(graph.root).flatMap(node => {
324
+ const schema = schemas.get(node.id);
325
+ return schema ? [[node.package.root, schema]] : [];
326
+ }));
327
+ this.#resolveConfigurationReady();
328
+ }
288
329
  return {
289
330
  graph,
290
- configResolver: this.#configResolver,
331
+ configResolver,
291
332
  primaryConfigDocument: Object.freeze({}),
292
333
  environmentLayers,
293
334
  };
294
335
  }
295
- const composed = await new ConfigComposer(this.#disabledPluginInstanceKeys)
336
+ const composed = await new ConfigComposer(this.#disabledPluginInstanceKeys, this.#modules)
296
337
  .compose(graph, activeDocument);
338
+ if (this.snapshot.generation === 0) {
339
+ this.#initialConfigSchemas = new Map(graphNodes(graph.root).flatMap(node => {
340
+ const schema = composed.ownSchemas.get(node.id);
341
+ return schema ? [[node.package.root, schema]] : [];
342
+ }));
343
+ this.#resolveConfigurationReady();
344
+ }
297
345
  return {
298
346
  graph,
299
- configResolver: this.#configViewResolver(composed.views, environmentLayers),
347
+ configResolver: this.#configViewResolver(composed.views, environmentLayers, composed.ownSchemas),
300
348
  primaryConfigDocument: composed.document,
301
349
  environmentLayers,
302
350
  };
@@ -317,14 +365,16 @@ export class RootRuntime {
317
365
  this.#configSnapshot = snapshot;
318
366
  this.#configDocument = structuredClone(snapshot.document);
319
367
  }
320
- #configViewResolver(views, environmentLayers = this.#environmentLayers) {
321
- return (node) => {
368
+ #configViewResolver(views, environmentLayers = this.#environmentLayers, schemas) {
369
+ const resolver = (node) => {
322
370
  const view = views.get(node.id);
323
371
  if (view === undefined)
324
372
  return undefined;
325
373
  return createEnvStore(node.id, this.#environment, environmentLayers)
326
374
  .expandMissingAsEmpty(view);
327
375
  };
376
+ resolver.schema = (node) => schemas?.get(node.id);
377
+ return resolver;
328
378
  }
329
379
  async #applyConfigPatches(patches) {
330
380
  if (!this.#configDocument) {
@@ -344,7 +394,7 @@ export class RootRuntime {
344
394
  const graph = await new ProjectGraphService(resolver, {
345
395
  disabledPluginInstanceKeys: this.#disabledPluginInstanceKeys,
346
396
  }).inspect(this.#projectRoot);
347
- const planned = await new ConfigPatchPlanner(new ConfigComposer(this.#disabledPluginInstanceKeys)).plan(graph, currentDocument, patches);
397
+ const planned = await new ConfigPatchPlanner(new ConfigComposer(this.#disabledPluginInstanceKeys, this.#modules)).plan(graph, currentDocument, patches);
348
398
  plan = planned;
349
399
  if (!planned.documentChanged)
350
400
  return undefined;
@@ -369,7 +419,7 @@ export class RootRuntime {
369
419
  }
370
420
  const inspected = {
371
421
  graph,
372
- configResolver: this.#configViewResolver(planned.views),
422
+ configResolver: this.#configViewResolver(planned.views, this.#environmentLayers, planned.ownSchemas),
373
423
  primaryConfigDocument: planned.document,
374
424
  environmentLayers: this.#environmentLayers,
375
425
  };
@@ -590,6 +640,9 @@ class GenerationAssembler {
590
640
  };
591
641
  }
592
642
  }
643
+ function graphNodes(node) {
644
+ return [node, ...node.children.flatMap(graphNodes)];
645
+ }
593
646
  function emptyState() {
594
647
  return {
595
648
  root: rootPluginId(),
@@ -30,14 +30,10 @@ export class SlotGenerationPreparer {
30
30
  for (const slot of replacements)
31
31
  capabilities.set(slot.id, slot);
32
32
  }
33
- // Capability-file HMR projects only the owning Features. The complete
34
- // snapshot below is assembled by retaining every other projection.
35
- const providers = [...selectedByFeature.keys()].map((feature) => {
36
- const provider = this.model.providers.get(feature);
37
- if (!provider)
38
- throw new Error(`Missing Feature provider for ${feature}`);
39
- return provider;
40
- });
33
+ // Snapshot-capturing execution indexes need a current view even when their
34
+ // own definitions were not edited. Retain only explicitly shareable resources.
35
+ const providers = [...this.model.providers.values()].filter((provider) => selectedByFeature.has(provider.id) || provider.runtime.retain !== 'when-owner-unchanged');
36
+ const projectedFeatures = providers.map((provider) => provider.id);
41
37
  const projected = await new FeatureProjector(providers).project(current.generation + 1, {
42
38
  root: current.root,
43
39
  tree: current.tree,
@@ -48,7 +44,7 @@ export class SlotGenerationPreparer {
48
44
  try {
49
45
  const snapshot = createSnapshotView(current.generation + 1, projected.state);
50
46
  const ownership = SourceOwnershipIndex.fromGeneration(this.model.graph, snapshot, this.model.featureIdsByPackageRoot);
51
- const assets = this.model.assets.replaceProjections(selectedByFeature.keys(), projected.disposers);
47
+ const assets = this.model.assets.replaceProjections(projectedFeatures, projected.disposers);
52
48
  return prepareRuntimeGeneration({
53
49
  snapshot: projected.state,
54
50
  dispose: () => assets.dispose(),
@@ -1,7 +1,6 @@
1
- import { type PluginId, type RuntimeSnapshot } from '@zhin.js/plugin-runtime';
1
+ import { type PluginId, type RuntimeSnapshot, type RuntimeConfigDocument } from '@zhin.js/plugin-runtime';
2
2
  import type { RuntimeEnvironment } from './environment.js';
3
3
  import type { EnvironmentLayers } from './environment-store.js';
4
- import type { RuntimeConfigDocument } from '@zhin.js/plugin-runtime';
5
4
  import type { IsolatedPluginRuntimePort } from './isolation.js';
6
5
  import type { ModuleRuntime } from './module-runtime.js';
7
6
  import { type PluginConfigResolver, type RootResourceInstaller } from './plugin-scope-assembler.js';
@@ -71,13 +71,28 @@ export class SubtreeGenerationPreparer {
71
71
  for (const slot of slots)
72
72
  addCapabilitySlot(capabilities, slot);
73
73
  }
74
- const projected = await new FeatureProjector(this.model.providers.values()).project(current.generation + 1, {
74
+ // Most indexes capture a complete construction snapshot and must rebuild.
75
+ // Only an explicitly shareable projection may retain unchanged transports.
76
+ const affectedFeatures = new Set([...this.model.providers.values()]
77
+ .filter((provider) => provider.runtime.retain !== 'when-owner-unchanged')
78
+ .map((provider) => provider.id));
79
+ for (const [feature, featureRoots] of this.model.rootsByFeature) {
80
+ if (featureRoots.some((entry) => roots.some((root) => isWithin(entry.owner, root)))) {
81
+ affectedFeatures.add(feature);
82
+ }
83
+ }
84
+ for (const slot of [...current.capabilities.values(), ...capabilities.values()]) {
85
+ if (roots.some((root) => isWithin(slot.owner, root)))
86
+ affectedFeatures.add(slot.feature);
87
+ }
88
+ const providers = [...this.model.providers.values()].filter((provider) => affectedFeatures.has(provider.id));
89
+ const projected = await new FeatureProjector(providers).project(current.generation + 1, {
75
90
  root: current.root,
76
91
  tree: plugins.tree,
77
92
  config: plugins.config,
78
93
  resources: plugins.resources,
79
94
  capabilities,
80
- }, signal);
95
+ }, signal, current.projections);
81
96
  for (const [feature, dispose] of projected.disposers) {
82
97
  projectionDisposers.set(feature, dispose);
83
98
  }
@@ -87,7 +102,7 @@ export class SubtreeGenerationPreparer {
87
102
  const replacements = new Map(plugins.createdScopeDisposers());
88
103
  // Local prepare still commits a complete immutable generation. The
89
104
  // replacement map controls lifetime ownership, not snapshot granularity.
90
- const assets = this.model.assets.replaceScopes([...nodes.keys()], replacements, projectionDisposers);
105
+ const assets = this.model.assets.replaceScopes([...nodes.keys()], replacements, affectedFeatures, projectionDisposers);
91
106
  return prepareRuntimeGeneration({
92
107
  snapshot: projected.state,
93
108
  dispose: () => assets.dispose(),
@@ -1,7 +1,6 @@
1
- import { type CapabilityId, type PluginId, type RuntimeSnapshot } from '@zhin.js/plugin-runtime';
1
+ import { type CapabilityId, type PluginId, type RuntimeSnapshot, type RuntimeConfigDocument } from '@zhin.js/plugin-runtime';
2
2
  import type { RuntimeEnvironment } from './environment.js';
3
3
  import type { EnvironmentLayers } from './environment-store.js';
4
- import type { RuntimeConfigDocument } from '@zhin.js/plugin-runtime';
5
4
  import type { IsolatedPluginRuntimePort } from './isolation.js';
6
5
  import type { ModuleRuntime } from './module-runtime.js';
7
6
  import { type PluginConfigResolver, type RootResourceInstaller } from './plugin-scope-assembler.js';
@@ -92,7 +92,7 @@ export class TopologyGenerationPreparer {
92
92
  plugins.bindCandidateSnapshot(snapshot);
93
93
  const ownership = SourceOwnershipIndex.fromGeneration(this.graph, snapshot, featureTopology.featureIdsByPackageRoot);
94
94
  const replacements = new Map(plugins.createdScopeDisposers());
95
- const assets = this.model.assets.replaceScopes(graphOrder(this.graph), replacements, projectionDisposers);
95
+ const assets = this.model.assets.replaceScopes(graphOrder(this.graph), replacements, new Set([...this.model.providers.keys(), ...featureTopology.providers.keys()]), projectionDisposers);
96
96
  return prepareRuntimeGeneration({
97
97
  snapshot: projected.state,
98
98
  dispose: () => assets.dispose(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhin.js/runtime",
3
- "version": "1.1.4",
3
+ "version": "1.1.6",
4
4
  "description": "Static Plugin graph, generation transaction and HMR Root runtime for Zhin.js",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -20,24 +20,25 @@
20
20
  "es-module-lexer": "2.3.1",
21
21
  "import-meta-resolve": "4.2.0",
22
22
  "semver": "7.8.5",
23
- "@zhin.js/plugin-runtime": "1.1.11",
24
- "@zhin.js/feature-kit": "1.1.2"
23
+ "@zhin.js/feature-kit": "1.1.4",
24
+ "@zhin.js/plugin-runtime": "1.1.13"
25
25
  },
26
26
  "devDependencies": {
27
27
  "@types/node": "^26.1.2",
28
28
  "tsx": "^4.23.1",
29
29
  "typescript": "^6.0.3",
30
- "@zhin.js/adapter": "1.1.16",
31
- "@zhin.js/agent-feature": "1.1.3",
32
- "@zhin.js/command": "1.1.4",
33
- "@zhin.js/component": "1.1.3",
34
- "@zhin.js/mcp-feature": "1.1.3",
35
- "@zhin.js/layout": "1.1.3",
36
- "@zhin.js/page": "1.1.3",
37
- "@zhin.js/middleware": "1.1.3",
38
- "@zhin.js/prompt-section": "1.1.2",
39
- "@zhin.js/skill": "1.1.3",
40
- "@zhin.js/tool": "1.1.3"
30
+ "@zhin.js/adapter": "1.1.18",
31
+ "@zhin.js/agent-feature": "1.1.5",
32
+ "@zhin.js/command": "1.1.6",
33
+ "@zhin.js/component": "1.1.5",
34
+ "@zhin.js/handler": "1.1.4",
35
+ "@zhin.js/layout": "1.1.5",
36
+ "@zhin.js/mcp-feature": "1.1.5",
37
+ "@zhin.js/middleware": "1.1.5",
38
+ "@zhin.js/page": "1.1.5",
39
+ "@zhin.js/prompt-section": "1.1.4",
40
+ "@zhin.js/skill": "1.1.5",
41
+ "@zhin.js/tool": "1.1.5"
41
42
  },
42
43
  "repository": {
43
44
  "type": "git",