@alisio/core 0.1.0-alpha.16 → 0.1.0-alpha.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,268 @@
1
+ import { agentCatalogFromState, resolveActiveAgent } from "../agents/active.js";
2
+ import { BUILTIN_COMMANDS } from "./builtins.js";
3
+ export { BUILTIN_COMMANDS };
4
+ const builtinDescriptor = (c) => ({
5
+ name: c.name,
6
+ description: c.description,
7
+ ...(c.aliases ? { aliases: [...c.aliases] } : {}),
8
+ ...(c.argumentHint ? { argumentHint: c.argumentHint } : {}),
9
+ source: "builtin",
10
+ surfaces: [...c.surfaces],
11
+ execution: c.execution,
12
+ });
13
+ const ALL = ["tui", "web", "api"];
14
+ /** Root sessions of this workspace bound to the current provider, newest first (as the TUI). */
15
+ const workspaceSessions = (host) => host.store
16
+ .list()
17
+ .filter((s) => s.workspace === host.workspace && s.provider === host.provider.id);
18
+ const firstPrompt = (host, id) => {
19
+ const first = host.store.messages(id).find((m) => m.role === "user" && !m.summary);
20
+ return first?.role === "user" ? first.text.replace(/\s+/g, " ").slice(0, 60) : "";
21
+ };
22
+ const HANDLERS = {
23
+ async tools(_args, _ctx, host) {
24
+ const policy = host.runner.policy;
25
+ const rows = host.registry
26
+ .list()
27
+ .map((t) => {
28
+ const effect = t.effect ?? "external";
29
+ const state = effect === "read" || effect === "internal" || policy[effect]
30
+ ? "enabled"
31
+ : host.runner.approvals && (effect === "write" || effect === "process")
32
+ ? "ask"
33
+ : "disabled";
34
+ return `| \`${t.name}\` | ${effect} | ${state} |`;
35
+ })
36
+ .join("\n");
37
+ return {
38
+ text: `**Tools**\n\n| Tool | Effect | State |\n| --- | --- | --- |\n${rows}\n\n\`ask\` prompts before running (allow once / session / deny). Use --allow-write / --allow-process to pre-allow; --read-only disables them. Paths outside the workspace ask per directory; pre-allow with --add-dir or the \`additionalDirectories\` config key. \`internal\` tools (built-in plugins) only write Alisio's own state.`,
39
+ };
40
+ },
41
+ async sessions(_args, ctx, host) {
42
+ const list = workspaceSessions(host).slice(0, 20);
43
+ if (!list.length)
44
+ return { text: "No sessions in this workspace", tone: "notice" };
45
+ return {
46
+ text: [
47
+ "**Recent sessions** (use `/resume <id-prefix>`)",
48
+ "",
49
+ ...list.map((s) => `- \`${s.id}\` ${s.id === ctx.sessionId ? "**(current)** " : ""}${s.model} · ${host.store.messages(s.id).length} msgs · ${firstPrompt(host, s.id) || "_empty_"}`),
50
+ ].join("\n"),
51
+ data: list,
52
+ };
53
+ },
54
+ async resume(args, _ctx, host) {
55
+ const target = args.trim();
56
+ if (!target)
57
+ throw new Error("Usage: /resume <id>");
58
+ const matches = workspaceSessions(host).filter((s) => s.id.startsWith(target));
59
+ if (matches.length !== 1)
60
+ throw new Error(matches.length
61
+ ? `Ambiguous session prefix: ${target}`
62
+ : `No session in this workspace matches ${target}`);
63
+ const found = matches[0];
64
+ return { text: `Resumed session ${found.id}`, tone: "notice", sessionId: found.id };
65
+ },
66
+ async clear(_args, ctx, host) {
67
+ const model = host.store.get(ctx.sessionId).model;
68
+ const created = host.store.create(host.workspace, host.provider.id, model);
69
+ return { text: `New session ${created.id}`, tone: "notice", sessionId: created.id };
70
+ },
71
+ async compact(args, ctx, host) {
72
+ const result = await host.runner.compact(ctx.sessionId, {
73
+ ...(args.trim() ? { focus: args.trim() } : {}),
74
+ ...(ctx.signal ? { signal: ctx.signal } : {}),
75
+ });
76
+ return result
77
+ ? {
78
+ text: `Compacted ${result.replaced} messages: ~${result.before} → ~${result.after} tokens`,
79
+ tone: "notice",
80
+ data: result,
81
+ }
82
+ : { text: "Not enough history to compact", tone: "notice" };
83
+ },
84
+ async model(args, ctx, host) {
85
+ const model = args.trim();
86
+ if (!model)
87
+ throw new Error("Usage: /model <model id>");
88
+ host.runner.setModel(ctx.sessionId, model);
89
+ return { text: `Model set to \`${model}\``, tone: "notice", data: { model } };
90
+ },
91
+ async effort(args, _ctx, host) {
92
+ const level = args.trim();
93
+ if (!level)
94
+ return {
95
+ text: `Reasoning effort: ${host.config?.agents.effort ?? "model default"}`,
96
+ tone: "notice",
97
+ };
98
+ if (!host.updateSetting)
99
+ throw new Error("Effort changes are unavailable here");
100
+ const value = level === "!clear" ? undefined : level;
101
+ await host.updateSetting("agents.effort", value);
102
+ return {
103
+ text: value ? `Reasoning effort set to ${value}` : "Reasoning effort cleared",
104
+ tone: "notice",
105
+ data: { effort: value },
106
+ };
107
+ },
108
+ async stats(_args, ctx, host) {
109
+ const session = host.store.get(ctx.sessionId);
110
+ const runs = host.store.runs?.(ctx.sessionId) ?? [];
111
+ const tokens = runs.reduce((sum, r) => ({
112
+ input: sum.input + (r.usage?.input ?? 0),
113
+ output: sum.output + (r.usage?.output ?? 0),
114
+ }), { input: 0, output: 0 });
115
+ return {
116
+ text: [
117
+ "**Session statistics**",
118
+ "",
119
+ `- Session: \`${session.id}\``,
120
+ `- Model: \`${session.model}\``,
121
+ `- Messages: ${host.store.messages(session.id).length}`,
122
+ `- Runs: ${runs.length}`,
123
+ `- Tokens: in ${tokens.input} · out ${tokens.output}`,
124
+ ].join("\n"),
125
+ data: { session, runs: runs.length, tokens },
126
+ };
127
+ },
128
+ async skills(_args, _ctx, host) {
129
+ const skills = host.skillCatalog?.() ?? [];
130
+ if (!skills.length)
131
+ return { text: "No skills found", tone: "notice" };
132
+ const state = (s) => s.effective ? "enabled" : s.shadowedBy ? `shadowed by ${s.shadowedBy}` : "disabled";
133
+ return {
134
+ text: `**Skills**\n\n| Skill | Scope | State |\n| --- | --- | --- |\n${skills
135
+ .map((s) => `| \`${s.displayId}\` | ${s.scope} | ${state(s)} |`)
136
+ .join("\n")}`,
137
+ data: skills,
138
+ };
139
+ },
140
+ async plugins(_args, _ctx, host) {
141
+ const plugins = host.pluginCatalog?.() ?? [];
142
+ if (!plugins.length)
143
+ return { text: "No plugins loaded", tone: "notice" };
144
+ return {
145
+ text: `**Plugins**\n\n| Plugin | Status | Enabled |\n| --- | --- | --- |\n${plugins
146
+ .map((p) => `| \`${p.id}\` | ${p.status} | ${p.enabled ? "yes" : "no"} |`)
147
+ .join("\n")}`,
148
+ data: plugins,
149
+ };
150
+ },
151
+ async mcp(_args, _ctx, host) {
152
+ const servers = host.mcp?.list() ?? [];
153
+ if (!servers.length)
154
+ return { text: "No MCP servers configured", tone: "notice" };
155
+ return {
156
+ text: `**MCP servers**\n\n| Server | Status | Tools |\n| --- | --- | --- |\n${servers
157
+ .map((s) => `| \`${s.name}\` | ${s.status} | ${s.counts.tools} |`)
158
+ .join("\n")}`,
159
+ data: servers,
160
+ };
161
+ },
162
+ async agents(args, ctx, host) {
163
+ // With arguments, `/agents <verb>` belongs to the subagents plugin's own command (as in the
164
+ // TUI); without them it lists the active-agent catalog.
165
+ const plugin = args.trim() ? host.plugins?.commands.get("agents") : undefined;
166
+ if (plugin)
167
+ return { text: await plugin(args.trim(), { sessionId: ctx.sessionId }) };
168
+ let state;
169
+ try {
170
+ state = host.plugins?.pluginState?.("subagents", "mainAgents");
171
+ }
172
+ catch {
173
+ /* agent contributions are best-effort */
174
+ }
175
+ const agents = agentCatalogFromState(state);
176
+ const active = resolveActiveAgent(agents, host.config?.agents.active);
177
+ return {
178
+ text: [
179
+ "**Agents**",
180
+ "",
181
+ ...agents.map((a) => `- **${a.name}**${a.id === active.id ? " (current)" : ""}${a.readOnly ? " · read-only" : ""} — ${a.description}`),
182
+ ].join("\n"),
183
+ data: { agents, active: active.id },
184
+ };
185
+ },
186
+ };
187
+ export class CommandCatalog {
188
+ host;
189
+ constructor(host) {
190
+ this.host = host;
191
+ }
192
+ builtins() {
193
+ return BUILTIN_COMMANDS.map(builtinDescriptor);
194
+ }
195
+ /** Every descriptor (optionally for one surface). Precedence: builtin > plugin > prompt > skill. */
196
+ list(surface) {
197
+ const out = this.builtins();
198
+ const taken = new Set(out.flatMap((c) => [c.name, ...(c.aliases ?? [])]));
199
+ const push = (descriptor) => {
200
+ if (taken.has(descriptor.name))
201
+ return;
202
+ taken.add(descriptor.name);
203
+ out.push(descriptor);
204
+ };
205
+ const host = this.host;
206
+ if (host?.plugins)
207
+ for (const [name, info] of host.plugins.commandInfo)
208
+ push({
209
+ name,
210
+ description: info.description ?? `plugin ${info.plugin}`,
211
+ ...(info.argumentHint ? { argumentHint: info.argumentHint } : {}),
212
+ source: "plugin",
213
+ owner: info.plugin,
214
+ surfaces: [...ALL],
215
+ execution: "core",
216
+ });
217
+ if (host?.prompts)
218
+ for (const template of host.prompts.templates.values())
219
+ push({
220
+ name: template.name,
221
+ description: template.description,
222
+ ...(template.argumentHint ? { argumentHint: template.argumentHint } : {}),
223
+ source: "prompt",
224
+ owner: template.source,
225
+ surfaces: [...ALL],
226
+ execution: "surface",
227
+ });
228
+ for (const skill of host?.skillCatalog?.() ?? [])
229
+ if (skill.effective && skill.enabled)
230
+ push({
231
+ name: `skill:${skill.id}`,
232
+ description: skill.description,
233
+ source: "skill",
234
+ ...(skill.owner ? { owner: skill.owner.id } : {}),
235
+ surfaces: [...ALL],
236
+ execution: "surface",
237
+ });
238
+ return surface ? out.filter((c) => c.surfaces.includes(surface)) : out;
239
+ }
240
+ /** A command by name or alias (built-in names and aliases are case-insensitive). */
241
+ resolve(name) {
242
+ const lower = name.toLowerCase();
243
+ const all = this.list();
244
+ return (all.find((c) => c.source === "builtin" && (c.name === lower || c.aliases?.includes(lower))) ??
245
+ all.find((c) => c.source !== "builtin" && c.name === name));
246
+ }
247
+ /** Runs an `execution: "core"` command. Surface commands and unknown names throw. */
248
+ async execute(name, args, ctx) {
249
+ const descriptor = this.resolve(name);
250
+ if (!descriptor)
251
+ throw new Error(`Unknown command /${name}. Type /help.`);
252
+ if (descriptor.execution !== "core")
253
+ throw new Error(`/${descriptor.name} is handled by each surface, not by the core catalog`);
254
+ const host = this.host;
255
+ if (!host)
256
+ throw new Error(`/${descriptor.name} needs an application host`);
257
+ if (descriptor.source === "plugin") {
258
+ const handler = host.plugins?.commands.get(descriptor.name);
259
+ if (!handler)
260
+ throw new Error(`Unknown plugin command: ${descriptor.name}`);
261
+ return { text: await handler(args, { sessionId: ctx.sessionId }) };
262
+ }
263
+ const handler = HANDLERS[descriptor.name];
264
+ if (!handler)
265
+ throw new Error(`/${descriptor.name} has no core handler`);
266
+ return handler(args, ctx, host);
267
+ }
268
+ }
package/dist/config.d.ts CHANGED
@@ -36,6 +36,7 @@ export declare const configSchema: z.ZodPipe<z.ZodObject<{
36
36
  }, z.core.$strict>>;
37
37
  plugins: z.ZodDefault<z.ZodArray<z.ZodString>>;
38
38
  skills: z.ZodDefault<z.ZodArray<z.ZodString>>;
39
+ additionalDirectories: z.ZodDefault<z.ZodArray<z.ZodString>>;
39
40
  skillOverrides: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodObject<{
40
41
  enabled: z.ZodBoolean;
41
42
  }, z.core.$strict>>>;
@@ -175,6 +176,7 @@ export declare const configSchema: z.ZodPipe<z.ZodObject<{
175
176
  }, z.core.$strip>>;
176
177
  tui: z.ZodDefault<z.ZodObject<{
177
178
  paddingX: z.ZodDefault<z.ZodNumber>;
179
+ contentPaddingX: z.ZodDefault<z.ZodNumber>;
178
180
  skillSlashCommands: z.ZodDefault<z.ZodBoolean>;
179
181
  }, z.core.$strict>>;
180
182
  agents: z.ZodDefault<z.ZodObject<{
@@ -195,6 +197,7 @@ export declare const configSchema: z.ZodPipe<z.ZodObject<{
195
197
  };
196
198
  plugins: string[];
197
199
  skills: string[];
200
+ additionalDirectories: string[];
198
201
  skillOverrides: Record<string, {
199
202
  enabled: boolean;
200
203
  }>;
@@ -234,6 +237,7 @@ export declare const configSchema: z.ZodPipe<z.ZodObject<{
234
237
  };
235
238
  tui: {
236
239
  paddingX: number;
240
+ contentPaddingX: number;
237
241
  skillSlashCommands: boolean;
238
242
  };
239
243
  agents: {
@@ -284,6 +288,7 @@ export declare const configSchema: z.ZodPipe<z.ZodObject<{
284
288
  };
285
289
  plugins: string[];
286
290
  skills: string[];
291
+ additionalDirectories: string[];
287
292
  skillOverrides: Record<string, {
288
293
  enabled: boolean;
289
294
  }>;
@@ -376,6 +381,7 @@ export declare const configSchema: z.ZodPipe<z.ZodObject<{
376
381
  };
377
382
  tui: {
378
383
  paddingX: number;
384
+ contentPaddingX: number;
379
385
  skillSlashCommands: boolean;
380
386
  };
381
387
  agents: {
@@ -454,6 +460,7 @@ declare const SETTABLE_KEYS: {
454
460
  readonly "limits.timeoutMs": z.ZodDefault<z.ZodNumber>;
455
461
  readonly "pluginHooks.timeoutMs": z.ZodDefault<z.ZodNumber>;
456
462
  readonly "tui.paddingX": z.ZodDefault<z.ZodNumber>;
463
+ readonly "tui.contentPaddingX": z.ZodDefault<z.ZodNumber>;
457
464
  readonly "tui.skillSlashCommands": z.ZodDefault<z.ZodBoolean>;
458
465
  readonly "websearch.provider": z.ZodOptional<z.ZodEnum<{
459
466
  brave: "brave";
@@ -469,6 +476,13 @@ declare const SETTABLE_KEYS: {
469
476
  };
470
477
  export type SettableSettingKey = keyof typeof SETTABLE_KEYS;
471
478
  export declare function isSettableSettingKey(key: string): key is SettableSettingKey;
479
+ export interface SettableSettingInfo {
480
+ key: SettableSettingKey;
481
+ kind: "boolean" | "number" | "string" | "enum";
482
+ options?: string[];
483
+ }
484
+ /** Every settable key with its value kind (and enum options), for settings editors. */
485
+ export declare function settableSettings(): SettableSettingInfo[];
472
486
  /**
473
487
  * Atomically sets one user-facing setting in the global config file (`<config home>/config.json`)
474
488
  * while preserving every unrelated JSON field. The value is validated against the same zod leaf
package/dist/config.js CHANGED
@@ -1,4 +1,4 @@
1
- import { chmod, mkdir, open, readFile, rename, unlink } from "node:fs/promises";
1
+ import { chmod, mkdir, open, readFile, realpath, rename, unlink } from "node:fs/promises";
2
2
  import { homedir } from "node:os";
3
3
  import { dirname, join, resolve } from "node:path";
4
4
  import { z } from "zod";
@@ -81,6 +81,12 @@ const configObjectSchema = z
81
81
  provider: providerSchema.default(() => providerSchema.parse({})),
82
82
  plugins: z.array(z.string()).default([]),
83
83
  skills: z.array(z.string()).default([]),
84
+ /**
85
+ * Extra directories the mediated path policy may touch outside the workspace. Resolved
86
+ * relative to the defining configuration file and canonicalized on load. Additive across
87
+ * layers like `plugins`/`skills`: a lower layer can only ADD, never clear the global list.
88
+ */
89
+ additionalDirectories: z.array(z.string().trim().min(1)).default([]),
84
90
  /** Project-local enable/disable overrides for effective non-plugin skills. */
85
91
  skillOverrides: z.record(z.string(), z.object({ enabled: z.boolean() }).strict()).default({}),
86
92
  mcp: z
@@ -178,8 +184,15 @@ const configObjectSchema = z
178
184
  })),
179
185
  tui: z
180
186
  .object({
181
- /** Horizontal padding (columns) around the editor input box. */
187
+ /** Horizontal padding (columns) around the editor input box. Editor-only. */
182
188
  paddingX: z.number().int().min(0).max(4).default(1),
189
+ /**
190
+ * Horizontal inset (columns) applied to each side of the transcript content, so
191
+ * agent-written rows keep breathing room from the window borders. Independent of
192
+ * `paddingX` (which styles only the editor input box). Clamped on narrow terminals so the
193
+ * content column never collapses below a usable width.
194
+ */
195
+ contentPaddingX: z.number().int().min(0).max(12).default(2),
183
196
  /**
184
197
  * Offer effective skills as first-class `skill:<id>` editor slash-autocomplete entries.
185
198
  * Off hides those entries; the `/skills` manager and its argument completion stay available.
@@ -187,7 +200,7 @@ const configObjectSchema = z
187
200
  skillSlashCommands: z.boolean().default(true),
188
201
  })
189
202
  .strict()
190
- .default(() => ({ paddingX: 1, skillSlashCommands: true })),
203
+ .default(() => ({ paddingX: 1, contentPaddingX: 2, skillSlashCommands: true })),
191
204
  /**
192
205
  * The ACTIVE agent drives the main session: its system prompt is appended to every prompt and
193
206
  * its optional model selector is offered when the agent is chosen (`/agents`). `build` is the
@@ -237,6 +250,26 @@ function isUsableLegacyModel(model) {
237
250
  const normalized = model.trim();
238
251
  return normalized !== "" && normalized !== LEGACY_PROVIDER_MODEL_PLACEHOLDER;
239
252
  }
253
+ /**
254
+ * Appends the entries of `selected` after the `global` list, dropping exact duplicates so the
255
+ * global entries keep their order and the selected layer only ever ADDS to the collection. An
256
+ * empty `selected` list therefore leaves the global list intact. Entries were already resolved
257
+ * per layer by `parseLayer`, so deduplication happens on the resolved strings.
258
+ */
259
+ function mergeUnique(global, selected) {
260
+ const seen = new Set(global);
261
+ const merged = [...global];
262
+ for (const entry of selected)
263
+ if (!seen.has(entry)) {
264
+ seen.add(entry);
265
+ merged.push(entry);
266
+ }
267
+ return merged;
268
+ }
269
+ /** Absolute, canonical directory when it exists; a missing path keeps its resolved absolute form. */
270
+ async function canonicalDirectory(path) {
271
+ return realpath(path).catch(() => resolve(path));
272
+ }
240
273
  /**
241
274
  * Whether legacy provider selection should take priority over a saved plugin profile for this
242
275
  * run: an explicit endpoint override, or a trusted project/explicit layer whose parsed root
@@ -269,6 +302,9 @@ export async function loadConfigWithProvenance(workspace, options = {}) {
269
302
  const raw = (await readJson(file)) ?? {};
270
303
  const config = configSchema.parse(raw);
271
304
  config.skills = config.skills.map((p) => resolve(file, "..", p));
305
+ // Extra tool roots are paths relative to the config file; canonicalize so containment checks
306
+ // compare real directories (a symlinked root never escapes its declared location).
307
+ config.additionalDirectories = await Promise.all(config.additionalDirectories.map((p) => canonicalDirectory(resolve(file, "..", p))));
272
308
  // Plugin entries are paths (relative to the config file) or npm package names.
273
309
  config.plugins = config.plugins.map((p) => (isPathSpec(p) ? resolve(file, "..", p) : p));
274
310
  for (const server of Object.values(config.mcp.servers))
@@ -310,8 +346,44 @@ export async function loadConfigWithProvenance(workspace, options = {}) {
310
346
  for (const key of selected.keys) {
311
347
  if (key === "mcp" || key === "mcpServers")
312
348
  continue;
313
- if (key in selected.config)
314
- overlaid[key] = selected.config[key];
349
+ if (!(key in selected.config))
350
+ continue;
351
+ // Additive collections: the selected layer ADDS to the global list (global entries
352
+ // first, exact duplicates dropped); an empty selected list never clears the global one.
353
+ if (key === "plugins") {
354
+ overlaid.plugins = mergeUnique(overlaid.plugins, selected.config.plugins);
355
+ continue;
356
+ }
357
+ if (key === "skills") {
358
+ overlaid.skills = mergeUnique(overlaid.skills, selected.config.skills);
359
+ continue;
360
+ }
361
+ if (key === "additionalDirectories") {
362
+ overlaid.additionalDirectories = mergeUnique(overlaid.additionalDirectories, selected.config.additionalDirectories);
363
+ continue;
364
+ }
365
+ // Additive records: merge by key, the selected layer wins per key.
366
+ if (key === "pluginOverrides") {
367
+ overlaid.pluginOverrides = {
368
+ ...overlaid.pluginOverrides,
369
+ ...selected.config.pluginOverrides,
370
+ };
371
+ continue;
372
+ }
373
+ if (key === "skillOverrides") {
374
+ // Global skill overrides were reset above (skill activation is project-local); the
375
+ // selected overrides still merge into that empty record and win per key.
376
+ overlaid.skillOverrides = { ...overlaid.skillOverrides, ...selected.config.skillOverrides };
377
+ continue;
378
+ }
379
+ if (key === "builtinPlugins") {
380
+ // Per-plugin options merge by plugin id: the selected entry wins per id, so
381
+ // `{id: {enabled: false}}` in the selected layer still disables that built-in while
382
+ // every other global entry stays.
383
+ overlaid.builtinPlugins = { ...overlaid.builtinPlugins, ...selected.config.builtinPlugins };
384
+ continue;
385
+ }
386
+ overlaid[key] = selected.config[key];
315
387
  }
316
388
  if (selected.keys.has("mcp") || selected.keys.has("mcpServers"))
317
389
  // Only servers merge upward; `mcp.allow` is a global/user preference and is deliberately
@@ -470,6 +542,7 @@ const SETTABLE_KEYS = {
470
542
  "limits.timeoutMs": SETTABLE_SECTIONS.limits.shape.timeoutMs,
471
543
  "pluginHooks.timeoutMs": SETTABLE_SECTIONS.pluginHooks.shape.timeoutMs,
472
544
  "tui.paddingX": SETTABLE_SECTIONS.tui.shape.paddingX,
545
+ "tui.contentPaddingX": SETTABLE_SECTIONS.tui.shape.contentPaddingX,
473
546
  "tui.skillSlashCommands": SETTABLE_SECTIONS.tui.shape.skillSlashCommands,
474
547
  "websearch.provider": SETTABLE_SECTIONS.websearch.shape.provider,
475
548
  "agents.active": SETTABLE_SECTIONS.agents.shape.active,
@@ -478,6 +551,33 @@ const SETTABLE_KEYS = {
478
551
  export function isSettableSettingKey(key) {
479
552
  return Object.prototype.hasOwnProperty.call(SETTABLE_KEYS, key);
480
553
  }
554
+ /** Unwraps optional/default/nullable wrappers down to the value schema. */
555
+ function settingLeaf(schema) {
556
+ let current = schema;
557
+ for (let depth = 0; depth < 8; depth++) {
558
+ const type = current.def?.type;
559
+ if ((type === "optional" || type === "default" || type === "nullable") &&
560
+ current.def?.innerType)
561
+ current = current.def.innerType;
562
+ else
563
+ break;
564
+ }
565
+ return current;
566
+ }
567
+ /** Every settable key with its value kind (and enum options), for settings editors. */
568
+ export function settableSettings() {
569
+ return Object.keys(SETTABLE_KEYS).map((key) => {
570
+ const leaf = settingLeaf(SETTABLE_KEYS[key]);
571
+ const type = leaf.def?.type;
572
+ if (type === "enum")
573
+ return { key, kind: "enum", options: (leaf.options ?? []).map((o) => String(o)) };
574
+ if (type === "boolean")
575
+ return { key, kind: "boolean" };
576
+ if (type === "number" || type === "int")
577
+ return { key, kind: "number" };
578
+ return { key, kind: "string" };
579
+ });
580
+ }
481
581
  /**
482
582
  * Atomically sets one user-facing setting in the global config file (`<config home>/config.json`)
483
583
  * while preserving every unrelated JSON field. The value is validated against the same zod leaf
@@ -18,6 +18,10 @@ export interface Session {
18
18
  options?: Record<string, unknown>;
19
19
  createdAt?: number;
20
20
  updatedAt?: number;
21
+ /** Root sessions pinned in web clients (v4). */
22
+ pinned?: boolean;
23
+ /** When a root session was archived in web clients (v4); absent when not archived. */
24
+ archivedAt?: number;
21
25
  }
22
26
  export interface ChildSessionRecord {
23
27
  id?: string;
@@ -30,6 +34,82 @@ export interface ChildSessionRecord {
30
34
  depth: number;
31
35
  options: Record<string, unknown>;
32
36
  }
37
+ /** `queued` and `running` are the only non-terminal run states. */
38
+ export type RunStatus = "queued" | "running" | "completed" | "turns_exceeded" | "failed" | "cancelled" | "interrupted";
39
+ export type TerminalRunStatus = Exclude<RunStatus, "queued" | "running">;
40
+ /** One `AgentRunner.run` execution (v4 `runs` table). `id` equals `RunEvent.runId`. */
41
+ export interface RunRecord {
42
+ id: string;
43
+ session: string;
44
+ status: RunStatus;
45
+ /** Client idempotency key; absent for TUI/headless runs. */
46
+ requestId?: string;
47
+ correlationId?: string;
48
+ /** Process that executes (or executed) the run, for startup reconciliation. */
49
+ ownerPid?: number;
50
+ model?: string;
51
+ createdAt: number;
52
+ startedAt?: number;
53
+ endedAt?: number;
54
+ error?: string;
55
+ usage?: {
56
+ input: number;
57
+ output: number;
58
+ cachedInput?: number;
59
+ };
60
+ }
61
+ export interface BeginRunInput {
62
+ id: string;
63
+ session: string;
64
+ /** `queued` for scheduled runs, `running` (default) when execution starts now. */
65
+ status?: "queued" | "running";
66
+ requestId?: string;
67
+ correlationId?: string;
68
+ model?: string;
69
+ }
70
+ export interface EndRunInput {
71
+ status: TerminalRunStatus;
72
+ error?: string;
73
+ usage?: {
74
+ input: number;
75
+ output: number;
76
+ cachedInput?: number;
77
+ };
78
+ }
79
+ export interface PageOptions {
80
+ /** Only rows with a sequence greater than this (ascending from there). */
81
+ after?: number;
82
+ /** Only rows with a sequence lower than this (the newest `limit` of them). */
83
+ before?: number;
84
+ limit?: number;
85
+ }
86
+ export interface MessagePage {
87
+ items: Array<{
88
+ seq: number;
89
+ message: Message;
90
+ compacted: boolean;
91
+ }>;
92
+ /** More rows exist beyond the page in the paging direction. */
93
+ hasMore: boolean;
94
+ }
95
+ export interface StoredEvent {
96
+ /** Global `events.seq` as a string (same value as `RunEvent.eventId`). */
97
+ eventId: string;
98
+ runId: string;
99
+ type: string;
100
+ data: unknown;
101
+ createdAt?: number;
102
+ correlationId?: string;
103
+ }
104
+ export interface EventPage {
105
+ items: StoredEvent[];
106
+ hasMore: boolean;
107
+ }
108
+ /** Optional tool-call metadata (v4 columns); stores may ignore it. */
109
+ export interface ToolCallMeta {
110
+ runId?: string;
111
+ effect?: string;
112
+ }
33
113
  export interface SessionStore {
34
114
  create(workspace: string, provider: string, model: string): Session;
35
115
  get(id: string): Session;
@@ -64,12 +144,39 @@ export interface SessionStore {
64
144
  overwrite(id: string, messages: Message[]): void;
65
145
  acquire(id: string): void;
66
146
  release(id: string): void;
67
- beginCall(session: string, call: ToolCall): void;
147
+ beginCall(session: string, call: ToolCall, meta?: ToolCallMeta): void;
68
148
  endCall(session: string, call: ToolCall, result: ToolResult): void;
69
149
  /** The persisted result of a completed tool call, for replay of rich (ui/image) parts. */
70
150
  callResult(session: string, callId: string): ToolResult | undefined;
71
151
  reconcile(id: string, acknowledge?: boolean): void;
72
- event(id: string, runId: string, type: string, data: unknown): void;
152
+ /**
153
+ * Persist a durable run event. Stores that assign a global, monotonic sequence return it; the
154
+ * runner exposes it as `RunEvent.eventId`. Returning nothing is allowed (no `eventId`).
155
+ */
156
+ event(id: string, runId: string, type: string, data: unknown, meta?: {
157
+ correlationId?: string;
158
+ }): number | void;
159
+ /**
160
+ * Records a run, or moves an existing `queued` run with the same id to `running`. A run whose
161
+ * `(session, requestId)` already exists is returned as is (`created: false`): idempotency.
162
+ */
163
+ beginRun?(input: BeginRunInput): {
164
+ run: RunRecord;
165
+ created: boolean;
166
+ };
167
+ /** Moves a non-terminal run to a terminal state; terminal runs are never overwritten. */
168
+ endRun?(id: string, input: EndRunInput): void;
169
+ runByRequest?(session: string, requestId: string): RunRecord | undefined;
170
+ /** Runs of a session, newest first. */
171
+ runs?(session: string, options?: {
172
+ limit?: number;
173
+ }): RunRecord[];
174
+ messagesPage?(session: string, options?: PageOptions & {
175
+ compacted?: boolean;
176
+ }): MessagePage;
177
+ eventsPage?(session: string, options?: PageOptions): EventPage;
178
+ /** Marks queued/running runs whose owner process is not alive as interrupted. */
179
+ interruptRuns?(): number;
73
180
  }
74
181
  export interface ContextSource {
75
182
  /** System instructions; `sessionId` scopes lazily attached nested instructions. */
@@ -1,4 +1,5 @@
1
1
  import { type Attachment, type Message, type ModelProvider, type RunEvent, type ToolDefinition } from "@alisio/sdk";
2
+ import type { PathAccess } from "../runtime/access.ts";
2
3
  import type { ApprovalHandler, ContextSource, Policy, RunnerExtensions, SessionStore } from "./contracts.ts";
3
4
  import type { ToolRegistry } from "./registry.ts";
4
5
  export interface CompactionSettings {
@@ -34,6 +35,11 @@ export interface RunnerOptions {
34
35
  compaction?: CompactionSettings;
35
36
  /** When set, write/process tools not allowed by the policy are offered and ask first. */
36
37
  approve?: ApprovalHandler;
38
+ /**
39
+ * Mediated path policy (workspace plus declared extra directories and external approval).
40
+ * When set, declared tool paths outside the allowed roots ask before the effect gate runs.
41
+ */
42
+ pathAccess?: PathAccess;
37
43
  /** Generic hooks (implemented by the plugin host) for compaction and session start. */
38
44
  extensions?: RunnerExtensions;
39
45
  /**
@@ -87,6 +93,10 @@ export interface RunOptions {
87
93
  timeoutMs?: number;
88
94
  /** Reasoning effort level for this run; beats the runner-level default when set. */
89
95
  reasoningEffort?: string;
96
+ /** Run id to use for this run's events (e.g. preassigned by an embedder); a UUID otherwise. */
97
+ runId?: string;
98
+ /** Copied to every event of this run as `RunEvent.correlationId` (e.g. an HTTP request id). */
99
+ correlationId?: string;
90
100
  }
91
101
  /**
92
102
  * Default cumulative token budget for one run: several context windows (each turn re-sends the