akanjs 3.0.0-alpha.40 → 3.0.0-alpha.41

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akanjs",
3
- "version": "3.0.0-alpha.40",
3
+ "version": "3.0.0-alpha.41",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
package/server/akanApp.ts CHANGED
@@ -54,6 +54,11 @@ export interface AkanAppOptions {
54
54
  port?: number;
55
55
  wsBasePort?: number;
56
56
  openapi?: boolean;
57
+ /**
58
+ * Boot only these modules and the ones they reach, in every child. Omitted or empty mounts every enabled
59
+ * module. Handed down as `AKAN_MODULES`, since each replica builds its own container.
60
+ */
61
+ modules?: string[];
57
62
  }
58
63
 
59
64
  interface AkanReplicaConfig {
@@ -86,6 +91,7 @@ export class AkanApp {
86
91
  readonly #port: number;
87
92
  readonly #wsBasePort: number;
88
93
  readonly #openapi?: boolean;
94
+ readonly #modules: string[];
89
95
  readonly #children = new Map<number, ChildState>();
90
96
  readonly #roomChildren = new Map<string, Set<number>>();
91
97
  readonly #childRooms = new Map<number, Set<string>>();
@@ -127,6 +133,7 @@ export class AkanApp {
127
133
  this.#port = Number(resolvedOptions.port ?? process.env.PORT ?? 8282);
128
134
  this.#wsBasePort = Number(resolvedOptions.wsBasePort ?? process.env.AKAN_WS_BASE_PORT ?? this.#port + 10_000);
129
135
  this.#openapi = resolvedOptions.openapi;
136
+ this.#modules = resolvedOptions.modules ?? [];
130
137
  }
131
138
 
132
139
  static #resolveServerPath(serverPath: string) {
@@ -297,6 +304,7 @@ export class AkanApp {
297
304
  AKAN_CHILD_SOCKET: upstream.http.socketPath,
298
305
  AKAN_CHILD_WS_PORT: upstream.ws ? String(upstream.ws.port) : "",
299
306
  ...(this.#openapi === undefined ? {} : { AKAN_OPENAPI: this.#openapi ? "true" : "false" }),
307
+ ...(this.#modules.length ? { AKAN_MODULES: this.#modules.join(",") } : {}),
300
308
  },
301
309
  ipc: (message) => this.#handleMessage(idx, message as AkanIpcMessage, proc),
302
310
  stdout: "pipe",
@@ -68,9 +68,9 @@ export class AkanOption<Env extends BackendEnv = BackendEnv> {
68
68
  else this.#getLlms.push(() => llmOrFn);
69
69
  return this;
70
70
  }
71
- getUses(env: Env): Record<string, PromiseOrObject<unknown>> {
72
- const uses = this.#getUses.map((fn) => fn(env));
73
- return Object.assign({}, ...uses);
71
+ /** Every entry in declaration order, duplicates kept: the boot stage rejects a key claimed twice. */
72
+ getUses(env: Env): [string, PromiseOrObject<unknown>][] {
73
+ return this.#getUses.flatMap((fn) => Object.entries(fn(env)));
74
74
  }
75
75
  getMiddlewares(): MiddlewareCls[] {
76
76
  return this.#middlewares;
@@ -41,6 +41,11 @@ export interface AkanServerOptions {
41
41
  openapi?: boolean;
42
42
  /** `/mcp` is mounted by default; `false` takes it off, and the object form carries the rest of its settings. */
43
43
  mcp?: boolean | McpServerOption;
44
+ /**
45
+ * Boot only these modules and the ones they reach; every other module stays out of the container, so its
46
+ * services, signals, routes and schedules do not exist. Omitted or empty mounts every enabled module.
47
+ */
48
+ modules?: string[];
44
49
  }
45
50
 
46
51
  export interface McpServerOption {
@@ -122,6 +127,7 @@ export class AkanServer {
122
127
  mcpAuth: McpAuthOption = AkanServer.#mcpAuthFromEnv();
123
128
  mcpOption: Omit<McpServerOption, "enabled" | "readOnly" | "auth"> = AkanServer.#mcpOptionFromEnv();
124
129
  serverMode: "federation" | "batch" | "all";
130
+ modules: string[];
125
131
  shutdownTimeoutMs = AkanServer.#defaultShutdownTimeoutMs();
126
132
 
127
133
  #di: DiLifecycle;
@@ -152,7 +158,9 @@ export class AkanServer {
152
158
  });
153
159
  this.setMcp(options?.mcp ?? this.mcp);
154
160
  this.serverMode = serverMode;
155
- this.#di = new DiLifecycle(this.env, serverMode, ...libs);
161
+
162
+ this.modules = options?.modules ?? AkanServer.#envList("AKAN_MODULES") ?? [];
163
+ this.#di = new DiLifecycle({ env: this.env, modules: this.modules }, ...libs);
156
164
  }
157
165
  setPrefix(prefix: string) {
158
166
  this.prefix = prefix;
@@ -591,7 +599,7 @@ export class AkanServer {
591
599
  !("database" in value) &&
592
600
  !("service" in value) &&
593
601
  !("scalar" in value) &&
594
- ("openapi" in value || "mcp" in value),
602
+ ("openapi" in value || "mcp" in value || "modules" in value),
595
603
  );
596
604
  }
597
605
 
@@ -29,17 +29,29 @@ import { getPredefinedAdaptor, predefinedAdaptorRole } from "./predefinedAdaptor
29
29
  import { collectAdaptors, resolveAdaptorHierarchy } from "./resolveAdaptorHierarchy";
30
30
  import { resolveServiceHierarchy } from "./resolveServiceHierarchy";
31
31
  import {
32
+ assertUniqueRegistrations,
32
33
  type DiModuleCandidate,
34
+ getModuleCascadeRefNames,
33
35
  getModuleDependencyRefNames,
34
36
  isDestroyableUse,
35
37
  normalizeAdaptorRefName,
36
38
  normalizeServiceRefName,
37
39
  normalizeSignalRefName,
40
+ type Registration,
38
41
  reasonMessage,
39
42
  runStage,
40
43
  toError,
41
44
  } from "./utils";
42
45
 
46
+ export interface DiLifecycleProps {
47
+ env: BackendEnv;
48
+ /**
49
+ * Boot only these modules and the ones they reach, leaving every other module out of the container. Omitted or
50
+ * empty mounts every module whose service is enabled.
51
+ */
52
+ modules?: string[];
53
+ }
54
+
43
55
  /**
44
56
  * Owns the app's DI container state (registry + live maps + init order) and
45
57
  * encapsulates every init / destroy step. `AkanServer` delegates to this so the
@@ -88,7 +100,7 @@ export class DiLifecycle {
88
100
  return !names.some((name) => process.env[name] === "false" || process.env[name] === "0");
89
101
  }
90
102
 
91
- constructor(env: BackendEnv, serverMode: "federation" | "batch" | "all", ...libs: AkanLib[]) {
103
+ constructor({ env, modules = [] }: DiLifecycleProps, ...libs: AkanLib[]) {
92
104
  this.#env = env;
93
105
 
94
106
  this.#predefinedAdaptor = { ...getPredefinedAdaptor(getEnv().databaseMode ?? "single") };
@@ -133,7 +145,7 @@ export class DiLifecycle {
133
145
  this.#scalar.set(mod.constant.refName, mod);
134
146
  });
135
147
  });
136
- const disabledModules = this.#resolveDisabledModules(databaseCandidates, serviceCandidates);
148
+ const disabledModules = this.#resolveDisabledModules(databaseCandidates, serviceCandidates, modules);
137
149
  databaseCandidates.forEach(({ refName, module }) => {
138
150
  if (disabledModules.has(refName)) return;
139
151
  this.#database.set(refName, module as DatabaseModule);
@@ -146,23 +158,40 @@ export class DiLifecycle {
146
158
  this.logger.info("agent relay is provided by a lib module — the framework's is skipped");
147
159
  if (!this.#scalar.has("agentTurn"))
148
160
  this.#scalar.set("agentTurn", { constant: agentTurnConstant, database: agentTurnDocument });
161
+ const adaptorClaims = new Map<string, AdaptorCls>();
162
+ const adaptorRegistrations: Registration[] = [];
163
+
164
+ const claimAdaptor = (adaptorCls: AdaptorCls, owner: string) => {
165
+ const claimed = adaptorClaims.get(adaptorCls.refName);
166
+ if (claimed === adaptorCls) return;
167
+ if (!claimed) adaptorClaims.set(adaptorCls.refName, adaptorCls);
168
+ adaptorRegistrations.push({ key: adaptorCls.refName, owner });
169
+ };
170
+ for (const [role, adaptorCls] of Object.entries(this.#predefinedAdaptor))
171
+ claimAdaptor(adaptorCls, `predefined adaptor "${role}"`);
149
172
  this.#database.forEach((mod) => {
150
173
  const { adaptor, schema } = DatabaseResolver.resolveDatabase(mod.constant, mod.database);
151
174
  this.#adaptor.set(adaptor.refName, adaptor);
175
+ claimAdaptor(adaptor, `database module "${mod.constant.refName}"`);
152
176
  this.#cascade.register(mod.constant, schema, mod.service.srv);
153
177
  });
154
178
  const services = [
155
179
  ...[...this.#service.values()].map((mod) => mod.service.srv),
156
180
  ...[...this.#database.values()].map((mod) => mod.service.srv),
157
181
  ];
158
- for (const adaptor of collectAdaptors(services)) {
159
- this.#adaptor.set(adaptor.refName, adaptor);
182
+ for (const service of services) {
183
+ for (const adaptor of collectAdaptors([service])) {
184
+ this.#adaptor.set(adaptor.refName, adaptor);
185
+ claimAdaptor(adaptor, `service "${service.refName}"`);
186
+ }
160
187
  }
188
+ assertUniqueRegistrations("adaptor", adaptorRegistrations);
161
189
  }
162
190
 
163
191
  #resolveDisabledModules(
164
192
  databaseCandidates: Map<string, DiModuleCandidate>,
165
193
  serviceCandidates: Map<string, DiModuleCandidate>,
194
+ modules: string[],
166
195
  ) {
167
196
  const candidates = new Map<string, DiModuleCandidate>([...databaseCandidates, ...serviceCandidates]);
168
197
  const disabledReasons = new Map<string, string>();
@@ -171,6 +200,14 @@ export class DiLifecycle {
171
200
  if (!module.service.srv.enabled) disabledReasons.set(refName, "service disabled");
172
201
  });
173
202
 
203
+ const selected = this.#resolveSelectedModules(candidates, modules);
204
+ if (selected) {
205
+ candidates.forEach(({ refName }) => {
206
+ if (!selected.has(refName) && !disabledReasons.has(refName))
207
+ disabledReasons.set(refName, 'not named by the "modules" option');
208
+ });
209
+ }
210
+
174
211
  let changed = true;
175
212
  while (changed) {
176
213
  changed = false;
@@ -194,6 +231,42 @@ export class DiLifecycle {
194
231
  return new Set(disabledReasons.keys());
195
232
  }
196
233
 
234
+ /**
235
+ * The named modules closed over everything they reach: the services and signals they inject, and the cascade
236
+ * edges whose absence fails `CascadeRunner.seal`. `null` means no selection was asked for.
237
+ *
238
+ * An unknown name is refused rather than ignored, because a typo would otherwise boot an app with the module
239
+ * silently missing — the one failure this option exists to make impossible.
240
+ */
241
+ #resolveSelectedModules(candidates: Map<string, DiModuleCandidate>, modules: string[]) {
242
+ if (!modules.length) return null;
243
+ const known = new Set([...candidates.keys(), ...this.#service.keys()]);
244
+ const unknown = modules.filter((refName) => !known.has(refName));
245
+ if (unknown.length) {
246
+ const registered = [...known].sort((a, b) => a.localeCompare(b)).join(", ");
247
+ throw new Error(
248
+ `[DI:modules] unknown module ${unknown.map((refName) => `"${refName}"`).join(", ")}. Registered: ${registered}`,
249
+ );
250
+ }
251
+ const selected = new Set<string>();
252
+ const pending = modules.filter((refName) => candidates.has(refName));
253
+ while (pending.length) {
254
+ const refName = pending.pop();
255
+ if (!refName || selected.has(refName)) continue;
256
+ selected.add(refName);
257
+ const candidate = candidates.get(refName);
258
+ if (!candidate) continue;
259
+ const dependencies = [
260
+ ...getModuleDependencyRefNames(candidate.module),
261
+ ...getModuleCascadeRefNames(candidate.module),
262
+ ];
263
+ for (const dependency of dependencies) if (candidates.has(dependency)) pending.push(dependency);
264
+ }
265
+ const mounted = [...selected].sort((a, b) => a.localeCompare(b)).join(", ");
266
+ this.logger.info(`Mounting ${selected.size} of ${candidates.size} module(s): ${mounted}`);
267
+ return selected;
268
+ }
269
+
197
270
  /** Run every init stage in dependency order and collect the generated routes. */
198
271
  async initializeAll(): Promise<SignalRoutes> {
199
272
  await this.#initializeUses();
@@ -393,14 +466,20 @@ export class DiLifecycle {
393
466
 
394
467
  async #initializeUses() {
395
468
 
396
- const uses = Object.assign(
397
- { llmOption: Object.assign({}, ...this.#libs.map((lib) => lib.option.getLlm(this.#env))) },
398
- ...this.#libs.map((lib) => lib.option.getUses(this.#env)),
399
- );
400
- const entries = Object.entries(uses);
469
+ const entries = [
470
+ {
471
+ key: "llmOption",
472
+ owner: "the framework",
473
+ value: Object.assign({}, ...this.#libs.map((lib) => lib.option.getLlm(this.#env))) as unknown,
474
+ },
475
+ ...this.#libs.flatMap((lib) =>
476
+ lib.option.getUses(this.#env).map(([key, value]) => ({ key, owner: `lib "${lib.name}"`, value })),
477
+ ),
478
+ ];
479
+ assertUniqueRegistrations("use", entries);
401
480
  await runStage(
402
481
  "uses",
403
- entries.map(([key, value]) => ({
482
+ entries.map(({ key, value }) => ({
404
483
  label: `uses:${key}`,
405
484
  run: async () => {
406
485
  const useValue = value instanceof Promise ? await value : value;
@@ -1,5 +1,6 @@
1
1
  import { INJECT_META } from "akanjs/base";
2
2
  import { lowerlize } from "akanjs/common";
3
+ import { ConstantRegistry } from "akanjs/constant";
3
4
  import type { InjectInfo } from "akanjs/service";
4
5
  import type { DatabaseModule, ServiceModule } from "../akanLib";
5
6
 
@@ -67,6 +68,45 @@ export const getModuleDependencyRefNames = (mod: DatabaseModule | ServiceModule)
67
68
  return dependencies;
68
69
  };
69
70
 
71
+ /**
72
+ * The modules a cascade edge forces this one to be mounted with: a `removeRef` target and a monomorphic
73
+ * `removeWith` owner both fail `CascadeRunner.seal` when they are absent, so they are boot dependencies the
74
+ * inject graph cannot see. A polymorphic owner list is exempt — it spans optional modules by design.
75
+ */
76
+ export const getModuleCascadeRefNames = (mod: DatabaseModule | ServiceModule) => {
77
+ const dependencies = new Set<string>();
78
+ if (!("constant" in mod)) return dependencies;
79
+ const { cascade } = mod.constant.full;
80
+ for (const modelRef of cascade.removeRef.values()) dependencies.add(ConstantRegistry.getRefName(modelRef));
81
+ for (const path of cascade.removeWith.values()) {
82
+ if (path.typeValues.length) continue;
83
+ dependencies.add(path.refName ?? ConstantRegistry.getRefName(path.modelRef as never));
84
+ }
85
+ return dependencies;
86
+ };
87
+
88
+ export interface Registration {
89
+ key: string;
90
+ /** What claimed the key, phrased for a boot error: `predefined adaptor "storage"`, `lib "shared"`. */
91
+ owner: string;
92
+ }
93
+
94
+ /**
95
+ * A key registered twice is silently last-write-wins everywhere downstream — an app that meant to add a second
96
+ * adaptor gets one, and the other's `onInit` never runs. Refuse the boot instead, naming both claimants.
97
+ */
98
+ export const assertUniqueRegistrations = (kind: string, registrations: Registration[]) => {
99
+ const claimed = new Map<string, string>();
100
+ const clashes: string[] = [];
101
+ for (const { key, owner } of registrations) {
102
+ const previous = claimed.get(key);
103
+ if (previous) clashes.push(` • "${key}" is registered by ${previous} and by ${owner}`);
104
+ else claimed.set(key, owner);
105
+ }
106
+ if (!clashes.length) return;
107
+ throw new Error(`[DI:${kind}] ${clashes.length} duplicate registration(s):\n${clashes.join("\n")}`);
108
+ };
109
+
70
110
  /**
71
111
  * Run every task in parallel and, if any rejects, throw a single
72
112
  * `AggregateError` that enumerates every failing label + cause. This replaces
@@ -0,0 +1,139 @@
1
+ import { AgentAbort, AgentProgress } from "../../vendor/use-agentic";
2
+
3
+ /** The reading half of the store an in-page wait needs. `AgentBridge` is one; a test can be another. */
4
+ export interface StateSource {
5
+ read(key: string, viewKey?: string): unknown;
6
+ subscribe(listener: () => void): () => void;
7
+ }
8
+
9
+ export interface StateWaitOptions {
10
+ key: string;
11
+ viewKey?: string;
12
+ /** Settle when the key reads exactly this. Null waits for it to change from whatever it holds now. */
13
+ equals?: string | null;
14
+ /** Straight off the tool argument, so it may be anything; `StateWait.seconds` is what makes it a number. */
15
+ seconds?: unknown;
16
+ }
17
+
18
+ /**
19
+ * One `waitFor` call: park until a published state key settles, or until the timeout says how it is going.
20
+ *
21
+ * Two clocks, because neither covers the other. The store's own subscription catches the value changing, which is
22
+ * the whole point of the tool and has to land immediately. The tick catches what the store never announces —
23
+ * `retainLive` / `releaseLive` mutate the live-key map without notifying any listener, so a page navigated away
24
+ * from mid-wait would otherwise hold the turn until the timeout — and the countdown row needs a tick anyway.
25
+ *
26
+ * Nothing here throws on a wait that ran out. A key still reading `generating` after two minutes is an answer, not
27
+ * a failure, and the model is the one that decides whether to wait again.
28
+ */
29
+ export class StateWait {
30
+ static readonly tickMs = 1000;
31
+ static readonly defaultSeconds = 120;
32
+ static readonly maxSeconds = 600;
33
+
34
+ /** Clamped rather than refused: a model that asks for an hour gets the longest wait on offer and reads how long. */
35
+ static seconds(value: unknown): number {
36
+ if (typeof value !== "number" || !Number.isFinite(value)) return StateWait.defaultSeconds;
37
+ return Math.min(Math.max(Math.round(value), 1), StateWait.maxSeconds);
38
+ }
39
+
40
+ /** A string is itself; everything else is JSON, so a number, a boolean and null each compare as they are written. */
41
+ static print(value: unknown): string {
42
+ if (typeof value === "string") return value;
43
+ try {
44
+ return JSON.stringify(value) ?? String(value);
45
+ } catch {
46
+ return String(value);
47
+ }
48
+ }
49
+
50
+ readonly #source: StateSource;
51
+ readonly #key: string;
52
+ readonly #viewKey: string;
53
+ readonly #equals: string | null;
54
+ readonly #seconds: number;
55
+ #was = "";
56
+ #now = "";
57
+ #elapsed = 0;
58
+ #settled: boolean = false;
59
+ #ticker: ReturnType<typeof setInterval> | undefined;
60
+ #unsubscribe: (() => void) | undefined;
61
+ #signal: AbortSignal | null = null;
62
+ #resolve: ((message: string) => void) | null = null;
63
+ #reject: ((error: Error) => void) | null = null;
64
+
65
+ constructor(source: StateSource, { key, viewKey = "", equals = null, seconds }: StateWaitOptions) {
66
+ this.#source = source;
67
+ this.#key = key;
68
+ this.#viewKey = viewKey;
69
+ this.#equals = equals;
70
+ this.#seconds = StateWait.seconds(seconds);
71
+ }
72
+
73
+ run(): Promise<string> {
74
+ this.#was = StateWait.print(this.#source.read(this.#key, this.#viewKey));
75
+ this.#now = this.#was;
76
+ if (this.#equals !== null && this.#was === this.#equals)
77
+ return Promise.resolve(`${this.#key} is already ${this.#was}.`);
78
+ return new Promise<string>((resolve, reject) => {
79
+ this.#resolve = resolve;
80
+ this.#reject = reject;
81
+ this.#signal = AgentAbort.current;
82
+ if (this.#signal?.aborted) {
83
+ this.#abort();
84
+ return;
85
+ }
86
+ this.#signal?.addEventListener("abort", this.#abort);
87
+ this.#unsubscribe = this.#source.subscribe(this.#check);
88
+ this.#ticker = setInterval(this.#tick, StateWait.tickMs);
89
+ AgentProgress.report("", { done: 0, total: this.#seconds });
90
+ });
91
+ }
92
+
93
+ /** The session races every call against the same signal; honouring it here is what stops the timer. */
94
+ #abort = () => {
95
+ this.#stop();
96
+ this.#reject?.(new Error("The user aborted the turn."));
97
+ };
98
+
99
+ #check = () => {
100
+ if (this.#settled) return;
101
+ let now: string;
102
+ try {
103
+ now = StateWait.print(this.#source.read(this.#key, this.#viewKey));
104
+ } catch (error) {
105
+
106
+ this.#end(`Stopped waiting: ${error instanceof Error ? error.message : String(error)}`);
107
+ return;
108
+ }
109
+ this.#now = now;
110
+ if (this.#equals === null ? now !== this.#was : now === this.#equals) this.#end(`${this.#key} is now ${now}.`);
111
+ };
112
+
113
+ #tick = () => {
114
+ this.#check();
115
+ if (this.#settled) return;
116
+ this.#elapsed += 1;
117
+ if (this.#elapsed >= this.#seconds) {
118
+ this.#end(
119
+ `${this.#key} is still ${this.#now} after ${this.#seconds}s. Call waitFor again to keep waiting, or tell the user it is taking longer than expected.`,
120
+ );
121
+ return;
122
+ }
123
+ AgentProgress.report("", { done: this.#elapsed, total: this.#seconds });
124
+ };
125
+
126
+ #end(message: string) {
127
+ this.#stop();
128
+ this.#resolve?.(message);
129
+ }
130
+
131
+ #stop() {
132
+ this.#settled = true;
133
+ clearInterval(this.#ticker);
134
+ this.#ticker = undefined;
135
+ this.#unsubscribe?.();
136
+ this.#unsubscribe = undefined;
137
+ this.#signal?.removeEventListener("abort", this.#abort);
138
+ }
139
+ }
@@ -4,6 +4,7 @@ import { AgentBridge } from "./AgentBridge";
4
4
  import { ScreenReader } from "./ScreenReader";
5
5
  import { ScreenSettle } from "./ScreenSettle";
6
6
  import { ScreenTarget } from "./ScreenTarget";
7
+ import { StateWait } from "./StateWait";
7
8
 
8
9
  /**
9
10
  * The tools every akan screen has whatever it declares: where it can go, what it is rendering, what one of the
@@ -38,6 +39,7 @@ export class StoreSurfaceSource implements SurfaceSource {
38
39
  StoreSurfaceSource.#goBack(),
39
40
  StoreSurfaceSource.#readScreen(viewKey),
40
41
  this.#readState(viewKey),
42
+ this.#waitFor(viewKey),
41
43
  StoreSurfaceSource.#highlight(viewKey),
42
44
  ];
43
45
  this.#builtins.set(viewKey, builtins);
@@ -190,6 +192,65 @@ export class StoreSurfaceSource implements SurfaceSource {
190
192
  };
191
193
  }
192
194
 
195
+ /**
196
+ * The wait that costs no model turns.
197
+ *
198
+ * An agent that started something slow has one way to learn it finished: ask again, and again, a full round trip
199
+ * per look. That burns the turn budget in seconds and reads to the user as a loop. This parks the call instead —
200
+ * the session already awaits `run`, so the turn simply takes as long as the work does, and the change report
201
+ * that follows carries whatever landed while it waited.
202
+ *
203
+ * It watches a published state key rather than sleeping for a fixed span. A bare sleep would only make the
204
+ * polling slower, and a screen that can report progress at all reports it into the store; a screen that reports
205
+ * none publishes a key with `st.expose`, which is worth doing anyway, since the key is then readable too.
206
+ */
207
+ #waitFor(viewKey: string): ToolEntry {
208
+ return {
209
+ name: "waitFor",
210
+ description:
211
+ "Wait here until one of this page's state keys settles, instead of asking again and again. Use it after starting something slow — a generation, an upload, a long job — whenever a state key reports how it is going: the turn pauses with no model round trip and resumes the moment the key moves. Keys are listed in the state context block. Returns what the key holds when the wait ends.",
212
+ parameters: {
213
+ type: "object",
214
+ properties: {
215
+ key: { type: "string", description: "The state key to watch, spelled as the state context block lists it." },
216
+ equals: {
217
+ type: "string",
218
+ description:
219
+ "Wait until the key reads exactly this, compared as text. Omit to wait until it changes from whatever it holds now.",
220
+ },
221
+ timeoutSeconds: {
222
+ type: "number",
223
+ description: `How long to wait before answering with the key's current value. Default ${StateWait.defaultSeconds}, maximum ${StateWait.maxSeconds}. Running out is not a failure — call waitFor again to keep waiting.`,
224
+ },
225
+ },
226
+ required: ["key"],
227
+ additionalProperties: false,
228
+ },
229
+
230
+ effect: "state",
231
+ run: async (args) => {
232
+ this.#bridge ??= AgentBridge.of();
233
+ const key = typeof args.key === "string" ? args.key.trim() : "";
234
+ if (!key) throw new Error("waitFor needs a key to watch.");
235
+ const live = this.#bridge.readableKeys(viewKey);
236
+ if (!live.includes(key))
237
+ throw new Error(
238
+ `No state key named ${key} is read by this screen. ${
239
+ live.length
240
+ ? `Keys here: ${live.join(", ")}.`
241
+ : "This screen reads no state keys, so there is nothing to wait for."
242
+ }`,
243
+ );
244
+ return await new StateWait(this.#bridge, {
245
+ key,
246
+ viewKey,
247
+ equals: typeof args.equals === "string" ? args.equals : null,
248
+ seconds: args.timeoutSeconds,
249
+ }).run();
250
+ },
251
+ };
252
+ }
253
+
193
254
  /**
194
255
  * The ring goes on once the scroll lands, not when it starts: a smooth scroll across a long page takes most of a
195
256
  * second, and a flash begun at the top is already fading by the time the user's eye arrives. Settles on the
@@ -1,9 +1,13 @@
1
+
2
+
3
+ export { AgentAbort, AgentProgress } from "../../vendor/use-agentic";
1
4
  export * from "./AgentBridge";
2
5
  export * from "./AgentContext";
3
6
  export * from "./AgentPrompts";
4
7
  export * from "./ScreenReader";
5
8
  export * from "./ScreenSettle";
6
9
  export * from "./ScreenTarget";
10
+ export * from "./StateWait";
7
11
  export * from "./StoreCatalogue";
8
12
  export * from "./StoreSurfaceSource";
9
13
  export * from "./storeSurface";
@@ -7,6 +7,11 @@ export interface AkanAppOptions {
7
7
  port?: number;
8
8
  wsBasePort?: number;
9
9
  openapi?: boolean;
10
+ /**
11
+ * Boot only these modules and the ones they reach, in every child. Omitted or empty mounts every enabled
12
+ * module. Handed down as `AKAN_MODULES`, since each replica builds its own container.
13
+ */
14
+ modules?: string[];
10
15
  }
11
16
  /** Gateway/orchestrator that starts Akan child servers and proxies HTTP/WebSocket traffic. */
12
17
  export declare class AkanApp {
@@ -33,7 +33,8 @@ export declare class AkanOption<Env extends BackendEnv = BackendEnv> {
33
33
  setAgentAccess(guards: GuardCls | GuardCls[] | null): this;
34
34
  /** Settings for whichever adaptor fills `LlmAdaptorRole`, injected into it as the `llmOption` use. */
35
35
  setLlm(llmOrFn: LlmOption | ((env: Env) => LlmOption)): this;
36
- getUses(env: Env): Record<string, PromiseOrObject<unknown>>;
36
+ /** Every entry in declaration order, duplicates kept: the boot stage rejects a key claimed twice. */
37
+ getUses(env: Env): [string, PromiseOrObject<unknown>][];
37
38
  getMiddlewares(): MiddlewareCls[];
38
39
  getAdaptorOverrides(): AdaptorOverride[];
39
40
  getWebProxies(): WebProxyRegistration[];
@@ -14,6 +14,11 @@ export interface AkanServerOptions {
14
14
  openapi?: boolean;
15
15
  /** `/mcp` is mounted by default; `false` takes it off, and the object form carries the rest of its settings. */
16
16
  mcp?: boolean | McpServerOption;
17
+ /**
18
+ * Boot only these modules and the ones they reach; every other module stays out of the container, so its
19
+ * services, signals, routes and schedules do not exist. Omitted or empty mounts every enabled module.
20
+ */
21
+ modules?: string[];
17
22
  }
18
23
  export interface McpServerOption {
19
24
  enabled?: boolean;
@@ -77,6 +82,7 @@ export declare class AkanServer {
77
82
  mcpAuth: McpAuthOption;
78
83
  mcpOption: Omit<McpServerOption, "enabled" | "readOnly" | "auth">;
79
84
  serverMode: "federation" | "batch" | "all";
85
+ modules: string[];
80
86
  shutdownTimeoutMs: number;
81
87
  constructor(name?: string, env?: BackendEnv, serverMode?: "federation" | "batch" | "all", ...libsOrOptions: (AkanLib | AkanServerOptions)[]);
82
88
  setPrefix(prefix: string): this;
@@ -6,6 +6,14 @@ import type { ServerSignal, ServerSignalCls } from "../../signal/serverSignal.d.
6
6
  import type { AkanLib, DatabaseModule, ScalarModule, ServiceModule } from "../akanLib.d.ts";
7
7
  import type { WebProxyRegistration } from "../proxy.d.ts";
8
8
  import type { SignalRoutes } from "../types.d.ts";
9
+ export interface DiLifecycleProps {
10
+ env: BackendEnv;
11
+ /**
12
+ * Boot only these modules and the ones they reach, leaving every other module out of the container. Omitted or
13
+ * empty mounts every module whose service is enabled.
14
+ */
15
+ modules?: string[];
16
+ }
9
17
  /**
10
18
  * Owns the app's DI container state (registry + live maps + init order) and
11
19
  * encapsulates every init / destroy step. `AkanServer` delegates to this so the
@@ -31,7 +39,7 @@ export declare class DiLifecycle {
31
39
  adaptor: ReadonlyMap<string, AdaptorCls>;
32
40
  middleware: ReadonlyMap<string, MiddlewareCls>;
33
41
  };
34
- constructor(env: BackendEnv, serverMode: "federation" | "batch" | "all", ...libs: AkanLib[]);
42
+ constructor({ env, modules }: DiLifecycleProps, ...libs: AkanLib[]);
35
43
  /** Run every init stage in dependency order and collect the generated routes. */
36
44
  initializeAll(): Promise<SignalRoutes>;
37
45
  destroyAll(): Promise<void>;
@@ -15,6 +15,22 @@ export declare const normalizeServiceRefName: (refName: string) => string;
15
15
  export declare const normalizeSignalRefName: (refName: string) => string;
16
16
  export declare const normalizeAdaptorRefName: (refName: string) => string;
17
17
  export declare const getModuleDependencyRefNames: (mod: DatabaseModule | ServiceModule) => Set<string>;
18
+ /**
19
+ * The modules a cascade edge forces this one to be mounted with: a `removeRef` target and a monomorphic
20
+ * `removeWith` owner both fail `CascadeRunner.seal` when they are absent, so they are boot dependencies the
21
+ * inject graph cannot see. A polymorphic owner list is exempt — it spans optional modules by design.
22
+ */
23
+ export declare const getModuleCascadeRefNames: (mod: DatabaseModule | ServiceModule) => Set<string>;
24
+ export interface Registration {
25
+ key: string;
26
+ /** What claimed the key, phrased for a boot error: `predefined adaptor "storage"`, `lib "shared"`. */
27
+ owner: string;
28
+ }
29
+ /**
30
+ * A key registered twice is silently last-write-wins everywhere downstream — an app that meant to add a second
31
+ * adaptor gets one, and the other's `onInit` never runs. Refuse the boot instead, naming both claimants.
32
+ */
33
+ export declare const assertUniqueRegistrations: (kind: string, registrations: Registration[]) => void;
18
34
  /**
19
35
  * Run every task in parallel and, if any rejects, throw a single
20
36
  * `AggregateError` that enumerates every failing label + cause. This replaces
@@ -0,0 +1,36 @@
1
+ /** The reading half of the store an in-page wait needs. `AgentBridge` is one; a test can be another. */
2
+ export interface StateSource {
3
+ read(key: string, viewKey?: string): unknown;
4
+ subscribe(listener: () => void): () => void;
5
+ }
6
+ export interface StateWaitOptions {
7
+ key: string;
8
+ viewKey?: string;
9
+ /** Settle when the key reads exactly this. Null waits for it to change from whatever it holds now. */
10
+ equals?: string | null;
11
+ /** Straight off the tool argument, so it may be anything; `StateWait.seconds` is what makes it a number. */
12
+ seconds?: unknown;
13
+ }
14
+ /**
15
+ * One `waitFor` call: park until a published state key settles, or until the timeout says how it is going.
16
+ *
17
+ * Two clocks, because neither covers the other. The store's own subscription catches the value changing, which is
18
+ * the whole point of the tool and has to land immediately. The tick catches what the store never announces —
19
+ * `retainLive` / `releaseLive` mutate the live-key map without notifying any listener, so a page navigated away
20
+ * from mid-wait would otherwise hold the turn until the timeout — and the countdown row needs a tick anyway.
21
+ *
22
+ * Nothing here throws on a wait that ran out. A key still reading `generating` after two minutes is an answer, not
23
+ * a failure, and the model is the one that decides whether to wait again.
24
+ */
25
+ export declare class StateWait {
26
+ #private;
27
+ static readonly tickMs = 1000;
28
+ static readonly defaultSeconds = 120;
29
+ static readonly maxSeconds = 600;
30
+ /** Clamped rather than refused: a model that asks for an hour gets the longest wait on offer and reads how long. */
31
+ static seconds(value: unknown): number;
32
+ /** A string is itself; everything else is JSON, so a number, a boolean and null each compare as they are written. */
33
+ static print(value: unknown): string;
34
+ constructor(source: StateSource, { key, viewKey, equals, seconds }: StateWaitOptions);
35
+ run(): Promise<string>;
36
+ }
@@ -1,9 +1,11 @@
1
+ export { AgentAbort, AgentProgress } from "../../vendor/use-agentic.d.ts";
1
2
  export * from "./AgentBridge.d.ts";
2
3
  export * from "./AgentContext.d.ts";
3
4
  export * from "./AgentPrompts.d.ts";
4
5
  export * from "./ScreenReader.d.ts";
5
6
  export * from "./ScreenSettle.d.ts";
6
7
  export * from "./ScreenTarget.d.ts";
8
+ export * from "./StateWait.d.ts";
7
9
  export * from "./StoreCatalogue.d.ts";
8
10
  export * from "./StoreSurfaceSource.d.ts";
9
11
  export * from "./storeSurface.d.ts";
@@ -0,0 +1,17 @@
1
+ /**
2
+ * The abort signal of the tool call running now.
3
+ *
4
+ * Reached through a module slot rather than a parameter for the same reason `AgentProgress` is — work several
5
+ * frames down, a store action or a poll loop, reads it without every signature between here and there growing a
6
+ * channel argument, and a session executes tool calls one at a time. Outside a call `current` is null, so a tool
7
+ * that honours it runs unchanged in a test, under the dock, and on the server.
8
+ *
9
+ * Honouring it is optional: the session races every call against the same signal, so Stop lands whatever a tool
10
+ * does. What reading it buys is the tool's own cleanup — a timer that would otherwise keep ticking for the rest of
11
+ * its timeout with nobody left to answer.
12
+ */
13
+ export declare class AgentAbort {
14
+ #private;
15
+ static get current(): AbortSignal | null;
16
+ static run<T>(signal: AbortSignal, exec: () => Promise<T> | T): Promise<T>;
17
+ }
@@ -1,3 +1,4 @@
1
+ export * from "./AgentAbort.d.ts";
1
2
  export * from "./Agentic.d.ts";
2
3
  export * from "./AgenticSurface.d.ts";
3
4
  export * from "./AgentProgress.d.ts";
@@ -0,0 +1,29 @@
1
+ /**
2
+ * The abort signal of the tool call running now.
3
+ *
4
+ * Reached through a module slot rather than a parameter for the same reason `AgentProgress` is — work several
5
+ * frames down, a store action or a poll loop, reads it without every signature between here and there growing a
6
+ * channel argument, and a session executes tool calls one at a time. Outside a call `current` is null, so a tool
7
+ * that honours it runs unchanged in a test, under the dock, and on the server.
8
+ *
9
+ * Honouring it is optional: the session races every call against the same signal, so Stop lands whatever a tool
10
+ * does. What reading it buys is the tool's own cleanup — a timer that would otherwise keep ticking for the rest of
11
+ * its timeout with nobody left to answer.
12
+ */
13
+ export class AgentAbort {
14
+ static #signal: AbortSignal | null = null;
15
+
16
+ static get current(): AbortSignal | null {
17
+ return AgentAbort.#signal;
18
+ }
19
+
20
+ static async run<T>(signal: AbortSignal, exec: () => Promise<T> | T): Promise<T> {
21
+ const outer = AgentAbort.#signal;
22
+ AgentAbort.#signal = signal;
23
+ try {
24
+ return await exec();
25
+ } finally {
26
+ AgentAbort.#signal = outer;
27
+ }
28
+ }
29
+ }
@@ -1,3 +1,4 @@
1
+ import { AgentAbort } from "./AgentAbort";
1
2
  import { AgentProgress, type AgentProgressReport } from "./AgentProgress";
2
3
  import type {
3
4
  AgentRunner,
@@ -300,12 +301,14 @@ export class AgentSession {
300
301
  }
301
302
  const before = this.#surface.snapshot();
302
303
  try {
303
- const result = await AgentProgress.run(
304
- (report) => {
305
- this.#progress = { ...report, callId: call.id };
306
- this.#notify();
307
- },
308
- () => this.#surface.call(call.name, call.args),
304
+ const result = await AgentAbort.run(signal, () =>
305
+ AgentProgress.run(
306
+ (report) => {
307
+ this.#progress = { ...report, callId: call.id };
308
+ this.#notify();
309
+ },
310
+ () => AgentSession.#raced(this.#surface.call(call.name, call.args), signal),
311
+ ),
309
312
  );
310
313
 
311
314
  if (entry.effect !== "query") await this.#options.settle?.();
@@ -402,6 +405,35 @@ export class AgentSession {
402
405
  });
403
406
  }
404
407
 
408
+ /**
409
+ * The call, or the abort — whichever lands first.
410
+ *
411
+ * A tool is handed the signal through `AgentAbort` and may stop itself, but nothing obliges it to, and a tool
412
+ * that waits on a two-minute job is exactly the one a user reaches for Stop during. Without this race the loop
413
+ * stays parked inside the call for those two minutes with the chat still showing a turn in flight.
414
+ *
415
+ * The losing promise is left running rather than cancelled: the work is usually a job a server is already
416
+ * doing, and throwing away a result that is about to land helps nobody. Both of its outcomes are handled here,
417
+ * so a late failure settles nothing instead of surfacing as an unhandled rejection.
418
+ */
419
+ static #raced<T>(work: Promise<T>, signal: AbortSignal): Promise<T> {
420
+ return new Promise<T>((resolve, reject) => {
421
+ const onAbort = () => reject(new Error("The user aborted the turn."));
422
+ work.then(
423
+ (value) => {
424
+ signal.removeEventListener("abort", onAbort);
425
+ resolve(value);
426
+ },
427
+ (error: unknown) => {
428
+ signal.removeEventListener("abort", onAbort);
429
+ reject(error instanceof Error ? error : new Error(String(error)));
430
+ },
431
+ );
432
+ if (signal.aborted) onAbort();
433
+ else signal.addEventListener("abort", onAbort, { once: true });
434
+ });
435
+ }
436
+
405
437
  static #confirmMessage(name: string, entry: ToolEntry, args: Record<string, unknown>): string | null {
406
438
  const confirm = entry.confirm;
407
439
  if (confirm === undefined || confirm === false) return null;
@@ -1,4 +1,5 @@
1
1
  "use client";
2
+ export * from "./AgentAbort";
2
3
  export * from "./Agentic";
3
4
  export * from "./AgenticSurface";
4
5
  export * from "./AgentProgress";