@zhin.js/runtime 1.1.5 → 1.1.7

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,5 +1,7 @@
1
1
  import type { RuntimeConfigDocument, PluginId } from '@zhin.js/plugin-runtime';
2
+ import { type ErrorObject } from 'ajv/dist/2020.js';
2
3
  import type { ProjectGraph } from './project-graph.js';
4
+ import type { ModuleRuntime } from './module-runtime.js';
3
5
  export declare const HOST_CONFIG_SCHEMA: {
4
6
  $schema: string;
5
7
  type: string;
@@ -204,6 +206,20 @@ export declare const HOST_CONFIG_SCHEMA: {
204
206
  description: string;
205
207
  "x-descriptionZh": string;
206
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
+ };
207
223
  };
208
224
  };
209
225
  execSecurity: {
@@ -218,25 +234,224 @@ export declare const HOST_CONFIG_SCHEMA: {
218
234
  description: string;
219
235
  "x-descriptionZh": string;
220
236
  };
221
- execApprovalMode: {
237
+ network: {
238
+ type: string;
239
+ additionalProperties: boolean;
240
+ description: string;
241
+ "x-descriptionZh": string;
242
+ properties: {
243
+ enabled: {
244
+ type: string;
245
+ default: boolean;
246
+ description: string;
247
+ "x-descriptionZh": string;
248
+ };
249
+ allowedDomains: {
250
+ type: string;
251
+ items: {
252
+ type: string;
253
+ };
254
+ description: string;
255
+ "x-descriptionZh": string;
256
+ };
257
+ };
258
+ };
259
+ masterShell: {
222
260
  type: string;
223
261
  enum: string[];
262
+ default: string;
224
263
  description: string;
225
264
  "x-descriptionZh": string;
226
265
  };
227
- subagentExecApprovalMode: {
266
+ toolBudget: {
267
+ type: string;
268
+ description: string;
269
+ "x-descriptionZh": string;
270
+ properties: {
271
+ extendBy: {
272
+ type: string;
273
+ minimum: number;
274
+ default: number;
275
+ description: string;
276
+ "x-descriptionZh": string;
277
+ };
278
+ progressWindow: {
279
+ type: string;
280
+ minimum: number;
281
+ default: number;
282
+ description: string;
283
+ "x-descriptionZh": string;
284
+ };
285
+ maxTotalIterations: {
286
+ type: string;
287
+ minimum: number;
288
+ default: number;
289
+ description: string;
290
+ "x-descriptionZh": string;
291
+ };
292
+ maxSubagentTotalIterations: {
293
+ type: string;
294
+ minimum: number;
295
+ default: number;
296
+ description: string;
297
+ "x-descriptionZh": string;
298
+ };
299
+ maxWallClockMs: {
300
+ type: string;
301
+ minimum: number;
302
+ default: number;
303
+ description: string;
304
+ "x-descriptionZh": string;
305
+ };
306
+ maxTotalTokens: {
307
+ type: string;
308
+ minimum: number;
309
+ default: number;
310
+ description: string;
311
+ "x-descriptionZh": string;
312
+ };
313
+ failureStreakLimit: {
314
+ type: string;
315
+ minimum: number;
316
+ default: number;
317
+ description: string;
318
+ "x-descriptionZh": string;
319
+ };
320
+ repeatLimit: {
321
+ type: string;
322
+ minimum: number;
323
+ default: number;
324
+ description: string;
325
+ "x-descriptionZh": string;
326
+ };
327
+ autoContinue: {
328
+ type: string;
329
+ enum: string[];
330
+ default: string;
331
+ description: string;
332
+ "x-descriptionZh": string;
333
+ };
334
+ maxContinuations: {
335
+ type: string;
336
+ minimum: number;
337
+ default: number;
338
+ description: string;
339
+ "x-descriptionZh": string;
340
+ };
341
+ progressUpdates: {
342
+ type: string;
343
+ enum: string[];
344
+ default: string;
345
+ description: string;
346
+ "x-descriptionZh": string;
347
+ };
348
+ progressMinIntervalMs: {
349
+ type: string;
350
+ minimum: number;
351
+ default: number;
352
+ description: string;
353
+ "x-descriptionZh": string;
354
+ };
355
+ progressMaxUpdates: {
356
+ type: string;
357
+ minimum: number;
358
+ default: number;
359
+ description: string;
360
+ "x-descriptionZh": string;
361
+ };
362
+ };
363
+ };
364
+ injectionGuard: {
365
+ type: string;
366
+ description: string;
367
+ "x-descriptionZh": string;
368
+ properties: {
369
+ confirmHighRisk: {
370
+ type: string;
371
+ enum: string[];
372
+ default: string;
373
+ description: string;
374
+ "x-descriptionZh": string;
375
+ };
376
+ outboundCheck: {
377
+ type: string;
378
+ enum: string[];
379
+ default: string;
380
+ description: string;
381
+ "x-descriptionZh": string;
382
+ };
383
+ trustedOutputTools: {
384
+ type: string;
385
+ items: {
386
+ type: string;
387
+ };
388
+ default: never[];
389
+ description: string;
390
+ "x-descriptionZh": string;
391
+ };
392
+ };
393
+ };
394
+ usageQuota: {
395
+ type: string;
396
+ description: string;
397
+ "x-descriptionZh": string;
398
+ properties: {
399
+ windowMs: {
400
+ type: string;
401
+ minimum: number;
402
+ default: number;
403
+ description: string;
404
+ "x-descriptionZh": string;
405
+ };
406
+ perUserTokens: {
407
+ type: string;
408
+ minimum: number;
409
+ default: number;
410
+ description: string;
411
+ "x-descriptionZh": string;
412
+ };
413
+ perGroupTokens: {
414
+ type: string;
415
+ minimum: number;
416
+ default: number;
417
+ description: string;
418
+ "x-descriptionZh": string;
419
+ };
420
+ perGroupRequestsPerMinute: {
421
+ type: string;
422
+ minimum: number;
423
+ default: number;
424
+ description: string;
425
+ "x-descriptionZh": string;
426
+ };
427
+ exemptOwner: {
428
+ type: string;
429
+ default: boolean;
430
+ description: string;
431
+ "x-descriptionZh": string;
432
+ };
433
+ };
434
+ };
435
+ subagentTurnMode: {
228
436
  type: string;
229
437
  enum: string[];
438
+ default: string;
230
439
  description: string;
231
440
  "x-descriptionZh": string;
232
441
  };
233
- workerExecApprovalMode: {
442
+ execApprovalMode: {
234
443
  type: string;
235
444
  enum: string[];
236
445
  description: string;
237
446
  "x-descriptionZh": string;
238
447
  };
239
- taskExecApprovalMode: {
448
+ subagentExecApprovalMode: {
449
+ type: string;
450
+ enum: string[];
451
+ description: string;
452
+ "x-descriptionZh": string;
453
+ };
454
+ workerExecApprovalMode: {
240
455
  type: string;
241
456
  enum: string[];
242
457
  description: string;
@@ -639,6 +854,7 @@ export interface ComposedConfig {
639
854
  readonly effectiveSchema: JsonSchema;
640
855
  readonly document: RuntimeConfigDocument;
641
856
  readonly views: ReadonlyMap<PluginId, unknown>;
857
+ readonly ownSchemas: ReadonlyMap<PluginId, JsonSchema>;
642
858
  }
643
859
  export declare class ConfigSchemaCollisionError extends Error {
644
860
  readonly plugin: PluginId;
@@ -655,8 +871,15 @@ export declare class ConfigValidationError extends Error {
655
871
  }
656
872
  export declare class ConfigComposer {
657
873
  private readonly inactivePluginInstanceKeys;
658
- constructor(inactivePluginInstanceKeys?: readonly string[]);
874
+ private readonly modules?;
875
+ constructor(inactivePluginInstanceKeys?: readonly string[], modules?: Pick<ModuleRuntime, "load"> | undefined);
659
876
  compose(graph: ProjectGraph, input?: RuntimeConfigDocument,
660
877
  /** Source config file name, annotated on ConfigValidationError. */
661
878
  source?: string): Promise<ComposedConfig>;
662
879
  }
880
+ /** Reuses Runtime validation/defaults without mutating the caller's configuration. */
881
+ export declare function parsePluginConfigSchema(schema: JsonSchema, input: unknown): Readonly<{
882
+ value: unknown;
883
+ errors: readonly ErrorObject[];
884
+ }>;
885
+ 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,11 +47,11 @@ 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 /
@@ -74,17 +75,7 @@ export class ConfigComposer {
74
75
  },
75
76
  });
76
77
  const document = structuredClone(input);
77
- const ajv = new Ajv2020({
78
- allErrors: true,
79
- useDefaults: true,
80
- strict: true,
81
- // 当前内置 schema 无需 union(log_level 的 ['string','number'] 是 type 数组,
82
- // 不触发 allowUnionTypes);保留此项是面向未来插件 schema 可能出现的
83
- // anyOf/oneOf 标量 union,避免届时 Ajv strict 模式直接报错。
84
- allowUnionTypes: true,
85
- });
86
- ajv.addKeyword({ keyword: 'x-descriptionZh', schemaType: 'string' });
87
- ajv.addKeyword({ keyword: 'x-keyPlaceholder', schemaType: 'string' });
78
+ const ajv = configurationValidator();
88
79
  const validate = ajv.compile(effectiveSchema);
89
80
  if (!validate(document)) {
90
81
  throw new ConfigValidationError(formatErrors(validate.errors ?? []), source);
@@ -98,6 +89,7 @@ export class ConfigComposer {
98
89
  effectiveSchema,
99
90
  document: Object.freeze(document),
100
91
  views,
92
+ ownSchemas,
101
93
  });
102
94
  }
103
95
  }
@@ -153,8 +145,8 @@ function injectFrameworkRoleSchema(properties) {
153
145
  }),
154
146
  });
155
147
  }
156
- async function composeNode(node, ownSchemas) {
157
- const own = await readOwnSchema(node);
148
+ async function composeNode(node, ownSchemas, modules) {
149
+ const own = await readOwnSchema(node, modules);
158
150
  ownSchemas.set(node.id, own);
159
151
  const properties = { ...schemaProperties(own) };
160
152
  // Adapter plugins (schemas declaring array-typed `endpoints`) get
@@ -167,24 +159,14 @@ async function composeNode(node, ownSchemas) {
167
159
  if (Object.hasOwn(properties, child.instanceKey)) {
168
160
  throw new ConfigSchemaCollisionError(node.id, child.instanceKey);
169
161
  }
170
- properties[child.instanceKey] = withDefault(await composeNode(child, ownSchemas));
162
+ properties[child.instanceKey] = withDefault(await composeNode(child, ownSchemas, modules));
171
163
  }
172
164
  return Object.freeze({ ...own, properties });
173
165
  }
174
- async function readOwnSchema(node) {
175
- const file = join(node.package.root, 'schema.json');
176
- let value;
177
- try {
178
- value = JSON.parse(await readFile(file, 'utf8'));
179
- }
180
- catch (error) {
181
- if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
182
- value = { type: 'object', additionalProperties: false, properties: {} };
183
- }
184
- else {
185
- throw error;
186
- }
187
- }
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: {} };
188
170
  if (!value || typeof value !== 'object' || Array.isArray(value)) {
189
171
  throw new TypeError(`${file} must contain a JSON Schema object`);
190
172
  }
@@ -204,7 +186,7 @@ async function readOwnSchema(node) {
204
186
  if (Object.hasOwn(properties, 'commandNamespace')) {
205
187
  throw new ConfigSchemaCollisionError(node.id, 'commandNamespace');
206
188
  }
207
- return Object.freeze({
189
+ return deepFreeze({
208
190
  $id: `urn:zhin:plugin-config:${encodeURIComponent(String(node.id))}`,
209
191
  type: 'object',
210
192
  additionalProperties: false,
@@ -275,3 +257,32 @@ function formatErrors(errors) {
275
257
  return base;
276
258
  });
277
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() {
@@ -121,8 +121,22 @@
121
121
  "groupMode": {
122
122
  "type": "string",
123
123
  "enum": ["supersede", "fifo"],
124
- "description": "Replace an older queued group turn, or process all turns in arrival order.",
125
- "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": "同一会话中允许排在当前回合之后的回合数;超出时回复一句“正在忙”。"
126
140
  }
127
141
  }
128
142
  },
@@ -138,10 +152,85 @@
138
152
  "description": "Command allowlist preset used outside full mode.",
139
153
  "x-descriptionZh": "非 full 模式使用的命令白名单预设。"
140
154
  },
155
+ "network": {
156
+ "type": "object",
157
+ "additionalProperties": false,
158
+ "description": "Network access for IM turns (web_fetch / web_search and networked commands). Off by default; enabled allows HTTPS egress, optionally limited to allowedDomains. When enabled is unset, execPreset: network still enables it for compatibility.",
159
+ "x-descriptionZh": "IM 回合的出网权限(web_fetch / web_search 与联网命令)。默认关闭;enabled 开启 HTTPS 出网,allowedDomains 非空时只允许这些域名。未设置 enabled 时兼容旧配置:execPreset: network 视为开启。",
160
+ "properties": {
161
+ "enabled": {
162
+ "type": "boolean",
163
+ "default": false,
164
+ "description": "Allow HTTPS egress for IM turns.",
165
+ "x-descriptionZh": "是否允许 IM 回合 HTTPS 出网。"
166
+ },
167
+ "allowedDomains": {
168
+ "type": "array",
169
+ "items": { "type": "string" },
170
+ "description": "When non-empty, only these domains (and their subdomains) are reachable.",
171
+ "x-descriptionZh": "非空时只允许访问这些域名(含子域名)。"
172
+ }
173
+ }
174
+ },
175
+ "masterShell": {
176
+ "type": "string",
177
+ "enum": ["off", "isolated"],
178
+ "default": "off",
179
+ "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.",
180
+ "x-descriptionZh": "IM 私聊中 master 的 Shell 权限。off:IM 会话不提供 bash;isolated:master 私聊在 Docker 容器内执行 bash(工作区可写、无网络、development 白名单、其余命令需审批),宿主无 Docker 时直接失败。"
181
+ },
182
+ "toolBudget": {
183
+ "type": "object",
184
+ "description": "Progress-aware tool-call budget. maxIterations is a soft budget extended while the turn makes progress; hard caps always apply.",
185
+ "x-descriptionZh": "进度感知的工具调用预算。maxIterations 为软上限,回合有进展时自动延长;硬上限始终生效。",
186
+ "properties": {
187
+ "extendBy": { "type": "integer", "minimum": 0, "default": 10, "description": "Steps added per extension when progressing; 0 disables.", "x-descriptionZh": "有进展时每次延长的步数;0 关闭。" },
188
+ "progressWindow": { "type": "integer", "minimum": 0, "default": 5, "description": "Progress must have happened within this many recent steps.", "x-descriptionZh": "最近多少步内有进展才延长。" },
189
+ "maxTotalIterations": { "type": "integer", "minimum": 0, "default": 40, "description": "Hard step cap per turn segment.", "x-descriptionZh": "每段回合的硬步数上限。" },
190
+ "maxSubagentTotalIterations": { "type": "integer", "minimum": 0, "default": 30, "description": "Hard step cap for an in-turn sub-agent.", "x-descriptionZh": "回合内子 Agent 的硬步数上限。" },
191
+ "maxWallClockMs": { "type": "integer", "minimum": 0, "default": 600000, "description": "Wall-clock cap for the whole turn in ms; 0 disables.", "x-descriptionZh": "整个回合的时间上限(毫秒);0 关闭。" },
192
+ "maxTotalTokens": { "type": "integer", "minimum": 0, "default": 0, "description": "Token cap for the whole turn; 0 disables.", "x-descriptionZh": "整个回合的 Token 上限;0 关闭。" },
193
+ "failureStreakLimit": { "type": "integer", "minimum": 0, "default": 3, "description": "Consecutive tool failures that stop extensions.", "x-descriptionZh": "连续失败多少次后不再延长。" },
194
+ "repeatLimit": { "type": "integer", "minimum": 0, "default": 2, "description": "Consecutive identical repeated calls that stop extensions.", "x-descriptionZh": "连续重复相同调用多少次后不再延长。" },
195
+ "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 总是。续跑沿用同一回合的策略与审批。" },
196
+ "maxContinuations": { "type": "integer", "minimum": 0, "default": 2, "description": "Maximum automatic continuations per turn.", "x-descriptionZh": "每回合最多自动续跑次数。" },
197
+ "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 包括群聊(群聊需显式开启)。" },
198
+ "progressMinIntervalMs": { "type": "integer", "minimum": 0, "default": 45000, "description": "Minimum time between progress updates in ms.", "x-descriptionZh": "两次进度更新的最小间隔(毫秒)。" },
199
+ "progressMaxUpdates": { "type": "integer", "minimum": 0, "default": 3, "description": "Maximum progress updates per turn; 0 disables.", "x-descriptionZh": "每回合最多进度更新次数;0 关闭。" }
200
+ }
201
+ },
202
+ "injectionGuard": {
203
+ "type": "object",
204
+ "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.",
205
+ "x-descriptionZh": "注入防护。外部内容(工具结果、网页、文件、子 agent 报告、群聊背景消息)进入回合后,高危动作(执行命令、向其他会话发消息、改配置、定时任务)必须审批;出站回复检查系统提示、密钥与其他会话记忆泄露。",
206
+ "properties": {
207
+ "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:关闭。" },
208
+ "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:关闭。" },
209
+ "trustedOutputTools": { "type": "array", "items": { "type": "string" }, "default": [], "description": "Tools whose results do not count as external content.", "x-descriptionZh": "结果不算外部内容的工具。" }
210
+ }
211
+ },
212
+ "usageQuota": {
213
+ "type": "object",
214
+ "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.",
215
+ "x-descriptionZh": "按用户 / 按群的 token 配额与群速率限制。默认全部关闭(0),主人默认不受限;计数保存在内存中。",
216
+ "properties": {
217
+ "windowMs": { "type": "integer", "minimum": 60000, "default": 86400000, "description": "Quota window in ms.", "x-descriptionZh": "配额周期(毫秒)。" },
218
+ "perUserTokens": { "type": "integer", "minimum": 0, "default": 0, "description": "Tokens one user may use per window; 0 = unlimited.", "x-descriptionZh": "每个用户每周期可用的 token;0 不限。" },
219
+ "perGroupTokens": { "type": "integer", "minimum": 0, "default": 0, "description": "Tokens one group may use per window; 0 = unlimited.", "x-descriptionZh": "每个群每周期可用的 token;0 不限。" },
220
+ "perGroupRequestsPerMinute": { "type": "integer", "minimum": 0, "default": 0, "description": "Turns one group may start per minute; 0 = unlimited.", "x-descriptionZh": "每个群每分钟可发起的回合数;0 不限。" },
221
+ "exemptOwner": { "type": "boolean", "default": true, "description": "The owner is never limited.", "x-descriptionZh": "主人不受限制。" }
222
+ }
223
+ },
224
+ "subagentTurnMode": {
225
+ "type": "string",
226
+ "enum": ["off", "sync"],
227
+ "default": "off",
228
+ "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.",
229
+ "x-descriptionZh": "IM 回合中的子 Agent。off:不提供 spawn_task;sync:spawn_task 在当前回合内同步运行子 Agent,权限不超过父回合(父回合不能运行 bash 时子 Agent 也没有),审批发往同一会话。"
230
+ },
141
231
  "execApprovalMode": { "type": "string", "enum": ["ask", "bypass", "auto"], "description": "Approval policy for main Agent commands.", "x-descriptionZh": "主 Agent 命令的审批策略。" },
142
232
  "subagentExecApprovalMode": { "type": "string", "enum": ["ask", "bypass", "auto"], "description": "Approval policy for sub-Agent commands.", "x-descriptionZh": "子 Agent 命令的审批策略。" },
143
233
  "workerExecApprovalMode": { "type": "string", "enum": ["ask", "bypass", "auto"], "description": "Approval policy for worker commands.", "x-descriptionZh": "Worker 命令的审批策略。" },
144
- "taskExecApprovalMode": { "type": "string", "enum": ["ask", "bypass", "auto"], "description": "Approval policy for task commands.", "x-descriptionZh": "Task 命令的审批策略。" },
145
234
  "toolExecution": {
146
235
  "type": "string",
147
236
  "enum": ["parallel", "sequential", "tiered"],
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';
@@ -0,0 +1,5 @@
1
+ export declare const TOOL_PACKAGE_INSTALL_HINT = "\u7F3A\u5C11\u4F9D\u8D56 @zhin.js/tool\uFF08tools/ \u4E0B\u7684 defineAgentTool \u9700\u8981\u5B83\uFF09\u3002\u8BF7\u8FD0\u884C: pnpm add @zhin.js/tool";
2
+ /** True only for ERR_MODULE_NOT_FOUND naming the `@zhin.js/tool` package root. */
3
+ export declare function isToolPackageMissing(error: unknown): boolean;
4
+ /** Return an error that carries the exact install command when `@zhin.js/tool` is missing. */
5
+ export declare function withToolPackageInstallHint(error: unknown): unknown;
@@ -0,0 +1,21 @@
1
+ const TOOL_PACKAGE = '@zhin.js/tool';
2
+ const TOOL_ROOT_SPECIFIER = /['"]@zhin\.js\/tool['"]/u;
3
+ export const TOOL_PACKAGE_INSTALL_HINT = `缺少依赖 ${TOOL_PACKAGE}(tools/ 下的 defineAgentTool 需要它)。请运行: pnpm add ${TOOL_PACKAGE}`;
4
+ /** True only for ERR_MODULE_NOT_FOUND naming the `@zhin.js/tool` package root. */
5
+ export function isToolPackageMissing(error) {
6
+ if (!error || typeof error !== 'object')
7
+ return false;
8
+ const { code, message } = error;
9
+ return code === 'ERR_MODULE_NOT_FOUND'
10
+ && typeof message === 'string'
11
+ && TOOL_ROOT_SPECIFIER.test(message);
12
+ }
13
+ /** Return an error that carries the exact install command when `@zhin.js/tool` is missing. */
14
+ export function withToolPackageInstallHint(error) {
15
+ if (!isToolPackageMissing(error))
16
+ return error;
17
+ const original = error;
18
+ const augmented = new Error(`${TOOL_PACKAGE_INSTALL_HINT}\n原始错误: ${original.message}`, { cause: error });
19
+ augmented.code = original.code;
20
+ return augmented;
21
+ }
@@ -3,6 +3,7 @@ import { readdirSync, statSync, watch as watchDirectory, } from 'node:fs';
3
3
  import { basename, extname, isAbsolute, relative, resolve, sep } from 'node:path';
4
4
  import { pathToFileURL } from 'node:url';
5
5
  import { ModuleDependencyIndex, } from './module-dependency-index.js';
6
+ import { withToolPackageInstallHint } from './missing-tool-package.js';
6
7
  import { ensureTypeScriptSpecifierRemap } from './typescript-specifier-remap.js';
7
8
  const ignoredDirectories = new Set([
8
9
  '.git', '.zhin', 'coverage', 'data', 'dist', 'lib', 'node_modules',
@@ -27,6 +28,7 @@ export class NativeDevelopmentModuleRuntime {
27
28
  #pendingDependencies;
28
29
  #watchRoots;
29
30
  #revision = 0;
31
+ #entryRevisions = new Map();
30
32
  #closed = false;
31
33
  constructor(options) {
32
34
  ensureTypeScriptSpecifierRemap();
@@ -43,16 +45,23 @@ export class NativeDevelopmentModuleRuntime {
43
45
  const analysis = this.#pendingDependencies?.get(normalized)
44
46
  ?? await this.#dependencies.analyze(normalized);
45
47
  const url = pathToFileURL(normalized);
46
- url.searchParams.set('zhin-generation', String(this.#revision));
48
+ if (!this.#entryRevisions.has(normalized))
49
+ this.#entryRevisions.set(normalized, this.#revision);
50
+ url.searchParams.set('zhin-generation', String(this.#entryRevisions.get(normalized)));
47
51
  let loaded;
48
52
  try {
49
53
  loaded = await import(url.href);
50
54
  }
51
55
  catch (error) {
52
56
  if (!normalized.endsWith('.tsx') || !isUnsupportedTsxError(error) || !this.#tsxLoader) {
53
- throw error;
57
+ throw withToolPackageInstallHint(error);
58
+ }
59
+ try {
60
+ loaded = await this.#tsxLoader.load(url.href, pathToFileURL(`${this.#projectRoot}${sep}`).href);
61
+ }
62
+ catch (tsxError) {
63
+ throw withToolPackageInstallHint(tsxError);
54
64
  }
55
- loaded = await this.#tsxLoader.load(url.href, pathToFileURL(`${this.#projectRoot}${sep}`).href);
56
65
  }
57
66
  if (this.#pendingDependencies)
58
67
  this.#pendingDependencies.set(normalized, analysis);
@@ -61,8 +70,12 @@ export class NativeDevelopmentModuleRuntime {
61
70
  return loaded;
62
71
  }
63
72
  invalidate(source) {
73
+ const impacted = this.#dependencies.affectedSources(source);
64
74
  this.#dependencies.invalidate(source);
65
75
  this.#revision += 1;
76
+ for (const entry of new Set([normalizeSourcePath(source), ...impacted])) {
77
+ this.#entryRevisions.set(entry, this.#revision);
78
+ }
66
79
  }
67
80
  affectedSources(source) {
68
81
  return this.#dependencies.affectedSources(source);
@@ -86,6 +99,11 @@ export class NativeDevelopmentModuleRuntime {
86
99
  // complete project-local import closure.
87
100
  if (this.affectedSources(normalized).length > 1)
88
101
  return false;
102
+ // An already loaded ESM entry has the same revisioned import closure as a
103
+ // capability entry. Its manifest may name plugin.ts or any other filename;
104
+ // Runtime ownership, rather than directory spelling, chooses its reload.
105
+ if (this.#entryRevisions.has(normalized) && isExecutableSource(normalized))
106
+ return false;
89
107
  const parts = relative(packageRoot, normalized).split(sep);
90
108
  const capability = parts.findIndex((part) => capabilityRoots.has(part));
91
109
  if (capability < 0)
@@ -134,6 +152,11 @@ export class NativeDevelopmentModuleRuntime {
134
152
  }
135
153
  this.#pendingDependencies = undefined;
136
154
  this.#dependencies.retain(sources);
155
+ const retained = new Set(sources.map(normalizeSourcePath));
156
+ for (const entry of this.#entryRevisions.keys()) {
157
+ if (!retained.has(entry))
158
+ this.#entryRevisions.delete(entry);
159
+ }
137
160
  }
138
161
  rollbackGeneration() {
139
162
  this.#pendingDependencies = undefined;
@@ -170,6 +193,7 @@ export class NativeDevelopmentModuleRuntime {
170
193
  this.#watchers.clear();
171
194
  this.#pendingDependencies = undefined;
172
195
  this.#dependencies.clear();
196
+ this.#entryRevisions.clear();
173
197
  await this.#tsxLoader?.close?.();
174
198
  }
175
199
  #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.5",
3
+ "version": "1.1.7",
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",
@@ -8,7 +8,6 @@
8
8
  "exports": {
9
9
  ".": {
10
10
  "types": "./lib/index.d.ts",
11
- "development": "./src/index.ts",
12
11
  "import": "./lib/index.js"
13
12
  }
14
13
  },
@@ -20,24 +19,25 @@
20
19
  "es-module-lexer": "2.3.1",
21
20
  "import-meta-resolve": "4.2.0",
22
21
  "semver": "7.8.5",
23
- "@zhin.js/plugin-runtime": "1.1.12",
24
- "@zhin.js/feature-kit": "1.1.3"
22
+ "@zhin.js/feature-kit": "1.1.5",
23
+ "@zhin.js/plugin-runtime": "1.1.14"
25
24
  },
26
25
  "devDependencies": {
27
26
  "@types/node": "^26.1.2",
28
27
  "tsx": "^4.23.1",
29
28
  "typescript": "^6.0.3",
30
- "@zhin.js/adapter": "1.1.17",
31
- "@zhin.js/agent-feature": "1.1.4",
32
- "@zhin.js/command": "1.1.5",
33
- "@zhin.js/component": "1.1.4",
34
- "@zhin.js/layout": "1.1.4",
35
- "@zhin.js/mcp-feature": "1.1.4",
36
- "@zhin.js/middleware": "1.1.4",
37
- "@zhin.js/page": "1.1.4",
38
- "@zhin.js/prompt-section": "1.1.3",
39
- "@zhin.js/skill": "1.1.4",
40
- "@zhin.js/tool": "1.1.4"
29
+ "@zhin.js/agent-feature": "1.1.6",
30
+ "@zhin.js/command": "1.1.7",
31
+ "@zhin.js/adapter": "1.1.19",
32
+ "@zhin.js/component": "1.1.6",
33
+ "@zhin.js/handler": "1.1.5",
34
+ "@zhin.js/layout": "1.1.6",
35
+ "@zhin.js/mcp-feature": "1.1.6",
36
+ "@zhin.js/middleware": "1.1.6",
37
+ "@zhin.js/page": "1.1.6",
38
+ "@zhin.js/prompt-section": "1.1.5",
39
+ "@zhin.js/skill": "1.1.6",
40
+ "@zhin.js/tool": "1.1.6"
41
41
  },
42
42
  "repository": {
43
43
  "type": "git",
@@ -49,7 +49,7 @@
49
49
  "registry": "https://registry.npmjs.org"
50
50
  },
51
51
  "engines": {
52
- "node": "^20.19.0 || >=22.12.0"
52
+ "node": ">=22.19.0"
53
53
  },
54
54
  "license": "MIT",
55
55
  "scripts": {