@stratusagent/gateway 0.4.0 → 0.7.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.
package/dist/index.js CHANGED
@@ -2,10 +2,11 @@ import { createHash, randomUUID } from 'node:crypto';
2
2
  import { chmodSync, mkdirSync } from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { DatabaseSync } from 'node:sqlite';
5
- import { AgentRegistry, AgentRunner, EventBus, RunAbortedError, ToolRegistry, readPendingApproval, } from '@stratusagent/core';
5
+ import { AgentRegistry, AgentRunner, EventBus, RunAbortedError, SkillRegistry, ToolRegistry, createSkillReadTool, missingSkillRequirements, readPendingApproval, } from '@stratusagent/core';
6
6
  import { createDelegateTool, createRememberTool, } from '@stratusagent/agents';
7
7
  import { createLocalCommandExecutor } from '@stratusagent/executor-local';
8
- import { createDemoTool, createFileMemoryStore, createRuntimeProvider, DEFAULT_STRATUS_AGENT, loadRosterSouls, FALLBACK_ACTIVE_METADATA_KEY, loadSoulFile, memoryFilePath, migrateLegacyMemory, ConfigFileError, loadConfigFile, readNonEmptyString, resolveConfigLocation, resolveConfiguredSoul, resolveRuntimeConfig, stratusHomePath, withLegacyDefaultMemories, } from '@stratusagent/state';
8
+ import { loadPlugins, } from '@stratusagent/plugins';
9
+ import { createDemoTool, createFileMemoryStore, createRuntimeProvider, DEFAULT_STRATUS_AGENT, loadOperatorSkills, loadRosterSouls, FALLBACK_ACTIVE_METADATA_KEY, loadSoulFile, memoryFilePath, migrateLegacyMemory, ConfigFileError, loadConfigFile, resolveConfigLocation, resolveConfiguredSoul, resolveRuntimeConfig, applySoulPins, stratusHomePath, workspacesDirPath, withLegacyDefaultMemories, } from '@stratusagent/state';
9
10
  const SESSIONS_DB_FILENAME = 'sessions.db';
10
11
  const DEFAULT_IDLE_TIMEOUT_MS = 120_000;
11
12
  /**
@@ -107,11 +108,74 @@ export class SqliteSessionStore {
107
108
  .all(status);
108
109
  return rows.map((row) => row.id);
109
110
  }
110
- /** Newest-first session listing for one agent (or all agents). */
111
- list(agentId) {
111
+ /**
112
+ * When each agent last did anything, and how many of its sessions are live
113
+ * right now.
114
+ *
115
+ * One aggregate over the indexed columns — no conversation body is
116
+ * deserialized, for the same reason `listIdsByStatus` returns ids: a
117
+ * roster view asks this on every load, and it must not cost a JSON parse
118
+ * per session to answer.
119
+ *
120
+ * Both halves are needed and neither is sufficient. `lastActiveAt` alone
121
+ * reads a turn parked on a human as idle, because the save that recorded
122
+ * the park is the last thing that touched the row — and a turn waiting
123
+ * twenty minutes on an approval is exactly when someone wants to see the
124
+ * agent lit. `activeSessions` alone loses an agent that finished a moment
125
+ * ago. The caller decides what window counts as "recent"; a daemon that
126
+ * baked one in would need upgrading to change it.
127
+ */
128
+ /**
129
+ * How many sessions are in each state.
130
+ *
131
+ * A grouped count, not a listing that gets counted. The table grows for the
132
+ * life of an install and a health endpoint is polled, so materialising one
133
+ * object per historical session to produce five numbers gets steadily
134
+ * slower at exactly the thing meant to report that the daemon is fine.
135
+ */
136
+ countByStatus() {
137
+ const rows = this.db
138
+ .prepare('SELECT status, COUNT(*) AS total FROM sessions GROUP BY status')
139
+ .all();
140
+ const counts = {};
141
+ for (const row of rows) {
142
+ counts[row.status] = Number(row.total);
143
+ }
144
+ return counts;
145
+ }
146
+ lastActivityByAgent() {
147
+ const rows = this.db
148
+ .prepare(`
149
+ SELECT agent_id,
150
+ MAX(updated_at) AS last_active_at,
151
+ SUM(CASE WHEN status IN ('running', 'pending_approval') THEN 1 ELSE 0 END) AS active_sessions
152
+ FROM sessions
153
+ GROUP BY agent_id
154
+ `)
155
+ .all();
156
+ const activity = {};
157
+ for (const row of rows) {
158
+ activity[row.agent_id] = {
159
+ lastActiveAt: row.last_active_at,
160
+ activeSessions: Number(row.active_sessions),
161
+ };
162
+ }
163
+ return activity;
164
+ }
165
+ /**
166
+ * Newest-first session listing for one agent (or all agents).
167
+ *
168
+ * `limit` is not decoration: this table grows for the life of an install,
169
+ * and a surface that renders "recent conversations" would otherwise pull
170
+ * every session anyone has ever had to show ten of them.
171
+ */
172
+ list(agentId, limit) {
173
+ // -1 is SQLite's "no limit", so one prepared statement serves both cases
174
+ // rather than four.
175
+ const bound = limit !== undefined && Number.isInteger(limit) && limit >= 0 ? limit : -1;
112
176
  const rows = (agentId
113
- ? this.db.prepare('SELECT id, agent_id, status, created_at, updated_at FROM sessions WHERE agent_id = ? ORDER BY updated_at DESC').all(agentId)
114
- : this.db.prepare('SELECT id, agent_id, status, created_at, updated_at FROM sessions ORDER BY updated_at DESC').all());
177
+ ? this.db.prepare('SELECT id, agent_id, status, created_at, updated_at FROM sessions WHERE agent_id = ? ORDER BY updated_at DESC LIMIT ?').all(agentId, bound)
178
+ : this.db.prepare('SELECT id, agent_id, status, created_at, updated_at FROM sessions ORDER BY updated_at DESC LIMIT ?').all(bound));
115
179
  return rows.map((row) => ({
116
180
  id: row.id,
117
181
  agentId: row.agent_id,
@@ -125,64 +189,15 @@ export class SqliteSessionStore {
125
189
  }
126
190
  }
127
191
  /**
128
- * Apply a soul's provider/model pins to a selection, demoting the
129
- * daemon-wide defaults the pins outrank.
192
+ * A soul's provider/model pins, and the daemon-wide defaults they demote.
130
193
  *
131
- * Exported because this is not only dispatch logic: anything that wants to
132
- * know what a served agent will actually resolve to — a startup billing
133
- * check, a diagnostic — has to normalize the same way, and a second copy
134
- * of these rules drifts from this one.
194
+ * The implementation lives in `@stratusagent/state`, beside the resolver
195
+ * whose precedence it manipulates — it never had a gateway dependency. It is
196
+ * re-exported here because this is where callers were told to import it from,
197
+ * and because "what will a served agent actually resolve to" is a gateway
198
+ * question even when the rule answering it is not gateway code.
135
199
  */
136
- export const applySoulPins = (pins, selection, env, context) => {
137
- selection.presetSoul = pins;
138
- if (!pins.provider && !pins.model) {
139
- return { selection, env };
140
- }
141
- const processEnv = { ...(env.processEnv ?? process.env) };
142
- // The daemon-wide default can come from the selection, the environment,
143
- // or the config file. A config with no provider key predates the
144
- // anthropic option and is openai-specific (the resolver treats it that
145
- // way), so a real file without the key still names openai as the
146
- // default. Env values normalize exactly as the resolver normalizes
147
- // them: an empty or whitespace-padded STRATUS_PROVIDER is no default at
148
- // all, not a mismatching one.
149
- const defaultProvider = context.selectionProvider
150
- ?? readNonEmptyString(processEnv.STRATUS_PROVIDER)
151
- ?? readNonEmptyString(processEnv.STRATUSCLAW_PROVIDER)
152
- ?? context.configProvider
153
- ?? (context.configPresent ? 'openai' : undefined);
154
- if (pins.provider) {
155
- delete selection.provider;
156
- delete processEnv.STRATUS_PROVIDER;
157
- delete processEnv.STRATUSCLAW_PROVIDER;
158
- if (defaultProvider !== undefined && defaultProvider !== 'demo' && pins.provider !== defaultProvider) {
159
- // The default model, endpoint, and generic credentials were all
160
- // chosen for the default provider — none may ride along to the
161
- // soul's: a base URL would point the pinned provider at the wrong
162
- // service, and a generic API key would be sent to it. With NO
163
- // default selected anywhere — or the credential-less demo provider
164
- // as the default — there is nothing those values could have been
165
- // chosen for except whatever provider the soul selects — exactly
166
- // the resolver's own reading of a generic credential — so they stay.
167
- delete selection.model;
168
- delete processEnv.STRATUS_MODEL;
169
- delete processEnv.STRATUSCLAW_MODEL;
170
- delete selection.baseUrl;
171
- delete processEnv.STRATUS_BASE_URL;
172
- delete processEnv.STRATUSCLAW_BASE_URL;
173
- delete processEnv.STRATUS_API_KEY;
174
- delete processEnv.STRATUSCLAW_API_KEY;
175
- delete processEnv.STRATUS_API_KEY_ENV;
176
- delete processEnv.STRATUSCLAW_API_KEY_ENV;
177
- }
178
- }
179
- if (pins.model) {
180
- delete selection.model;
181
- delete processEnv.STRATUS_MODEL;
182
- delete processEnv.STRATUSCLAW_MODEL;
183
- }
184
- return { selection, env: { ...env, processEnv } };
185
- };
200
+ export { applySoulPins } from '@stratusagent/state';
186
201
  /**
187
202
  * Recorded on a turn the previous process was still running when it died.
188
203
  *
@@ -298,7 +313,7 @@ export const createGateway = (options = {}) => {
298
313
  // Identity, not presence: a request that already settled is gone from
299
314
  // the registry, and comparing against this exact function is what
300
315
  // makes every ending — click, timeout, abort, shutdown — idempotent.
301
- if (pendingApprovals.get(requestId) !== settle) {
316
+ if (pendingApprovals.get(requestId)?.settle !== settle) {
302
317
  return;
303
318
  }
304
319
  pendingApprovals.delete(requestId);
@@ -318,13 +333,31 @@ export const createGateway = (options = {}) => {
318
333
  approvalEmissions.add(emitted);
319
334
  void emitted.finally(() => approvalEmissions.delete(emitted));
320
335
  };
321
- pendingApprovals.set(requestId, settle);
322
336
  // What is left of the window, not a fresh one: a re-asked request
323
337
  // carries when it originally parked, and the elapsed time counts.
338
+ // Computed before registration because the summary below quotes the
339
+ // deadline, and a listing that disagreed with the announcing event about
340
+ // when a request expires would be worse than one that said nothing.
324
341
  const alreadyWaitedMs = request.parkedAt ? Date.now() - Date.parse(request.parkedAt) : 0;
325
342
  const remainingTimeoutMs = effectiveApprovalTimeoutMs > 0
326
343
  ? effectiveApprovalTimeoutMs - (Number.isFinite(alreadyWaitedMs) ? Math.max(0, alreadyWaitedMs) : 0)
327
344
  : 0;
345
+ const expiresAt = remainingTimeoutMs > 0
346
+ ? new Date(Date.now() + remainingTimeoutMs).toISOString()
347
+ : undefined;
348
+ pendingApprovals.set(requestId, {
349
+ settle,
350
+ pending: {
351
+ requestId,
352
+ sessionId: request.session.id,
353
+ agentId: request.session.agent.id,
354
+ call: request.call,
355
+ risk: request.risk,
356
+ parkedAt: request.parkedAt ?? new Date().toISOString(),
357
+ ...(expiresAt ? { expiresAt } : {}),
358
+ ...(request.session.metadata ? { metadata: request.session.metadata } : {}),
359
+ },
360
+ });
328
361
  if (effectiveApprovalTimeoutMs > 0 && remainingTimeoutMs <= 0) {
329
362
  // Nothing left to wait with. Settled here rather than armed with a
330
363
  // zero timer, which `setTimeout` would still run a tick later — long
@@ -366,9 +399,7 @@ export const createGateway = (options = {}) => {
366
399
  call: request.call,
367
400
  risk: request.risk,
368
401
  ...(request.session.metadata ? { metadata: request.session.metadata } : {}),
369
- ...(remainingTimeoutMs > 0
370
- ? { expiresAt: new Date(Date.now() + remainingTimeoutMs).toISOString() }
371
- : {}),
402
+ ...(expiresAt ? { expiresAt } : {}),
372
403
  });
373
404
  // Drained at shutdown alongside the resolutions: a channel that has not
374
405
  // yet been told a request exists cannot be told it was denied either.
@@ -379,11 +410,11 @@ export const createGateway = (options = {}) => {
379
410
  });
380
411
  });
381
412
  const resolveApproval = (input) => {
382
- const settle = pendingApprovals.get(input.requestId);
383
- if (!settle) {
413
+ const parked = pendingApprovals.get(input.requestId);
414
+ if (!parked) {
384
415
  return false;
385
416
  }
386
- settle(input.answer, input.reason ?? 'decided', input.actor);
417
+ parked.settle(input.answer, input.reason ?? 'decided', input.actor);
387
418
  return true;
388
419
  };
389
420
  /**
@@ -525,20 +556,37 @@ export const createGateway = (options = {}) => {
525
556
  lastDefaultAgentId = id;
526
557
  return id;
527
558
  };
559
+ /**
560
+ * Build the roster from what is on disk right now.
561
+ *
562
+ * Runs at start and again on every `reloadRoster()`, which is why it
563
+ * tracks what it registered rather than only adding: a soul file deleted
564
+ * since the last pass has to stop being dispatchable, and re-registering
565
+ * the survivors over a map nothing removes from would leave it addressable
566
+ * — by id, and from every channel — for the rest of the daemon's life.
567
+ */
528
568
  const loadRoster = async () => {
569
+ const seen = new Set();
570
+ const registerFresh = (source) => {
571
+ registerSource(source);
572
+ seen.add(source.definition.id);
573
+ };
529
574
  // The built-in id is reserved BEFORE ordinary roster entries load: a
530
575
  // roster file that happens to declare id "stratus" must not hijack
531
576
  // the documented built-in fallback for agentId-less dispatches — the
532
577
  // guard below skips it with a warning. Only the explicitly
533
578
  // configured default soul may take the id over (defaultAgentId
534
579
  // replaces a pathless source).
535
- registerSource({ definition: { ...DEFAULT_STRATUS_AGENT } });
580
+ registerFresh({ definition: { ...DEFAULT_STRATUS_AGENT } });
536
581
  // Throws on two roster files claiming one id: that is a collision with
537
582
  // no correct winner, and refusing to serve beats letting sort order
538
583
  // decide whose sessions, memory, and credentials an agent inherits.
539
584
  const entries = await loadRosterSouls(env, warn);
540
585
  for (const entry of entries) {
541
- if (sources.has(entry.soul.agent.id)) {
586
+ // Against ids claimed by THIS pass, not by any earlier one: a reload
587
+ // must be free to re-register the agent it just re-read, and only the
588
+ // built-in above can legitimately block a roster file.
589
+ if (seen.has(entry.soul.agent.id)) {
542
590
  // Only reachable for the reserved built-in id now — roster-vs-
543
591
  // roster duplicates never get this far. Reserved is not the same
544
592
  // failure as duplicated: a soul may not take the fallback over,
@@ -546,11 +594,52 @@ export const createGateway = (options = {}) => {
546
594
  warn(`agent id ${entry.soul.agent.id} is reserved for the built-in agent; ignoring ${entry.path}`);
547
595
  continue;
548
596
  }
549
- registerSource({ definition: entry.soul.agent, soulPath: entry.path, soul: entry.soul });
597
+ registerFresh({ definition: entry.soul.agent, soulPath: entry.path, soul: entry.soul });
550
598
  }
551
599
  // The configured default soul is part of the roster too — it is what
552
- // an agentId-less dispatch answers as.
553
- await defaultAgentId();
600
+ // an agentId-less dispatch answers as. It may live outside the agents
601
+ // directory, and it survives a read failure by name, so what it
602
+ // resolves to is kept whether or not this pass re-registered it.
603
+ seen.add(await defaultAgentId());
604
+ for (const id of [...sources.keys()]) {
605
+ if (!seen.has(id)) {
606
+ sources.delete(id);
607
+ registry.unregister(id);
608
+ }
609
+ }
610
+ // Advisory, per the skills spec: a skill may say which toolsets its
611
+ // procedure expects (`requires:`), and an agent enabling it without
612
+ // them gets a warning at load — never a refusal, because a skill is
613
+ // prose and can degrade. The check itself is the kernel's
614
+ // (`missingSkillRequirements`), shared with the CLI's local runs so
615
+ // both hosts warn about the same configuration.
616
+ for (const agent of registry.list()) {
617
+ for (const { skill, missing } of missingSkillRequirements(agent, skillCatalog)) {
618
+ warn(`agent ${agent.id} enables skill ${skill.id}, which expects tools the agent is not allowed: ${missing.join(', ')}`);
619
+ }
620
+ }
621
+ };
622
+ /**
623
+ * Roster rebuilds, one at a time.
624
+ *
625
+ * `loadRoster` prunes by comparing the registry against the ids *this pass*
626
+ * saw, so two overlapping passes destroy each other's work: the older one
627
+ * snapshots a roster without agent B, the newer one registers B and
628
+ * finishes, and then the older one reaches its prune and unregisters B —
629
+ * which two clients creating agents at once will do, each having been told
630
+ * 201 for an agent that is no longer dispatchable.
631
+ *
632
+ * A chain rather than a lock: a reload is idempotent, so a caller arriving
633
+ * mid-rebuild wants the *next* complete one, and waiting for it is exactly
634
+ * what this gives them.
635
+ */
636
+ let reloads = Promise.resolve();
637
+ const reloadsInOrder = async () => {
638
+ const next = reloads.then(loadRoster, loadRoster);
639
+ // Swallowed for the chain only — the caller still sees the rejection, but
640
+ // a roster that failed to load must not block every reload after it.
641
+ reloads = next.catch(() => undefined);
642
+ await next;
554
643
  };
555
644
  /**
556
645
  * A session pins its agent id, never a snapshot of the agent: the soul
@@ -592,8 +681,23 @@ export const createGateway = (options = {}) => {
592
681
  };
593
682
  // ---- runner pool --------------------------------------------------------
594
683
  const tools = new ToolRegistry();
684
+ // Which package each tool came from. Kernel tools are absent from this
685
+ // map, which is how `tools()` reports them as the kernel's.
686
+ const toolProvenance = new Map();
687
+ let loadedPlugins = [];
688
+ let pluginFailures = [];
689
+ const skillCatalog = new SkillRegistry();
690
+ let operatorSkills = [];
595
691
  tools.register(createDemoTool());
596
692
  tools.register(createRememberTool(memory));
693
+ // Registered here rather than left to the first runner, so `tools()`
694
+ // lists the reader before anything dispatches. The allowlist resolver
695
+ // mirrors the runner's own sourcing — the definition travelling with the
696
+ // session, then the roster — so the tool refuses exactly what the
697
+ // runner's gates refuse.
698
+ tools.register(createSkillReadTool(skillCatalog, {
699
+ allowlistFor: (session) => session.agent.skills ?? registry.get(session.agent.id)?.skills,
700
+ }));
597
701
  // Delegation is dispatcher-backed, not runner-capturing: the target agent
598
702
  // runs through the same per-provider routing as a direct dispatch, so a
599
703
  // delegated specialist uses their own provider and credentials.
@@ -647,6 +751,7 @@ export const createGateway = (options = {}) => {
647
751
  store,
648
752
  bus,
649
753
  agents: registry,
754
+ skills: skillCatalog,
650
755
  memory,
651
756
  streaming: true,
652
757
  ...(options.maxTurns !== undefined ? { maxTurns: options.maxTurns } : {}),
@@ -840,6 +945,16 @@ export const createGateway = (options = {}) => {
840
945
  const sessionChains = new Map();
841
946
  const inflight = new Set();
842
947
  let stopping = false;
948
+ /**
949
+ * The turn id running on each session, for callers that named one.
950
+ *
951
+ * Set when the chained work actually starts — not when the dispatch was
952
+ * queued — because a message waiting behind another turn has not begun,
953
+ * and stamping events with it would attribute the running turn's output to
954
+ * the queued one. Single-flight per session is what makes one entry per
955
+ * session sufficient.
956
+ */
957
+ const activeTurns = new Map();
843
958
  /**
844
959
  * The runtime one agent would run on right now — config snapshot, soul
845
960
  * pins, degradation and all.
@@ -1154,7 +1269,137 @@ export const createGateway = (options = {}) => {
1154
1269
  if (stopping) {
1155
1270
  throw new Error('The gateway is stopping and no longer accepts new work.');
1156
1271
  }
1157
- return onSessionChain(input.sessionId, () => dispatchInternal(input));
1272
+ const { turnId } = input;
1273
+ if (turnId === undefined) {
1274
+ return onSessionChain(input.sessionId, () => dispatchInternal(input));
1275
+ }
1276
+ return onSessionChain(input.sessionId, async () => {
1277
+ activeTurns.set(input.sessionId, turnId);
1278
+ try {
1279
+ return await dispatchInternal(input);
1280
+ }
1281
+ finally {
1282
+ // Unconditional, and safe to be: the chain guarantees no other turn
1283
+ // for this session ran between the set above and here, so this can
1284
+ // only ever be clearing its own entry.
1285
+ activeTurns.delete(input.sessionId);
1286
+ }
1287
+ });
1288
+ };
1289
+ /**
1290
+ * Load what the config asked for, and say what did not load.
1291
+ *
1292
+ * A plugin that fails is a warning rather than a refusal to start: a
1293
+ * daemon that will not boot because of a mistyped package name takes the
1294
+ * whole fleet down over one line of config, and the agents whose tools
1295
+ * did load are still worth serving. What is never degraded is the
1296
+ * security half — an undeclared name or a collision refuses that plugin
1297
+ * whole, inside the loader.
1298
+ */
1299
+ const startPlugins = async () => {
1300
+ const configured = options.plugins ?? {};
1301
+ if (Object.keys(configured).length === 0) {
1302
+ return;
1303
+ }
1304
+ const result = await loadPlugins({
1305
+ config: configured,
1306
+ host: options.pluginHost ?? {
1307
+ resolve: (specifier) => import.meta.resolve(specifier),
1308
+ import: (specifier) => import(specifier),
1309
+ },
1310
+ tools,
1311
+ skills: skillCatalog,
1312
+ bus,
1313
+ workspaceRoot: workspacesDirPath(env),
1314
+ });
1315
+ loadedPlugins = result.loaded;
1316
+ pluginFailures = result.failures;
1317
+ for (const plugin of result.loaded) {
1318
+ for (const tool of plugin.tools) {
1319
+ toolProvenance.set(tool.name, { package: plugin.package, trusted: plugin.trusted });
1320
+ }
1321
+ const contributed = [
1322
+ ...plugin.tools.map((tool) => `${tool.name} (${tool.risk})`),
1323
+ ...plugin.skills.map((skill) => `skill ${skill.id}`),
1324
+ ];
1325
+ log(`plugin ${plugin.package} loaded — ${contributed.join(', ') || 'no tools'}`);
1326
+ }
1327
+ for (const failure of result.failures) {
1328
+ warn(`plugin ${failure.package} did not load: ${failure.reason}`);
1329
+ }
1330
+ };
1331
+ /**
1332
+ * Load the operator's `~/.stratus/skills/` into the catalog. Before the
1333
+ * plugins, deliberately: an operator's bare id outranks a plugin's bare
1334
+ * alias for the same name, and the plugin's skill stays reachable by its
1335
+ * qualified `<package>:<skill>` form.
1336
+ */
1337
+ const startSkills = async () => {
1338
+ operatorSkills = await loadOperatorSkills(env, skillCatalog, warn);
1339
+ for (const skill of operatorSkills) {
1340
+ log(`skill ${skill.id} loaded from ${skill.path}`);
1341
+ }
1342
+ };
1343
+ /**
1344
+ * Everything after the plugins: the roster, the channels, and the sweeps
1345
+ * that need both. Split out so `start()` can wrap it in the cleanup a
1346
+ * failure here requires — see the call site.
1347
+ */
1348
+ const startServing = async () => {
1349
+ await loadRoster();
1350
+ const named = registry.list().map((agent) => agent.name).join(', ');
1351
+ log(`stratusd ready — ${registry.list().length} agent(s): ${named}`);
1352
+ // Read before anything can dispatch, so this is exactly what the
1353
+ // last process left running and not a turn of ours caught in flight.
1354
+ // Applied further down, once there is somewhere for the failure to
1355
+ // be heard.
1356
+ const abandoned = await listAbandonedTurns();
1357
+ // Channels come up after the roster so their first inbound message
1358
+ // already has agents to dispatch to. One failing adapter must not
1359
+ // keep the rest (or the gateway) down.
1360
+ for (const adapter of options.channels ?? []) {
1361
+ try {
1362
+ await adapter.start(gateway);
1363
+ startedChannels.push(adapter);
1364
+ }
1365
+ catch (error) {
1366
+ warn(`channel ${adapter.name} failed to start: ${error instanceof Error ? error.message : String(error)}`);
1367
+ // A start() that rejected may still hold sockets or listeners it
1368
+ // acquired before failing; it never reaches startedChannels, so
1369
+ // this is its only cleanup.
1370
+ try {
1371
+ await adapter.stop();
1372
+ }
1373
+ catch (stopError) {
1374
+ warn(`channel ${adapter.name} cleanup after failed start also failed: ${stopError instanceof Error ? stopError.message : String(stopError)}`);
1375
+ }
1376
+ }
1377
+ }
1378
+ // Last, and not awaited: recovery re-asks, so it needs the channels
1379
+ // above already listening — but a turn parked behind a slow approver
1380
+ // must not hold up start(), or a daemon with one outstanding request
1381
+ // would refuse to finish booting.
1382
+ void recoverParkedTurns().catch((error) => {
1383
+ warn(`recovering parked turns failed: ${error instanceof Error ? error.message : String(error)}`);
1384
+ });
1385
+ // Not awaited for the same reason, and independent of the sweep
1386
+ // above: these sessions are not parked, so the two never touch the
1387
+ // same one.
1388
+ void failAbandonedTurns(abandoned).catch((error) => {
1389
+ warn(`failing abandoned turns failed: ${error instanceof Error ? error.message : String(error)}`);
1390
+ });
1391
+ };
1392
+ /** Release what the plugins acquired, reporting rather than throwing. */
1393
+ const disposePlugins = async () => {
1394
+ await Promise.allSettled(loadedPlugins.map(async (plugin) => {
1395
+ try {
1396
+ await plugin.instance.dispose?.();
1397
+ }
1398
+ catch (error) {
1399
+ warn(`plugin ${plugin.package} failed to shut down: ${error instanceof Error ? error.message : String(error)}`);
1400
+ }
1401
+ }));
1402
+ loadedPlugins = [];
1158
1403
  };
1159
1404
  const startedChannels = [];
1160
1405
  const gateway = {
@@ -1162,48 +1407,26 @@ export const createGateway = (options = {}) => {
1162
1407
  store,
1163
1408
  async start() {
1164
1409
  await migrateLegacyMemory(env);
1165
- await loadRoster();
1166
- const named = registry.list().map((agent) => agent.name).join(', ');
1167
- log(`stratusd ready — ${registry.list().length} agent(s): ${named}`);
1168
- // Read before anything can dispatch, so this is exactly what the
1169
- // last process left running and not a turn of ours caught in flight.
1170
- // Applied further down, once there is somewhere for the failure to
1171
- // be heard.
1172
- const abandoned = await listAbandonedTurns();
1173
- // Channels come up after the roster so their first inbound message
1174
- // already has agents to dispatch to. One failing adapter must not
1175
- // keep the rest (or the gateway) down.
1176
- for (const adapter of options.channels ?? []) {
1177
- try {
1178
- await adapter.start(gateway);
1179
- startedChannels.push(adapter);
1180
- }
1181
- catch (error) {
1182
- warn(`channel ${adapter.name} failed to start: ${error instanceof Error ? error.message : String(error)}`);
1183
- // A start() that rejected may still hold sockets or listeners it
1184
- // acquired before failing; it never reaches startedChannels, so
1185
- // this is its only cleanup.
1186
- try {
1187
- await adapter.stop();
1188
- }
1189
- catch (stopError) {
1190
- warn(`channel ${adapter.name} cleanup after failed start also failed: ${stopError instanceof Error ? stopError.message : String(stopError)}`);
1191
- }
1192
- }
1410
+ // Before the roster and before channels: a turn must never arrive for
1411
+ // an agent whose soul lists a tool the daemon has not registered yet,
1412
+ // which would refuse the call as "not permitted" and blame the soul.
1413
+ // Skills first for the same reason — and before the plugins, so the
1414
+ // operator's bare ids win their aliases (see startSkills).
1415
+ await startSkills();
1416
+ await startPlugins();
1417
+ try {
1418
+ await startServing();
1419
+ }
1420
+ catch (error) {
1421
+ // Everything after the plugins loaded is inside that try for one
1422
+ // reason: a `start()` that rejects never reaches its caller's
1423
+ // shutdown path — `stratus serve` awaits it *before* the try/finally
1424
+ // that calls `stop()` — so a duplicate agent id in the roster would
1425
+ // leave a plugin's browser, socket, or subscription held for the
1426
+ // life of a process that is on its way out.
1427
+ await disposePlugins();
1428
+ throw error;
1193
1429
  }
1194
- // Last, and not awaited: recovery re-asks, so it needs the channels
1195
- // above already listening — but a turn parked behind a slow approver
1196
- // must not hold up start(), or a daemon with one outstanding request
1197
- // would refuse to finish booting.
1198
- void recoverParkedTurns().catch((error) => {
1199
- warn(`recovering parked turns failed: ${error instanceof Error ? error.message : String(error)}`);
1200
- });
1201
- // Not awaited for the same reason, and independent of the sweep
1202
- // above: these sessions are not parked, so the two never touch the
1203
- // same one.
1204
- void failAbandonedTurns(abandoned).catch((error) => {
1205
- warn(`failing abandoned turns failed: ${error instanceof Error ? error.message : String(error)}`);
1206
- });
1207
1430
  },
1208
1431
  // Drain: channels stop taking messages, in-flight turns finish, new
1209
1432
  // dispatches are refused, then the database closes. SIGTERM handling
@@ -1217,8 +1440,8 @@ export const createGateway = (options = {}) => {
1217
1440
  // outstanding approval timeout — a shutdown would hang for as long as
1218
1441
  // the longest request had left. Each denial is a real decision the
1219
1442
  // turn continues from, so the drain below still finishes those turns.
1220
- for (const settle of [...pendingApprovals.values()]) {
1221
- settle('deny', 'cancelled');
1443
+ for (const parked of [...pendingApprovals.values()]) {
1444
+ parked.settle('deny', 'cancelled');
1222
1445
  }
1223
1446
  // Let those resolutions reach their subscribers before the channels
1224
1447
  // go down, or a channel that renders approvals never learns to
@@ -1227,13 +1450,137 @@ export const createGateway = (options = {}) => {
1227
1450
  await Promise.allSettled(startedChannels.map((adapter) => adapter.stop()));
1228
1451
  startedChannels.length = 0;
1229
1452
  await Promise.allSettled([...inflight]);
1453
+ // After the turns that might still be using them. A plugin holding a
1454
+ // browser is the reason this exists, and closing it out from under a
1455
+ // running screenshot would fail that turn rather than tidy up.
1456
+ await disposePlugins();
1230
1457
  store.close();
1231
1458
  log('stratusd stopped');
1232
1459
  },
1233
1460
  dispatch,
1461
+ async sessionRouting(sessionId) {
1462
+ const session = await store.get(sessionId);
1463
+ return session
1464
+ ? { agentId: session.agent.id, metadata: session.metadata ?? {} }
1465
+ : undefined;
1466
+ },
1234
1467
  agents() {
1235
1468
  return registry.list();
1236
1469
  },
1470
+ async reloadRoster() {
1471
+ await reloadsInOrder();
1472
+ const roster = registry.list();
1473
+ log(`roster reloaded — ${roster.length} agent(s): ${roster.map((agent) => agent.name).join(', ')}`);
1474
+ return roster;
1475
+ },
1476
+ activeTurnId(sessionId) {
1477
+ return activeTurns.get(sessionId);
1478
+ },
1479
+ pendingApprovals() {
1480
+ return [...pendingApprovals.values()]
1481
+ .map((parked) => parked.pending)
1482
+ .sort((a, b) => a.parkedAt.localeCompare(b.parkedAt));
1483
+ },
1484
+ tools() {
1485
+ // From the registry, not from the load records: the risk reported is
1486
+ // the one a call will actually be judged at, floor applied, and a
1487
+ // second list assembled from manifests would drift from it the first
1488
+ // time a floor was raised.
1489
+ return tools.describe().map((descriptor) => {
1490
+ const provenance = toolProvenance.get(descriptor.name);
1491
+ return {
1492
+ name: descriptor.name,
1493
+ ...(descriptor.description ? { description: descriptor.description } : {}),
1494
+ risk: descriptor.risk ?? 'gated',
1495
+ ...(provenance ? { package: provenance.package, trusted: provenance.trusted } : {}),
1496
+ };
1497
+ });
1498
+ },
1499
+ skills() {
1500
+ // From the registry, not from the load records: the registry is the
1501
+ // live answer for which bare aliases still resolve — a plugin loaded
1502
+ // later can have retired one a record still remembers.
1503
+ const provenance = new Map();
1504
+ for (const info of operatorSkills) {
1505
+ provenance.set(info.id, { path: info.path });
1506
+ }
1507
+ for (const plugin of loadedPlugins) {
1508
+ for (const skill of plugin.skills) {
1509
+ provenance.set(skill.id, { package: plugin.package, path: skill.path });
1510
+ }
1511
+ }
1512
+ return skillCatalog.list().map((skill) => {
1513
+ const alias = skillCatalog.idsFor(skill.id).find((id) => id !== skill.id);
1514
+ const from = provenance.get(skill.id);
1515
+ return {
1516
+ id: skill.id,
1517
+ name: skill.name,
1518
+ description: skill.description,
1519
+ ...(alias !== undefined ? { alias } : {}),
1520
+ ...(from?.package !== undefined ? { package: from.package } : {}),
1521
+ ...(from !== undefined ? { path: from.path } : {}),
1522
+ };
1523
+ });
1524
+ },
1525
+ plugins() {
1526
+ const loaded = loadedPlugins.map((plugin) => ({
1527
+ package: plugin.package,
1528
+ name: plugin.name,
1529
+ trusted: plugin.trusted,
1530
+ tools: plugin.tools.map((tool) => ({
1531
+ name: tool.name,
1532
+ ...(tool.description ? { description: tool.description } : {}),
1533
+ risk: tool.risk,
1534
+ package: plugin.package,
1535
+ trusted: plugin.trusted,
1536
+ })),
1537
+ // The alias comes from the live registry, exactly as `skills()`
1538
+ // derives it: a plugin loaded later can have retired it, and this
1539
+ // listing must not advertise a bare id `skill.read` would refuse.
1540
+ skills: plugin.skills.map((skill) => {
1541
+ const alias = skillCatalog.idsFor(skill.id).find((id) => id !== skill.id);
1542
+ return {
1543
+ id: skill.id,
1544
+ name: skill.name,
1545
+ description: skill.description,
1546
+ ...(alias !== undefined ? { alias } : {}),
1547
+ package: plugin.package,
1548
+ path: skill.path,
1549
+ };
1550
+ }),
1551
+ }));
1552
+ const failed = pluginFailures.map((failure) => ({
1553
+ package: failure.package,
1554
+ error: failure.reason,
1555
+ }));
1556
+ return [...loaded, ...failed];
1557
+ },
1558
+ async servedSouls() {
1559
+ // From the registry rather than `sources` directly, so the order and
1560
+ // the membership are the roster's own — one entry per agent `agents()`
1561
+ // reports, and no entry for a source the registry no longer serves.
1562
+ return Promise.all(registry.list().map(async (agent) => {
1563
+ const source = sources.get(agent.id);
1564
+ // Re-read, not re-registered: this is a read, and a caller asking
1565
+ // what the daemon would bill must not reshape the roster as a side
1566
+ // effect. `refreshAgent` still owns that on the dispatch path.
1567
+ const fresh = source?.soulPath
1568
+ ? await loadSoulFile(source.soulPath).catch(() => undefined)
1569
+ : undefined;
1570
+ if (fresh && fresh.agent.id !== agent.id) {
1571
+ // The file was given to another agent. `refreshAgent` refuses every
1572
+ // dispatch for this id until the roster reloads, so there is no
1573
+ // runtime being served under it — reporting the cached pins would
1574
+ // claim billing for turns that cannot run, and reporting no soul at
1575
+ // all would claim the daemon-wide default instead. It is omitted.
1576
+ return undefined;
1577
+ }
1578
+ // Unreadable is the one case the cache answers for: the gateway goes
1579
+ // on dispatching from the soul it loaded, so those pins are live.
1580
+ const soul = fresh ?? source?.soul;
1581
+ return { id: agent.id, ...(soul ? { soul } : {}) };
1582
+ })).then((entries) => entries.filter((entry) => entry !== undefined));
1583
+ },
1237
1584
  resolveApproval,
1238
1585
  };
1239
1586
  return gateway;