@alisio/core 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/dist/application.d.ts +7 -0
  2. package/dist/application.js +90 -6
  3. package/dist/changelog/data.js +36 -0
  4. package/dist/commands/builtins.js +6 -0
  5. package/dist/commands/catalog.d.ts +8 -1
  6. package/dist/commands/catalog.js +62 -0
  7. package/dist/config.d.ts +37 -3
  8. package/dist/config.js +109 -7
  9. package/dist/core/contracts.d.ts +10 -1
  10. package/dist/core/human-wait.d.ts +25 -0
  11. package/dist/core/human-wait.js +54 -0
  12. package/dist/core/run-clock.d.ts +47 -0
  13. package/dist/core/run-clock.js +111 -0
  14. package/dist/core/run-stats.d.ts +2 -0
  15. package/dist/core/runner.d.ts +15 -1
  16. package/dist/core/runner.js +70 -18
  17. package/dist/core/timeout.js +2 -2
  18. package/dist/decisions/bound.d.ts +23 -0
  19. package/dist/decisions/bound.js +48 -0
  20. package/dist/decisions/breaker.d.ts +35 -0
  21. package/dist/decisions/breaker.js +61 -0
  22. package/dist/decisions/format.d.ts +15 -0
  23. package/dist/decisions/format.js +89 -0
  24. package/dist/decisions/index.d.ts +7 -0
  25. package/dist/decisions/index.js +7 -0
  26. package/dist/decisions/metrics.d.ts +45 -0
  27. package/dist/decisions/metrics.js +79 -0
  28. package/dist/decisions/registry.d.ts +22 -0
  29. package/dist/decisions/registry.js +64 -0
  30. package/dist/decisions/service.d.ts +137 -0
  31. package/dist/decisions/service.js +377 -0
  32. package/dist/decisions/validate.d.ts +26 -0
  33. package/dist/decisions/validate.js +182 -0
  34. package/dist/goal/host.d.ts +2 -0
  35. package/dist/goal/host.js +2 -1
  36. package/dist/index.d.ts +5 -2
  37. package/dist/index.js +3 -0
  38. package/dist/plugins/host.d.ts +43 -2
  39. package/dist/plugins/host.js +111 -16
  40. package/package.json +2 -2
@@ -14,6 +14,7 @@ import { type SettableSettingKey } from "./config.ts";
14
14
  import type { ApprovalHandler } from "./core/contracts.ts";
15
15
  import { ToolRegistry } from "./core/registry.ts";
16
16
  import { AgentRunner } from "./core/runner.ts";
17
+ import { DecisionMetrics, DecisionRegistry, DecisionService } from "./decisions/index.ts";
17
18
  import { GoalService } from "./goal/service.ts";
18
19
  import { HerdrBridge } from "./integrations/herdr.ts";
19
20
  import { McpConnector } from "./mcp/connector.ts";
@@ -198,6 +199,12 @@ export declare function createApplication(options?: AppOptions): Promise<{
198
199
  /** Tabular datasets of every session (ingestion, read-only queries, pages). */
199
200
  datasets: DatasetService;
200
201
  registry: ToolRegistry;
202
+ /** Decision Intelligence: the provider catalog, the service and in-memory metrics. */
203
+ decisions: {
204
+ registry: DecisionRegistry;
205
+ service: DecisionService;
206
+ metrics: DecisionMetrics;
207
+ };
201
208
  context: ProjectContext;
202
209
  skills: Skills;
203
210
  /** Path- and content-safe metadata for the interactive skills manager. */
@@ -16,9 +16,11 @@ import { subagentTasks } from "./background/mirror.js";
16
16
  import { BackgroundTasks } from "./background/service.js";
17
17
  import { configFile, configHome, loadConfigWithProvenance, overridesSavedProviderProfile, setConfigValue, setGlobalMcpAllow, setMcpServerEnabled, setProjectPluginEnabled, setProjectSkillEnabled, stateHome, } from "./config.js";
18
18
  import { completeText } from "./core/compaction.js";
19
+ import { HumanWaits } from "./core/human-wait.js";
19
20
  import { DEFAULT_MAX_OUTPUT_TOKENS } from "./core/output-limit.js";
20
21
  import { ToolRegistry } from "./core/registry.js";
21
22
  import { AgentRunner } from "./core/runner.js";
23
+ import { bindDecisions, DecisionMetrics, DecisionRegistry, DecisionService, } from "./decisions/index.js";
22
24
  import { GoalService } from "./goal/service.js";
23
25
  import { GoalStore } from "./goal/store.js";
24
26
  import { HerdrBridge } from "./integrations/herdr.js";
@@ -86,11 +88,25 @@ export async function createApplication(options = {}) {
86
88
  const addDirs = options.readOnly
87
89
  ? []
88
90
  : await Promise.all((options.addDirs ?? []).map((dir) => realpath(resolve(cwd, dir)).catch(() => resolve(cwd, dir))));
91
+ // Waits for a person (approvals, questions, the plan review) do not count against the run's
92
+ // time limit. One registry serves the runner, the plugin host's interactive UI and the
93
+ // external-directory approval, so every host (TUI, web server, embedders) gets it for free.
94
+ const humanWaits = new HumanWaits((session) => {
95
+ try {
96
+ return store.get(session).parentId ?? undefined;
97
+ }
98
+ catch {
99
+ return undefined;
100
+ }
101
+ });
102
+ const askExternalDirectory = options.approveExternalDirectory;
89
103
  const pathAccess = new PathAccess({
90
104
  workspace,
91
105
  extraRoots: [...config.additionalDirectories, ...addDirs],
92
- ...(options.approveExternalDirectory && !options.readOnly
93
- ? { approve: options.approveExternalDirectory }
106
+ ...(askExternalDirectory && !options.readOnly
107
+ ? {
108
+ approve: (request) => humanWaits.track(request.session, () => askExternalDirectory(request)),
109
+ }
94
110
  : {}),
95
111
  readOnly: !!options.readOnly,
96
112
  });
@@ -107,7 +123,19 @@ export async function createApplication(options = {}) {
107
123
  cwd,
108
124
  claudeMdFallback: config.context.claudeMdFallback,
109
125
  maxBytes: config.context.maxBytes,
110
- }), providers = new ProviderRegistry(), plugins = new PluginHost(registry, store, config.pluginHooks, providers), mcp = new McpConnector(config.mcp.servers, registry, workspace, options.readOnly ? "read-only" : options.allowMcp || mcpAllow ? "allowed" : "disabled", config.mcpSources);
126
+ }), providers = new ProviderRegistry(), plugins = new PluginHost(registry, store, {
127
+ hookTimeoutMs: config.pluginHooks.timeoutMs,
128
+ sessionEndTimeoutMs: config.pluginHooks.sessionEndTimeoutMs,
129
+ disposeTimeoutMs: config.pluginHooks.disposeTimeoutMs,
130
+ }, providers), decisionRegistry = new DecisionRegistry(), decisionMetrics = new DecisionMetrics(), decisionService = new DecisionService({
131
+ registry: decisionRegistry,
132
+ // Read per call: `updateSetting("decisions.*")` mutates `config.decisions`.
133
+ config: () => config.decisions,
134
+ metrics: decisionMetrics,
135
+ // Failures never reach the terminal (they would garble the TUI): they are kept as
136
+ // `lastLifecycleError` in the service status.
137
+ lifecycle: { timeoutMs: () => config.pluginHooks.timeoutMs },
138
+ }), mcp = new McpConnector(config.mcp.servers, registry, workspace, options.readOnly ? "read-only" : options.allowMcp || mcpAllow ? "allowed" : "disabled", config.mcpSources);
111
139
  let mcpRuntimePermission = options.readOnly
112
140
  ? "read-only"
113
141
  : options.allowMcp || mcpAllow
@@ -116,6 +144,20 @@ export async function createApplication(options = {}) {
116
144
  const herdr = options.noHerdr
117
145
  ? new HerdrBridge({}, async () => "")
118
146
  : HerdrBridge.fromEnvironment(workspace, (error) => process.stderr.write(`${error}\n`));
147
+ // Decision providers and per-plugin directories/options are bound before any plugin loads.
148
+ plugins.setDecisions({ registry: decisionRegistry, service: decisionService });
149
+ const stateRoot = options.db && options.db !== ":memory:" ? dirname(options.db) : stateHome();
150
+ plugins.setPluginEnvironment({
151
+ stateRoot,
152
+ configHome: configHome(),
153
+ options: (id, builtin) => {
154
+ if (builtin) {
155
+ const { enabled: _enabled, ...rest } = config.builtinPlugins[id] ?? {};
156
+ return rest;
157
+ }
158
+ return config.pluginOverrides[id]?.options ?? {};
159
+ },
160
+ });
119
161
  try {
120
162
  // First-party built-ins load through the trusted path, also under --read-only.
121
163
  const builtinContext = {
@@ -264,6 +306,9 @@ export async function createApplication(options = {}) {
264
306
  }
265
307
  }
266
308
  }
309
+ // Every plugin has registered its providers: start the configured one in the background
310
+ // (`activate` is bounded, never fatal and never awaited here).
311
+ decisionService.syncActive();
267
312
  // Project skills follow the config trust model (--trust-project or an explicit --config).
268
313
  await skills.discover(skillRoots({
269
314
  workspace,
@@ -285,7 +330,6 @@ export async function createApplication(options = {}) {
285
330
  resolveExtension: () => plugins.extensions.resolve("websearch"),
286
331
  }, pathAccess);
287
332
  // Python analysis and artifacts live next to the session database (like attachment blobs).
288
- const stateRoot = options.db && options.db !== ":memory:" ? dirname(options.db) : stateHome();
289
333
  const artifacts = new ArtifactStore({
290
334
  root: stateRoot,
291
335
  db: store.db,
@@ -730,7 +774,9 @@ export async function createApplication(options = {}) {
730
774
  : [];
731
775
  return declared.length ? Math.min(...declared) : undefined;
732
776
  };
777
+ plugins.setHumanWaits(humanWaits);
733
778
  const runner = new AgentRunner({
779
+ humanWaits,
734
780
  provider,
735
781
  providerFor,
736
782
  registry,
@@ -770,6 +816,7 @@ export async function createApplication(options = {}) {
770
816
  },
771
817
  }
772
818
  : {}),
819
+ decisions: (call, announce) => bindDecisions(decisionService, () => config.decisions.telemetry, call, announce),
773
820
  artifacts: (call, announce) => createArtifactPublisher(artifacts, {
774
821
  sessionId: call.sessionId,
775
822
  rootSessionId: store.rootOf(call.sessionId),
@@ -899,6 +946,8 @@ export async function createApplication(options = {}) {
899
946
  /** Tabular datasets of every session (ingestion, read-only queries, pages). */
900
947
  datasets,
901
948
  registry,
949
+ /** Decision Intelligence: the provider catalog, the service and in-memory metrics. */
950
+ decisions: { registry: decisionRegistry, service: decisionService, metrics: decisionMetrics },
902
951
  context,
903
952
  skills,
904
953
  /** Path- and content-safe metadata for the interactive skills manager. */
@@ -1103,6 +1152,33 @@ export async function createApplication(options = {}) {
1103
1152
  config.pluginHooks = { ...config.pluginHooks, timeoutMs: Number(value) };
1104
1153
  plugins.applyTimeoutSettings({ hookTimeoutMs: Number(value) });
1105
1154
  break;
1155
+ case "pluginHooks.disposeTimeoutMs":
1156
+ config.pluginHooks = { ...config.pluginHooks, disposeTimeoutMs: Number(value) };
1157
+ plugins.applyTimeoutSettings({ disposeTimeoutMs: Number(value) });
1158
+ break;
1159
+ case "decisions.enabled":
1160
+ case "decisions.telemetry": {
1161
+ // Read live by the service on its next call; `enabled` also drives the provider's
1162
+ // activate/deactivate lifecycle.
1163
+ const leaf = key.slice("decisions.".length);
1164
+ config.decisions = { ...config.decisions, [leaf]: value === true };
1165
+ decisionService.syncActive();
1166
+ break;
1167
+ }
1168
+ case "decisions.provider":
1169
+ // `!clear` (undefined) deactivates the feature.
1170
+ config.decisions = {
1171
+ ...config.decisions,
1172
+ provider: value === undefined || value === null ? null : String(value),
1173
+ };
1174
+ decisionService.syncActive();
1175
+ break;
1176
+ case "decisions.timeoutMs":
1177
+ case "decisions.minConfidence": {
1178
+ const leaf = key.slice("decisions.".length);
1179
+ config.decisions = { ...config.decisions, [leaf]: Number(value) };
1180
+ break;
1181
+ }
1106
1182
  case "tui.paddingX":
1107
1183
  config.tui = { ...config.tui, paddingX: Number(value) };
1108
1184
  break;
@@ -1357,7 +1433,11 @@ export async function createApplication(options = {}) {
1357
1433
  ...[...runtimes]
1358
1434
  .filter((runtime) => runtime !== provider.currentProvider)
1359
1435
  .map((runtime) => Promise.resolve(runtime.dispose?.())),
1360
- plugins.close(),
1436
+ // The active decision provider stops first (its own limit), then every plugin
1437
+ // is disposed in parallel, each bounded by `pluginHooks.disposeTimeoutMs`.
1438
+ decisionService
1439
+ .shutdown(config.pluginHooks.disposeTimeoutMs)
1440
+ .then(() => plugins.close()),
1361
1441
  ]);
1362
1442
  })());
1363
1443
  }
@@ -1370,7 +1450,11 @@ export async function createApplication(options = {}) {
1370
1450
  }
1371
1451
  catch (e) {
1372
1452
  await Promise.race([
1373
- Promise.allSettled([herdr.close(), mcp.close(), plugins.close()]),
1453
+ Promise.allSettled([
1454
+ herdr.close(),
1455
+ mcp.close(),
1456
+ decisionService.shutdown(config.pluginHooks.disposeTimeoutMs).then(() => plugins.close()),
1457
+ ]),
1374
1458
  delay(TEARDOWN_STAGE_TIMEOUT_MS),
1375
1459
  ]);
1376
1460
  store.close();
@@ -2,6 +2,42 @@
2
2
  export const CHANGELOG_DATA = {
3
3
  schema: 1,
4
4
  entries: [
5
+ {
6
+ version: "0.3.0",
7
+ date: "2026-10-03",
8
+ sections: [
9
+ {
10
+ title: "Added",
11
+ items: [
12
+ "Decision Intelligence: an optional provider contract that lets a plugin answer small, closed decisions (pick an option, yes/no, a level) without a full model generation. Alisio works the same without a provider; installing a provider plugin does not activate it.",
13
+ "`/decisions` (terminal and web) shows whether decisions are enabled, the active provider, its health and the session metrics. `/stats` gains a Decision Intelligence section once a decision has run.",
14
+ "New settings: the `decisions` block (`enabled`, `provider`, `timeoutMs`, `minConfidence`, `telemetry`), plugin options in `pluginOverrides.<id>.options` and `pluginHooks.disposeTimeoutMs`.",
15
+ ],
16
+ },
17
+ {
18
+ title: "Improved",
19
+ items: [
20
+ "Plugins are shut down in parallel, each with its own time limit, so one slow plugin no longer delays the others.",
21
+ "`alisio run` now shuts plugins down cleanly on SIGTERM and SIGHUP, not only on Ctrl+C.",
22
+ ],
23
+ },
24
+ ],
25
+ },
26
+ {
27
+ version: "0.2.1",
28
+ date: "2026-10-02",
29
+ sections: [
30
+ {
31
+ title: "Fixed",
32
+ items: [
33
+ 'A run no longer stops with "reached its 600 s limit" while you read a plan or answer a question. The run time limit (`limits.timeoutMs`) now counts only active time: the time the agent waits for you (the plan review, a question, a tool approval) is not counted. A model or tool that really stalls still hits the limit. The same applies to the `goal.maxMinutes` cap of a session goal.',
34
+ 'A run that ended or was stopped during a plan review no longer leaves the next message failing with an "Uncertain tool outcome" error.',
35
+ "In the web, a plan review or a question can wait up to 60 minutes for your answer (tool approvals still expire after 10 minutes).",
36
+ 'The Settings label of `limits.timeoutMs` said "Provider request timeout"; it now says it is the run time limit.',
37
+ ],
38
+ },
39
+ ],
40
+ },
5
41
  {
6
42
  version: "0.2.0",
7
43
  date: "2026-10-02",
@@ -151,6 +151,12 @@ export const BUILTIN_COMMANDS = [
151
151
  surfaces: INTERACTIVE,
152
152
  execution: "surface",
153
153
  },
154
+ {
155
+ name: "decisions",
156
+ description: "Show Decision Intelligence status, provider health and session metrics",
157
+ surfaces: ALL,
158
+ execution: "core",
159
+ },
154
160
  {
155
161
  name: "exit",
156
162
  description: "Exit Alisio",
@@ -4,10 +4,11 @@
4
4
  * The catalog reads its sources live from the host on each call, so plugin/template/skill
5
5
  * changes show up without rebuilding it.
6
6
  */
7
- import type { CommandDescriptor, ToolDefinition } from "@alisio/sdk";
7
+ import { type CommandDescriptor, type ToolDefinition } from "@alisio/sdk";
8
8
  import type { PluginCatalogEntry } from "../application.ts";
9
9
  import type { SessionStore } from "../core/contracts.ts";
10
10
  import type { AgentRunner } from "../core/runner.ts";
11
+ import { type DecisionMetrics, type DecisionService, type DecisionsConfig } from "../decisions/index.ts";
11
12
  import type { McpServerInfo } from "../mcp/connector.ts";
12
13
  import type { PromptTemplate } from "../resources/prompts.ts";
13
14
  import type { SkillCatalogEntry } from "../resources/skills.ts";
@@ -54,6 +55,12 @@ export interface CommandHost {
54
55
  active?: string;
55
56
  effort?: string;
56
57
  };
58
+ decisions?: Pick<DecisionsConfig, "timeoutMs" | "minConfidence" | "telemetry">;
59
+ };
60
+ /** Decision Intelligence (`/decisions`, and the `/stats` section). */
61
+ decisions?: {
62
+ service: Pick<DecisionService, "status">;
63
+ metrics: Pick<DecisionMetrics, "snapshot">;
57
64
  };
58
65
  updateSetting?(key: "agents.effort", value: unknown): Promise<string>;
59
66
  /** `/btw` side questions (tool-less, outside the conversation). */
@@ -1,4 +1,12 @@
1
+ /**
2
+ * One catalog of slash commands for every surface (TUI, web, API): built-ins, plugin commands,
3
+ * prompt templates and skills, with the pure core handlers of `execution: "core"` commands.
4
+ * The catalog reads its sources live from the host on each call, so plugin/template/skill
5
+ * changes show up without rebuilding it.
6
+ */
7
+ import { summarizeDecisionEvents, } from "@alisio/sdk";
1
8
  import { agentCatalogFromState, agentCommandDescriptors, resolveActiveAgent, } from "../agents/active.js";
9
+ import { formatDecisionStatsSection, formatDecisionsReport, } from "../decisions/index.js";
2
10
  import { formatSideQuestion, SIDE_QUESTION_DESCRIPTION, SIDE_QUESTION_USAGE, } from "../sessions/side-questions.js";
3
11
  import { BUILTIN_COMMANDS } from "./builtins.js";
4
12
  export { BUILTIN_COMMANDS };
@@ -20,7 +28,59 @@ const firstPrompt = (host, id) => {
20
28
  const first = host.store.messages(id).find((m) => m.role === "user" && !m.summary);
21
29
  return first?.role === "user" ? first.text.replace(/\s+/g, " ").slice(0, 60) : "";
22
30
  };
31
+ const EVENT_PAGE = 5000;
32
+ const MAX_EVENT_PAGES = 20;
33
+ /**
34
+ * The session's persisted `decision_*` events as `RunEvent`s (`createdAt` becomes the timestamp,
35
+ * so `lastFallback.at` is right), paged through the store. Empty without a paging store.
36
+ */
37
+ function decisionEvents(host, sessionId) {
38
+ const out = [];
39
+ let after = 0;
40
+ for (let page = 0; page < MAX_EVENT_PAGES; page++) {
41
+ const result = host.store.eventsPage?.(sessionId, { after, limit: EVENT_PAGE });
42
+ if (!result?.items.length)
43
+ break;
44
+ for (const item of result.items) {
45
+ if (item.type !== "decision_completed" && item.type !== "decision_fallback")
46
+ continue;
47
+ out.push({
48
+ schemaVersion: 1,
49
+ runId: item.runId,
50
+ sessionId,
51
+ seq: out.length + 1,
52
+ type: item.type,
53
+ timestamp: new Date(item.createdAt ?? 0).toISOString(),
54
+ data: item.data,
55
+ eventId: item.eventId,
56
+ });
57
+ }
58
+ if (!result.hasMore)
59
+ break;
60
+ after = Number(result.items.at(-1)?.eventId);
61
+ if (!Number.isFinite(after))
62
+ break;
63
+ }
64
+ return out;
65
+ }
23
66
  const HANDLERS = {
67
+ async decisions(_args, ctx, host) {
68
+ const decisions = host.decisions;
69
+ if (!decisions)
70
+ return { text: "Decision Intelligence is unavailable here", tone: "notice" };
71
+ const status = await decisions.service.status(ctx.signal);
72
+ const session = decisions.metrics.snapshot(ctx.sessionId);
73
+ const processView = decisions.metrics.snapshot();
74
+ return {
75
+ text: formatDecisionsReport({
76
+ status,
77
+ config: host.config?.decisions ?? { timeoutMs: 0, minConfidence: 0, telemetry: false },
78
+ session,
79
+ process: processView,
80
+ }),
81
+ data: { status, session, process: processView },
82
+ };
83
+ },
24
84
  async tools(_args, _ctx, host) {
25
85
  const policy = host.runner.policy;
26
86
  const rows = host.registry
@@ -113,6 +173,7 @@ const HANDLERS = {
113
173
  input: sum.input + (r.usage?.input ?? 0),
114
174
  output: sum.output + (r.usage?.output ?? 0),
115
175
  }), { input: 0, output: 0 });
176
+ const decisionSection = formatDecisionStatsSection(summarizeDecisionEvents(decisionEvents(host, ctx.sessionId)));
116
177
  return {
117
178
  text: [
118
179
  "**Session statistics**",
@@ -122,6 +183,7 @@ const HANDLERS = {
122
183
  `- Messages: ${host.store.messages(session.id).length}`,
123
184
  `- Runs: ${runs.length}`,
124
185
  `- Tokens: in ${tokens.input} · out ${tokens.output}`,
186
+ ...(decisionSection ? ["", decisionSection] : []),
125
187
  ].join("\n"),
126
188
  data: { session, runs: runs.length, tokens },
127
189
  };
package/dist/config.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { JsonValue } from "@alisio/sdk";
1
2
  import { z } from "zod";
2
3
  declare const serverSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
3
4
  transport: z.ZodLiteral<"stdio">;
@@ -142,11 +143,20 @@ export declare const configSchema: z.ZodPipe<z.ZodObject<{
142
143
  enabled: z.ZodOptional<z.ZodBoolean>;
143
144
  }, z.core.$loose>>>;
144
145
  pluginOverrides: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodObject<{
145
- enabled: z.ZodBoolean;
146
+ enabled: z.ZodOptional<z.ZodBoolean>;
147
+ options: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodType<JsonValue, unknown, z.core.$ZodTypeInternals<JsonValue, unknown>>>>;
146
148
  }, z.core.$strict>>>;
147
149
  pluginHooks: z.ZodDefault<z.ZodObject<{
148
150
  timeoutMs: z.ZodDefault<z.ZodNumber>;
149
151
  sessionEndTimeoutMs: z.ZodDefault<z.ZodNumber>;
152
+ disposeTimeoutMs: z.ZodDefault<z.ZodNumber>;
153
+ }, z.core.$strict>>;
154
+ decisions: z.ZodDefault<z.ZodObject<{
155
+ enabled: z.ZodDefault<z.ZodBoolean>;
156
+ provider: z.ZodDefault<z.ZodNullable<z.ZodString>>;
157
+ timeoutMs: z.ZodDefault<z.ZodNumber>;
158
+ minConfidence: z.ZodDefault<z.ZodNumber>;
159
+ telemetry: z.ZodDefault<z.ZodBoolean>;
150
160
  }, z.core.$strict>>;
151
161
  context: z.ZodDefault<z.ZodObject<{
152
162
  claudeMdFallback: z.ZodDefault<z.ZodBoolean>;
@@ -267,11 +277,20 @@ export declare const configSchema: z.ZodPipe<z.ZodObject<{
267
277
  enabled?: boolean | undefined;
268
278
  }>;
269
279
  pluginOverrides: Record<string, {
270
- enabled: boolean;
280
+ enabled?: boolean | undefined;
281
+ options?: Record<string, JsonValue> | undefined;
271
282
  }>;
272
283
  pluginHooks: {
273
284
  timeoutMs: number;
274
285
  sessionEndTimeoutMs: number;
286
+ disposeTimeoutMs: number;
287
+ };
288
+ decisions: {
289
+ enabled: boolean;
290
+ provider: string | null;
291
+ timeoutMs: number;
292
+ minConfidence: number;
293
+ telemetry: boolean;
275
294
  };
276
295
  context: {
277
296
  claudeMdFallback: boolean;
@@ -461,11 +480,20 @@ export declare const configSchema: z.ZodPipe<z.ZodObject<{
461
480
  enabled?: boolean | undefined;
462
481
  }>;
463
482
  pluginOverrides: Record<string, {
464
- enabled: boolean;
483
+ enabled?: boolean | undefined;
484
+ options?: Record<string, JsonValue> | undefined;
465
485
  }>;
466
486
  pluginHooks: {
467
487
  timeoutMs: number;
468
488
  sessionEndTimeoutMs: number;
489
+ disposeTimeoutMs: number;
490
+ };
491
+ decisions: {
492
+ enabled: boolean;
493
+ provider: string | null;
494
+ timeoutMs: number;
495
+ minConfidence: number;
496
+ telemetry: boolean;
469
497
  };
470
498
  context: {
471
499
  claudeMdFallback: boolean;
@@ -625,6 +653,12 @@ declare const SETTABLE_KEYS: {
625
653
  readonly "limits.firstTokenRetries": z.ZodDefault<z.ZodNumber>;
626
654
  readonly "limits.truncationRecoveries": z.ZodDefault<z.ZodNumber>;
627
655
  readonly "pluginHooks.timeoutMs": z.ZodDefault<z.ZodNumber>;
656
+ readonly "pluginHooks.disposeTimeoutMs": z.ZodDefault<z.ZodNumber>;
657
+ readonly "decisions.enabled": z.ZodDefault<z.ZodBoolean>;
658
+ readonly "decisions.provider": z.ZodDefault<z.ZodNullable<z.ZodString>>;
659
+ readonly "decisions.timeoutMs": z.ZodDefault<z.ZodNumber>;
660
+ readonly "decisions.minConfidence": z.ZodDefault<z.ZodNumber>;
661
+ readonly "decisions.telemetry": z.ZodDefault<z.ZodBoolean>;
628
662
  readonly "tui.paddingX": z.ZodDefault<z.ZodNumber>;
629
663
  readonly "tui.contentPaddingX": z.ZodDefault<z.ZodNumber>;
630
664
  readonly "tui.skillSlashCommands": z.ZodDefault<z.ZodBoolean>;
package/dist/config.js CHANGED
@@ -2,6 +2,7 @@ import { chmod, mkdir, open, readFile, realpath, rename, unlink } from "node:fs/
2
2
  import { homedir } from "node:os";
3
3
  import { dirname, join, resolve } from "node:path";
4
4
  import { z } from "zod";
5
+ import { DEFAULT_DECISIONS_CONFIG } from "./decisions/service.js";
5
6
  import { exists, readJson } from "./runtime/fs.js";
6
7
  import { isPathSpec } from "./runtime/modules.js";
7
8
  const providerSchema = z
@@ -82,6 +83,35 @@ const serversSchema = (value) => z.record(z.string(), value).superRefine((server
82
83
  export const OCI_IMAGE_PATTERN = /^[a-zA-Z0-9][^\s@]*@sha256:[a-f0-9]{64}$/;
83
84
  /** Analysis keys only the user (global) layer decides; a project layer cannot set them. */
84
85
  const GLOBAL_ONLY_ANALYSIS = ["runtime", "oci", "retention"];
86
+ /** Decision Intelligence keys only the user (global) layer decides: the provider and telemetry. */
87
+ const GLOBAL_ONLY_DECISIONS = ["provider", "telemetry"];
88
+ /** JSON values a plugin may receive as options (`pluginOverrides[id].options`). */
89
+ const jsonValueSchema = z.lazy(() => z.union([
90
+ z.string(),
91
+ z.number().finite(),
92
+ z.boolean(),
93
+ z.null(),
94
+ z.array(jsonValueSchema),
95
+ z.record(z.string(), jsonValueSchema),
96
+ ]));
97
+ const PLUGIN_OPTION_KEY = /^[A-Za-z][A-Za-z0-9_.-]{0,63}$/;
98
+ const PLUGIN_OPTIONS_MAX_BYTES = 8 * 1024;
99
+ const pluginOptionsSchema = z
100
+ .record(z.string(), jsonValueSchema)
101
+ .superRefine((options, context) => {
102
+ for (const key of Object.keys(options))
103
+ if (!PLUGIN_OPTION_KEY.test(key))
104
+ context.addIssue({
105
+ code: "custom",
106
+ path: [key],
107
+ message: "Plugin option names start with a letter and use letters, digits, dot, underscore or dash",
108
+ });
109
+ if (Buffer.byteLength(JSON.stringify(options), "utf8") > PLUGIN_OPTIONS_MAX_BYTES)
110
+ context.addIssue({
111
+ code: "custom",
112
+ message: "Plugin options may not exceed 8 KB serialized",
113
+ });
114
+ });
85
115
  const configObjectSchema = z
86
116
  .object({
87
117
  schemaVersion: z.literal(1).default(1),
@@ -114,15 +144,43 @@ const configObjectSchema = z
114
144
  .record(z.string(), z.object({ enabled: z.boolean().optional() }).passthrough())
115
145
  .default({}),
116
146
  /** Project-local enable/disable overrides for configured external plugins, keyed by plugin id. */
117
- pluginOverrides: z.record(z.string(), z.object({ enabled: z.boolean() }).strict()).default({}),
147
+ pluginOverrides: z
148
+ .record(z.string(), z
149
+ .object({
150
+ enabled: z.boolean().optional(),
151
+ /** Global-only, read-only options handed to the plugin as `api.options` (8 KB). */
152
+ options: pluginOptionsSchema.optional(),
153
+ })
154
+ .strict())
155
+ .default({}),
118
156
  /** Host-enforced timeouts for plugin hooks. */
119
157
  pluginHooks: z
120
158
  .object({
121
159
  timeoutMs: z.number().int().min(100).max(120_000).default(15_000),
122
160
  sessionEndTimeoutMs: z.number().int().min(100).max(120_000).default(10_000),
161
+ /** Per-plugin limit for `dispose()` (and the active decision provider's `deactivate()`). */
162
+ disposeTimeoutMs: z.number().int().min(100).max(10_000).default(2000),
123
163
  })
124
164
  .strict()
125
- .default(() => ({ timeoutMs: 15_000, sessionEndTimeoutMs: 10_000 })),
165
+ .default(() => ({ timeoutMs: 15_000, sessionEndTimeoutMs: 10_000, disposeTimeoutMs: 2000 })),
166
+ /**
167
+ * Decision Intelligence: the active provider is chosen here and only here. `provider` and
168
+ * `telemetry` are global-only (a repository cannot pick the engine or the telemetry policy).
169
+ */
170
+ decisions: z
171
+ .object({
172
+ enabled: z.boolean().default(true),
173
+ provider: z
174
+ .string()
175
+ .regex(/^[a-z0-9][a-z0-9.-]{0,63}$/)
176
+ .nullable()
177
+ .default(null),
178
+ timeoutMs: z.number().int().min(50).max(10_000).default(DEFAULT_DECISIONS_CONFIG.timeoutMs),
179
+ minConfidence: z.number().min(0).max(1).default(DEFAULT_DECISIONS_CONFIG.minConfidence),
180
+ telemetry: z.boolean().default(true),
181
+ })
182
+ .strict()
183
+ .default(() => ({ ...DEFAULT_DECISIONS_CONFIG })),
126
184
  context: z
127
185
  .object({
128
186
  /** Use CLAUDE.md where a directory has no AGENTS.md (off by default). */
@@ -550,8 +608,20 @@ export async function loadConfigWithProvenance(workspace, options = {}) {
550
608
  const tasksRaw = object.tasks && typeof object.tasks === "object" && !Array.isArray(object.tasks)
551
609
  ? object.tasks
552
610
  : {};
611
+ const decisionsRaw = object.decisions && typeof object.decisions === "object" && !Array.isArray(object.decisions)
612
+ ? object.decisions
613
+ : {};
614
+ const overridesRaw = object.pluginOverrides &&
615
+ typeof object.pluginOverrides === "object" &&
616
+ !Array.isArray(object.pluginOverrides)
617
+ ? object.pluginOverrides
618
+ : {};
553
619
  const ignored = [
554
620
  ...GLOBAL_ONLY_ANALYSIS.filter((key) => key in analysisRaw).map((key) => `analysis.${key}`),
621
+ ...GLOBAL_ONLY_DECISIONS.filter((key) => key in decisionsRaw).map((key) => `decisions.${key}`),
622
+ ...Object.entries(overridesRaw)
623
+ .filter(([, entry]) => !!entry && typeof entry === "object" && !Array.isArray(entry) && "options" in entry)
624
+ .map(([id]) => `pluginOverrides.${id}.options`),
555
625
  ...("retentionDays" in tasksRaw ? ["tasks.retentionDays"] : []),
556
626
  ];
557
627
  return { config, keys: new Set(Object.keys(object)), sources, ignored, file };
@@ -592,10 +662,19 @@ export async function loadConfigWithProvenance(workspace, options = {}) {
592
662
  }
593
663
  // Additive records: merge by key, the selected layer wins per key.
594
664
  if (key === "pluginOverrides") {
595
- overlaid.pluginOverrides = {
596
- ...overlaid.pluginOverrides,
597
- ...selected.config.pluginOverrides,
598
- };
665
+ // `options` is global-only: a repository must not inject options into a plugin that has
666
+ // system access, so the selected entry keeps its `enabled` but never its `options`.
667
+ const merged = { ...overlaid.pluginOverrides };
668
+ for (const [id, entry] of Object.entries(selected.config.pluginOverrides)) {
669
+ const options = global.config.pluginOverrides[id]?.options;
670
+ const { options: _ignored, ...rest } = entry;
671
+ merged[id] = { ...global.config.pluginOverrides[id], ...rest };
672
+ if (options)
673
+ merged[id].options = options;
674
+ else
675
+ delete merged[id].options;
676
+ }
677
+ overlaid.pluginOverrides = merged;
599
678
  continue;
600
679
  }
601
680
  if (key === "skillOverrides") {
@@ -622,6 +701,16 @@ export async function loadConfigWithProvenance(workspace, options = {}) {
622
701
  };
623
702
  continue;
624
703
  }
704
+ if (key === "decisions") {
705
+ // `provider` and `telemetry` are global-only (a repository cannot pick the decision
706
+ // engine nor its telemetry policy); the rest follows the selected layer.
707
+ overlaid.decisions = {
708
+ ...selected.config.decisions,
709
+ provider: global.config.decisions.provider,
710
+ telemetry: global.config.decisions.telemetry,
711
+ };
712
+ continue;
713
+ }
625
714
  if (key === "tasks") {
626
715
  // `retentionDays` is global-only (one sweep deletes the data of every workspace).
627
716
  overlaid.tasks = {
@@ -773,6 +862,7 @@ const SETTABLE_SECTIONS = {
773
862
  context: configObjectSchema.shape.context.removeDefault(),
774
863
  limits: configObjectSchema.shape.limits.removeDefault(),
775
864
  pluginHooks: configObjectSchema.shape.pluginHooks.removeDefault(),
865
+ decisions: configObjectSchema.shape.decisions.removeDefault(),
776
866
  tui: configObjectSchema.shape.tui.removeDefault(),
777
867
  websearch: configObjectSchema.shape.websearch.removeDefault(),
778
868
  agents: configObjectSchema.shape.agents.removeDefault(),
@@ -798,6 +888,12 @@ const SETTABLE_KEYS = {
798
888
  "limits.firstTokenRetries": SETTABLE_SECTIONS.limits.shape.firstTokenRetries,
799
889
  "limits.truncationRecoveries": SETTABLE_SECTIONS.limits.shape.truncationRecoveries,
800
890
  "pluginHooks.timeoutMs": SETTABLE_SECTIONS.pluginHooks.shape.timeoutMs,
891
+ "pluginHooks.disposeTimeoutMs": SETTABLE_SECTIONS.pluginHooks.shape.disposeTimeoutMs,
892
+ "decisions.enabled": SETTABLE_SECTIONS.decisions.shape.enabled,
893
+ "decisions.provider": SETTABLE_SECTIONS.decisions.shape.provider,
894
+ "decisions.timeoutMs": SETTABLE_SECTIONS.decisions.shape.timeoutMs,
895
+ "decisions.minConfidence": SETTABLE_SECTIONS.decisions.shape.minConfidence,
896
+ "decisions.telemetry": SETTABLE_SECTIONS.decisions.shape.telemetry,
801
897
  "tui.paddingX": SETTABLE_SECTIONS.tui.shape.paddingX,
802
898
  "tui.contentPaddingX": SETTABLE_SECTIONS.tui.shape.contentPaddingX,
803
899
  "tui.skillSlashCommands": SETTABLE_SECTIONS.tui.shape.skillSlashCommands,
@@ -945,7 +1041,13 @@ export async function setProjectPluginEnabled(input) {
945
1041
  !Array.isArray(raw.pluginOverrides)
946
1042
  ? raw.pluginOverrides
947
1043
  : {};
948
- raw.pluginOverrides = { ...current, [input.id]: { enabled: input.enabled } };
1044
+ const entry = current[input.id] &&
1045
+ typeof current[input.id] === "object" &&
1046
+ !Array.isArray(current[input.id])
1047
+ ? current[input.id]
1048
+ : {};
1049
+ // Keep the other fields of the entry (`options`): toggling a plugin must not erase them.
1050
+ raw.pluginOverrides = { ...current, [input.id]: { ...entry, enabled: input.enabled } };
949
1051
  }
950
1052
  raw.schemaVersion ??= 1;
951
1053
  await mkdir(dirname(file), { recursive: true, mode: 0o700 });