@alisio/core 0.2.1 → 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.
@@ -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. */
@@ -20,6 +20,7 @@ import { HumanWaits } from "./core/human-wait.js";
20
20
  import { DEFAULT_MAX_OUTPUT_TOKENS } from "./core/output-limit.js";
21
21
  import { ToolRegistry } from "./core/registry.js";
22
22
  import { AgentRunner } from "./core/runner.js";
23
+ import { bindDecisions, DecisionMetrics, DecisionRegistry, DecisionService, } from "./decisions/index.js";
23
24
  import { GoalService } from "./goal/service.js";
24
25
  import { GoalStore } from "./goal/store.js";
25
26
  import { HerdrBridge } from "./integrations/herdr.js";
@@ -122,7 +123,19 @@ export async function createApplication(options = {}) {
122
123
  cwd,
123
124
  claudeMdFallback: config.context.claudeMdFallback,
124
125
  maxBytes: config.context.maxBytes,
125
- }), 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);
126
139
  let mcpRuntimePermission = options.readOnly
127
140
  ? "read-only"
128
141
  : options.allowMcp || mcpAllow
@@ -131,6 +144,20 @@ export async function createApplication(options = {}) {
131
144
  const herdr = options.noHerdr
132
145
  ? new HerdrBridge({}, async () => "")
133
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
+ });
134
161
  try {
135
162
  // First-party built-ins load through the trusted path, also under --read-only.
136
163
  const builtinContext = {
@@ -279,6 +306,9 @@ export async function createApplication(options = {}) {
279
306
  }
280
307
  }
281
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();
282
312
  // Project skills follow the config trust model (--trust-project or an explicit --config).
283
313
  await skills.discover(skillRoots({
284
314
  workspace,
@@ -300,7 +330,6 @@ export async function createApplication(options = {}) {
300
330
  resolveExtension: () => plugins.extensions.resolve("websearch"),
301
331
  }, pathAccess);
302
332
  // Python analysis and artifacts live next to the session database (like attachment blobs).
303
- const stateRoot = options.db && options.db !== ":memory:" ? dirname(options.db) : stateHome();
304
333
  const artifacts = new ArtifactStore({
305
334
  root: stateRoot,
306
335
  db: store.db,
@@ -787,6 +816,7 @@ export async function createApplication(options = {}) {
787
816
  },
788
817
  }
789
818
  : {}),
819
+ decisions: (call, announce) => bindDecisions(decisionService, () => config.decisions.telemetry, call, announce),
790
820
  artifacts: (call, announce) => createArtifactPublisher(artifacts, {
791
821
  sessionId: call.sessionId,
792
822
  rootSessionId: store.rootOf(call.sessionId),
@@ -916,6 +946,8 @@ export async function createApplication(options = {}) {
916
946
  /** Tabular datasets of every session (ingestion, read-only queries, pages). */
917
947
  datasets,
918
948
  registry,
949
+ /** Decision Intelligence: the provider catalog, the service and in-memory metrics. */
950
+ decisions: { registry: decisionRegistry, service: decisionService, metrics: decisionMetrics },
919
951
  context,
920
952
  skills,
921
953
  /** Path- and content-safe metadata for the interactive skills manager. */
@@ -1120,6 +1152,33 @@ export async function createApplication(options = {}) {
1120
1152
  config.pluginHooks = { ...config.pluginHooks, timeoutMs: Number(value) };
1121
1153
  plugins.applyTimeoutSettings({ hookTimeoutMs: Number(value) });
1122
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
+ }
1123
1182
  case "tui.paddingX":
1124
1183
  config.tui = { ...config.tui, paddingX: Number(value) };
1125
1184
  break;
@@ -1374,7 +1433,11 @@ export async function createApplication(options = {}) {
1374
1433
  ...[...runtimes]
1375
1434
  .filter((runtime) => runtime !== provider.currentProvider)
1376
1435
  .map((runtime) => Promise.resolve(runtime.dispose?.())),
1377
- 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()),
1378
1441
  ]);
1379
1442
  })());
1380
1443
  }
@@ -1387,7 +1450,11 @@ export async function createApplication(options = {}) {
1387
1450
  }
1388
1451
  catch (e) {
1389
1452
  await Promise.race([
1390
- 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
+ ]),
1391
1458
  delay(TEARDOWN_STAGE_TIMEOUT_MS),
1392
1459
  ]);
1393
1460
  store.close();
@@ -2,6 +2,27 @@
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
+ },
5
26
  {
6
27
  version: "0.2.1",
7
28
  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 });
@@ -1,4 +1,4 @@
1
- import type { AnalysisCapability, ArtifactPublisher, ArtifactPublishInput, ArtifactRef, CompactionCheckpoint, InstallPreview, Message, ToolCall, ToolResult } from "@alisio/sdk";
1
+ import type { AnalysisCapability, ArtifactPublisher, ArtifactPublishInput, ArtifactRef, CompactionCheckpoint, InstallPreview, Message, RunEventDataMap, ToolCall, ToolDecisions, ToolResult } from "@alisio/sdk";
2
2
  export interface Session {
3
3
  id: string;
4
4
  workspace: string;
@@ -275,6 +275,15 @@ export type ArtifactPublisherFactory = (call: {
275
275
  runId: string;
276
276
  callId: string;
277
277
  }, announce: (published: PublishedArtifactInfo) => void) => CoreArtifactPublisher | undefined;
278
+ /**
279
+ * Creates the `ToolContext.decisions` of one tool call; `announce` emits the decision event of
280
+ * the run (persisted and fanned out like any other). `undefined` leaves the context without it.
281
+ */
282
+ export type DecisionsFactory = (call: {
283
+ sessionId: string;
284
+ runId: string;
285
+ callId: string;
286
+ }, announce: <K extends "decision_completed" | "decision_fallback">(type: K, data: RunEventDataMap[K]) => void) => ToolDecisions | undefined;
278
287
  /** Optional interactive approval for write/process/external tools not pre-allowed by the Policy. */
279
288
  export type ApprovalHandler = (request: ApprovalRequest) => Promise<ApprovalDecision>;
280
289
  export interface HookFailure {
@@ -1,6 +1,6 @@
1
1
  import { type Attachment, type DatasetRef, type Message, type ModelProvider, type RunEvent, type ToolDefinition, type ToolResult } from "@alisio/sdk";
2
2
  import type { PathAccess } from "../runtime/access.ts";
3
- import type { ApprovalHandler, ArtifactPublisherFactory, CapabilityGate, ContextSource, Policy, RunnerExtensions, SessionStore } from "./contracts.ts";
3
+ import type { ApprovalHandler, ArtifactPublisherFactory, CapabilityGate, ContextSource, DecisionsFactory, Policy, RunnerExtensions, SessionStore } from "./contracts.ts";
4
4
  import { HumanWaits } from "./human-wait.ts";
5
5
  import type { ToolRegistry } from "./registry.ts";
6
6
  export interface CompactionSettings {
@@ -81,6 +81,8 @@ export interface RunnerOptions {
81
81
  capabilities?: CapabilityGate;
82
82
  /** Artifact publisher of each tool call (`ToolContext.artifacts`). */
83
83
  artifacts?: ArtifactPublisherFactory;
84
+ /** Decision Intelligence bound to each tool call (`ToolContext.decisions`). */
85
+ decisions?: DecisionsFactory;
84
86
  /**
85
87
  * Provider-native tool definitions (for example a hosted `web_search` tool) appended to every
86
88
  * request's `tools` alongside the registry's function tools. Opt-in, set once for the whole
@@ -529,6 +529,7 @@ export class AgentRunner {
529
529
  callId: call.id,
530
530
  ...(published.executionId ? { executionId: published.executionId } : {}),
531
531
  }));
532
+ const decisions = o.decisions?.({ sessionId, runId, callId: call.id }, (type, data) => emit(type, data));
532
533
  /**
533
534
  * `analysis.install`: always asks, `once` only, and no flag or earlier grant covers it. Where
534
535
  * nobody can be asked (headless, no approval handler) the answer is `deny`.
@@ -590,6 +591,7 @@ export class AgentRunner {
590
591
  runId,
591
592
  callId: call.id,
592
593
  ...(publisher ? { artifacts: publisher } : {}),
594
+ ...(decisions ? { decisions } : {}),
593
595
  approveInstall,
594
596
  emit: (data) => emit("tool_progress", { id: call.id, data }),
595
597
  // Only the built-in plan tool reports its durable events (never a plugin tool).