@stratusagent/gateway 0.3.0 → 0.6.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
@@ -1,13 +1,26 @@
1
- import { createHash } from 'node:crypto';
1
+ 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, } from '@stratusagent/core';
5
+ import { AgentRegistry, AgentRunner, EventBus, RunAbortedError, ToolRegistry, 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, 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;
12
+ /**
13
+ * How long a parked call waits for a person. Long enough to survive a
14
+ * meeting, short enough that a request nobody saw does not hold a turn (and
15
+ * the Slack thread it is answering) open indefinitely.
16
+ */
17
+ const DEFAULT_APPROVAL_TIMEOUT_MS = 900_000;
18
+ /**
19
+ * Node's largest `setTimeout` delay. Anything above it does not become a
20
+ * long wait — it silently becomes a 1ms one, so a config asking for a
21
+ * 30-day approval window would expire every request almost immediately.
22
+ */
23
+ const MAX_TIMER_DELAY_MS = 2_147_483_647;
11
24
  export class SqliteSessionStore {
12
25
  db;
13
26
  constructor(filePath, options = {}) {
@@ -84,11 +97,85 @@ export class SqliteSessionStore {
84
97
  .prepare('INSERT OR REPLACE INTO sessions (id, agent_id, status, body, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)')
85
98
  .run(updated.id, updated.agent.id, updated.status, JSON.stringify(updated), updated.createdAt, updated.updatedAt);
86
99
  }
87
- /** Newest-first session listing for one agent (or all agents). */
88
- list(agentId) {
100
+ /**
101
+ * Session ids in a state, oldest first — the index a restarting daemon
102
+ * sweeps for turns parked on a human. The `status` column carries it, so
103
+ * no conversation body is deserialized to answer the question.
104
+ */
105
+ async listIdsByStatus(status) {
106
+ const rows = this.db
107
+ .prepare('SELECT id FROM sessions WHERE status = ? ORDER BY updated_at ASC')
108
+ .all(status);
109
+ return rows.map((row) => row.id);
110
+ }
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;
89
176
  const rows = (agentId
90
- ? this.db.prepare('SELECT id, agent_id, status, created_at, updated_at FROM sessions WHERE agent_id = ? ORDER BY updated_at DESC').all(agentId)
91
- : 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));
92
179
  return rows.map((row) => ({
93
180
  id: row.id,
94
181
  agentId: row.agent_id,
@@ -102,64 +189,25 @@ export class SqliteSessionStore {
102
189
  }
103
190
  }
104
191
  /**
105
- * Apply a soul's provider/model pins to a selection, demoting the
106
- * daemon-wide defaults the pins outrank.
192
+ * A soul's provider/model pins, and the daemon-wide defaults they demote.
107
193
  *
108
- * Exported because this is not only dispatch logic: anything that wants to
109
- * know what a served agent will actually resolve to — a startup billing
110
- * check, a diagnostic — has to normalize the same way, and a second copy
111
- * 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.
112
199
  */
113
- export const applySoulPins = (pins, selection, env, context) => {
114
- selection.presetSoul = pins;
115
- if (!pins.provider && !pins.model) {
116
- return { selection, env };
117
- }
118
- const processEnv = { ...(env.processEnv ?? process.env) };
119
- // The daemon-wide default can come from the selection, the environment,
120
- // or the config file. A config with no provider key predates the
121
- // anthropic option and is openai-specific (the resolver treats it that
122
- // way), so a real file without the key still names openai as the
123
- // default. Env values normalize exactly as the resolver normalizes
124
- // them: an empty or whitespace-padded STRATUS_PROVIDER is no default at
125
- // all, not a mismatching one.
126
- const defaultProvider = context.selectionProvider
127
- ?? readNonEmptyString(processEnv.STRATUS_PROVIDER)
128
- ?? readNonEmptyString(processEnv.STRATUSCLAW_PROVIDER)
129
- ?? context.configProvider
130
- ?? (context.configPresent ? 'openai' : undefined);
131
- if (pins.provider) {
132
- delete selection.provider;
133
- delete processEnv.STRATUS_PROVIDER;
134
- delete processEnv.STRATUSCLAW_PROVIDER;
135
- if (defaultProvider !== undefined && defaultProvider !== 'demo' && pins.provider !== defaultProvider) {
136
- // The default model, endpoint, and generic credentials were all
137
- // chosen for the default provider — none may ride along to the
138
- // soul's: a base URL would point the pinned provider at the wrong
139
- // service, and a generic API key would be sent to it. With NO
140
- // default selected anywhere — or the credential-less demo provider
141
- // as the default — there is nothing those values could have been
142
- // chosen for except whatever provider the soul selects — exactly
143
- // the resolver's own reading of a generic credential — so they stay.
144
- delete selection.model;
145
- delete processEnv.STRATUS_MODEL;
146
- delete processEnv.STRATUSCLAW_MODEL;
147
- delete selection.baseUrl;
148
- delete processEnv.STRATUS_BASE_URL;
149
- delete processEnv.STRATUSCLAW_BASE_URL;
150
- delete processEnv.STRATUS_API_KEY;
151
- delete processEnv.STRATUSCLAW_API_KEY;
152
- delete processEnv.STRATUS_API_KEY_ENV;
153
- delete processEnv.STRATUSCLAW_API_KEY_ENV;
154
- }
155
- }
156
- if (pins.model) {
157
- delete selection.model;
158
- delete processEnv.STRATUS_MODEL;
159
- delete processEnv.STRATUSCLAW_MODEL;
160
- }
161
- return { selection, env: { ...env, processEnv } };
162
- };
200
+ export { applySoulPins } from '@stratusagent/state';
201
+ /**
202
+ * Recorded on a turn the previous process was still running when it died.
203
+ *
204
+ * Exported because "distinguishable" is the whole point: a surface, a
205
+ * test, or an operator reading `lastError` has to be able to tell a turn
206
+ * nobody finished apart from one a provider or a tool actually failed, and
207
+ * matching on prose that lives in one place beats each caller inventing
208
+ * its own guess at the wording.
209
+ */
210
+ export const ABANDONED_TURN_ERROR = 'stratusd stopped while this turn was still running; it was not resumed. Send the message again.';
163
211
  /**
164
212
  * The always-on Stratus process. One gateway holds the roster, the durable
165
213
  * session store, the shared event bus, and a pool of runners keyed by
@@ -181,6 +229,269 @@ export const createGateway = (options = {}) => {
181
229
  ? new SqliteSessionStore(options.sessionDbPath)
182
230
  : new SqliteSessionStore(path.join(stratusHomePath(env), SESSIONS_DB_FILENAME), { ownedDirectory: true });
183
231
  const memory = withLegacyDefaultMemories(createFileMemoryStore(memoryFilePath(env)));
232
+ // ---- approval brokering -------------------------------------------------
233
+ const approvalTimeoutMs = options.approvalTimeoutMs ?? DEFAULT_APPROVAL_TIMEOUT_MS;
234
+ // Refused at construction, not absorbed: a negative or NaN timeout fails
235
+ // the `> 0` test below and quietly means "never expire" — the one
236
+ // behavior documented for an explicit 0, and the last one a caller who
237
+ // typed a bad number wants. Config-file values are already rejected with
238
+ // a better message; this catches the programmatic path.
239
+ if (!Number.isFinite(approvalTimeoutMs) || approvalTimeoutMs < 0) {
240
+ throw new Error(`approvalTimeoutMs must be a non-negative number of milliseconds (0 to wait indefinitely); received ${String(options.approvalTimeoutMs)}.`);
241
+ }
242
+ // Clamped, not trusted: a delay past Node's timer range is not a long
243
+ // wait, it is a 1ms one, so an over-large value would turn every approval
244
+ // into an instant expiry — the opposite of what it asked for. The CLI
245
+ // rejects these at config load with a better message; this is the
246
+ // backstop for a programmatic caller.
247
+ const effectiveApprovalTimeoutMs = Math.min(approvalTimeoutMs, MAX_TIMER_DELAY_MS);
248
+ if (approvalTimeoutMs > MAX_TIMER_DELAY_MS) {
249
+ warn(`approval timeout ${approvalTimeoutMs}ms is above Node's maximum timer delay; using ${MAX_TIMER_DELAY_MS}ms (~24.8 days)`);
250
+ }
251
+ const pendingApprovals = new Map();
252
+ /**
253
+ * In-flight `tool.approval-resolved` emissions. `EventBus.emit` awaits its
254
+ * subscribers in order, so one async handler ahead of a channel adapter
255
+ * suspends the emission before the adapter ever sees it. Nothing today is
256
+ * async in front of one — which is exactly the problem: the shutdown
257
+ * guarantee below would rest on that staying true, and the first async
258
+ * subscriber anyone adds would silently leave live-looking buttons in a
259
+ * workspace the daemon has left.
260
+ */
261
+ const approvalEmissions = new Set();
262
+ /**
263
+ * Publishes one parked call and settles when somebody answers it, the
264
+ * request expires, or the turn is cancelled.
265
+ *
266
+ * All three endings run through the same `settle`, which removes the
267
+ * request from the registry BEFORE resolving. That ordering is the whole
268
+ * point: a request that is no longer pending cannot be resolved twice,
269
+ * so a click racing the timeout — or arriving after an abort — is refused
270
+ * rather than executing a tool for a turn that has already moved on.
271
+ */
272
+ const requestApproval = (request) => new Promise((resolve) => {
273
+ // A shutdown denies what is parked once, at the top of stop(). Turns
274
+ // already running keep going through the drain, though, and one of them
275
+ // can reach a gated tool AFTER that snapshot — finishing a provider
276
+ // call, or moving to the next call in the same response. Parking it
277
+ // would deadlock the drain against a question nobody is left to answer:
278
+ // stop() waits for the turn, the turn waits for the approval, and the
279
+ // approval waits out its timeout — forever when there is none.
280
+ if (stopping) {
281
+ resolve('deny');
282
+ void bus.emit({
283
+ type: 'tool.approval-resolved',
284
+ sessionId: request.session.id,
285
+ requestId: randomUUID(),
286
+ answer: 'deny',
287
+ reason: 'cancelled',
288
+ });
289
+ return;
290
+ }
291
+ const requestId = randomUUID();
292
+ let timer;
293
+ const onAbort = () => settle('deny', 'cancelled');
294
+ /**
295
+ * Settles once the request has finished being announced.
296
+ *
297
+ * Both emissions go through the same subscriber list, in order, and
298
+ * `EventBus.emit` awaits each one — so an async subscriber ahead of a
299
+ * channel can hold the announcement while a timeout or abort fires
300
+ * behind it, and the channel would hear that a request it has never
301
+ * heard of was resolved. It drops that, then renders the announcement
302
+ * when it finally arrives, leaving buttons for a settled request that
303
+ * nothing will ever retract.
304
+ *
305
+ * Chaining is the fix rather than remembering orphaned resolutions in
306
+ * the channel: a resolution that arrives before its request is not
307
+ * something a consumer can act on, so it should not be able to happen.
308
+ * Starts resolved, because a turn already aborted never announces at
309
+ * all and its denial should not wait on an emission that never runs.
310
+ */
311
+ let announced = Promise.resolve();
312
+ const settle = (answer, reason, actor) => {
313
+ // Identity, not presence: a request that already settled is gone from
314
+ // the registry, and comparing against this exact function is what
315
+ // makes every ending — click, timeout, abort, shutdown — idempotent.
316
+ if (pendingApprovals.get(requestId)?.settle !== settle) {
317
+ return;
318
+ }
319
+ pendingApprovals.delete(requestId);
320
+ if (timer) {
321
+ clearTimeout(timer);
322
+ }
323
+ request.signal?.removeEventListener('abort', onAbort);
324
+ resolve(answer);
325
+ const emitted = announced.then(() => bus.emit({
326
+ type: 'tool.approval-resolved',
327
+ sessionId: request.session.id,
328
+ requestId,
329
+ answer,
330
+ reason,
331
+ ...(actor ? { actor } : {}),
332
+ }));
333
+ approvalEmissions.add(emitted);
334
+ void emitted.finally(() => approvalEmissions.delete(emitted));
335
+ };
336
+ // What is left of the window, not a fresh one: a re-asked request
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.
341
+ const alreadyWaitedMs = request.parkedAt ? Date.now() - Date.parse(request.parkedAt) : 0;
342
+ const remainingTimeoutMs = effectiveApprovalTimeoutMs > 0
343
+ ? effectiveApprovalTimeoutMs - (Number.isFinite(alreadyWaitedMs) ? Math.max(0, alreadyWaitedMs) : 0)
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
+ });
361
+ if (effectiveApprovalTimeoutMs > 0 && remainingTimeoutMs <= 0) {
362
+ // Nothing left to wait with. Settled here rather than armed with a
363
+ // zero timer, which `setTimeout` would still run a tick later — long
364
+ // enough to announce a request that is already over.
365
+ settle('deny', 'timeout');
366
+ return;
367
+ }
368
+ if (remainingTimeoutMs > 0) {
369
+ // Deliberately NOT unref'd. A parked turn is real outstanding work,
370
+ // and its timer is the only thing that will ever finish it — letting
371
+ // the process exit out from under one would abandon the turn instead
372
+ // of denying it. Shutdown is handled where it belongs, in stop(),
373
+ // which settles every outstanding request before draining.
374
+ timer = setTimeout(() => settle('deny', 'timeout'), remainingTimeoutMs);
375
+ }
376
+ // The abort listener is attached before the request is announced, so a
377
+ // turn cancelled while the event is still being delivered still ends
378
+ // the request rather than leaving it pending forever.
379
+ if (request.signal) {
380
+ if (request.signal.aborted) {
381
+ settle('deny', 'cancelled');
382
+ return;
383
+ }
384
+ request.signal.addEventListener('abort', onAbort, { once: true });
385
+ }
386
+ // `announced` is assigned BEFORE the emission starts, because emit runs
387
+ // its first subscribers synchronously — one of them settling the
388
+ // request inline would otherwise chain onto the already-resolved
389
+ // placeholder and race the very announcement it is answering.
390
+ let markAnnounced = () => { };
391
+ announced = new Promise((resolveAnnounced) => {
392
+ markAnnounced = resolveAnnounced;
393
+ });
394
+ const announcing = bus.emit({
395
+ type: 'tool.approval-requested',
396
+ sessionId: request.session.id,
397
+ agentId: request.session.agent.id,
398
+ requestId,
399
+ call: request.call,
400
+ risk: request.risk,
401
+ ...(request.session.metadata ? { metadata: request.session.metadata } : {}),
402
+ ...(expiresAt ? { expiresAt } : {}),
403
+ });
404
+ // Drained at shutdown alongside the resolutions: a channel that has not
405
+ // yet been told a request exists cannot be told it was denied either.
406
+ approvalEmissions.add(announcing);
407
+ void announcing.finally(() => {
408
+ approvalEmissions.delete(announcing);
409
+ markAnnounced();
410
+ });
411
+ });
412
+ const resolveApproval = (input) => {
413
+ const parked = pendingApprovals.get(input.requestId);
414
+ if (!parked) {
415
+ return false;
416
+ }
417
+ parked.settle(input.answer, input.reason ?? 'decided', input.actor);
418
+ return true;
419
+ };
420
+ /**
421
+ * How a turn's watchdog stops its own clock, registered while it runs.
422
+ *
423
+ * Recording that a tool phase is open is not enough on its own: the
424
+ * timer is only re-evaluated when an event arrives, so a phase opened by
425
+ * something that emits nothing — an approval policy handed straight to
426
+ * the gateway — would leave an already-armed timer running to fire
427
+ * mid-wait.
428
+ */
429
+ const suspendWatchdog = new Map();
430
+ /**
431
+ * Approval waits in progress, per session.
432
+ *
433
+ * Deliberately a balanced counter and not a set of call ids. An earlier
434
+ * shape had the approval wrapper *open* a phase that the watchdog's
435
+ * event observer was expected to close, and that handoff was wrong in
436
+ * every way it could be: the observer does not exist when no watchdog is
437
+ * armed, so nothing closed it; a policy that threw closed nothing; and a
438
+ * stale entry did not merely leak, it suppressed the watchdog for the
439
+ * next turn on that session. Four separate exits, each patched in turn.
440
+ *
441
+ * A counter incremented and decremented in one `finally` cannot have any
442
+ * of those bugs, because the thing that opens the phase is the thing
443
+ * that closes it, in the same scope, on every path out.
444
+ */
445
+ const approvalWaits = new Map();
446
+ const holdApprovalWait = (sessionId) => {
447
+ approvalWaits.set(sessionId, (approvalWaits.get(sessionId) ?? 0) + 1);
448
+ // Stop any clock already running: this wait produces no events, so
449
+ // nothing else would.
450
+ suspendWatchdog.get(sessionId)?.();
451
+ let released = false;
452
+ return () => {
453
+ if (released) {
454
+ return;
455
+ }
456
+ released = true;
457
+ const remaining = (approvalWaits.get(sessionId) ?? 1) - 1;
458
+ if (remaining > 0) {
459
+ approvalWaits.set(sessionId, remaining);
460
+ }
461
+ else {
462
+ approvalWaits.delete(sessionId);
463
+ }
464
+ };
465
+ };
466
+ const configuredApprovals = typeof options.approvals === 'function'
467
+ ? options.approvals({ request: requestApproval })
468
+ : options.approvals;
469
+ /**
470
+ * Every approval opens a tool phase, whatever policy answers it.
471
+ *
472
+ * `tool.approval-requested` is the broker's event, so it exists only for
473
+ * the remote path; a policy handed straight to the gateway emits nothing
474
+ * at all, and `tool.called` comes only *after* the answer. Without this,
475
+ * a slow custom policy on a streaming provider that hosts its own loop
476
+ * would be indistinguishable from a stalled turn — the case this
477
+ * watchdog work exists to stop, arriving through a different door.
478
+ */
479
+ const approvals = configuredApprovals
480
+ ? {
481
+ async approve(context) {
482
+ const release = holdApprovalWait(context.session.id);
483
+ try {
484
+ return await configuredApprovals.approve(context);
485
+ }
486
+ finally {
487
+ // Every path out, including a policy that throws. What happens
488
+ // after the answer is the tool events' business, and they are
489
+ // observed by the watchdog itself.
490
+ release();
491
+ }
492
+ },
493
+ }
494
+ : undefined;
184
495
  const registry = new AgentRegistry();
185
496
  const sources = new Map();
186
497
  // ---- roster -------------------------------------------------------------
@@ -245,26 +556,79 @@ export const createGateway = (options = {}) => {
245
556
  lastDefaultAgentId = id;
246
557
  return id;
247
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
+ */
248
568
  const loadRoster = async () => {
569
+ const seen = new Set();
570
+ const registerFresh = (source) => {
571
+ registerSource(source);
572
+ seen.add(source.definition.id);
573
+ };
249
574
  // The built-in id is reserved BEFORE ordinary roster entries load: a
250
575
  // roster file that happens to declare id "stratus" must not hijack
251
576
  // the documented built-in fallback for agentId-less dispatches — the
252
- // duplicate guard below skips it with a warning. Only the explicitly
577
+ // guard below skips it with a warning. Only the explicitly
253
578
  // configured default soul may take the id over (defaultAgentId
254
579
  // replaces a pathless source).
255
- registerSource({ definition: { ...DEFAULT_STRATUS_AGENT } });
580
+ registerFresh({ definition: { ...DEFAULT_STRATUS_AGENT } });
581
+ // Throws on two roster files claiming one id: that is a collision with
582
+ // no correct winner, and refusing to serve beats letting sort order
583
+ // decide whose sessions, memory, and credentials an agent inherits.
256
584
  const entries = await loadRosterSouls(env, warn);
257
585
  for (const entry of entries) {
258
- if (sources.has(entry.soul.agent.id)) {
259
- const existing = sources.get(entry.soul.agent.id);
260
- warn(`duplicate agent id ${entry.soul.agent.id} (${existing?.soulPath ?? 'built-in'} vs ${entry.path}); keeping the first`);
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)) {
590
+ // Only reachable for the reserved built-in id now — roster-vs-
591
+ // roster duplicates never get this far. Reserved is not the same
592
+ // failure as duplicated: a soul may not take the fallback over,
593
+ // and must not be able to take the daemon down by trying.
594
+ warn(`agent id ${entry.soul.agent.id} is reserved for the built-in agent; ignoring ${entry.path}`);
261
595
  continue;
262
596
  }
263
- registerSource({ definition: entry.soul.agent, soulPath: entry.path, soul: entry.soul });
597
+ registerFresh({ definition: entry.soul.agent, soulPath: entry.path, soul: entry.soul });
264
598
  }
265
599
  // The configured default soul is part of the roster too — it is what
266
- // an agentId-less dispatch answers as.
267
- 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
+ };
611
+ /**
612
+ * Roster rebuilds, one at a time.
613
+ *
614
+ * `loadRoster` prunes by comparing the registry against the ids *this pass*
615
+ * saw, so two overlapping passes destroy each other's work: the older one
616
+ * snapshots a roster without agent B, the newer one registers B and
617
+ * finishes, and then the older one reaches its prune and unregisters B —
618
+ * which two clients creating agents at once will do, each having been told
619
+ * 201 for an agent that is no longer dispatchable.
620
+ *
621
+ * A chain rather than a lock: a reload is idempotent, so a caller arriving
622
+ * mid-rebuild wants the *next* complete one, and waiting for it is exactly
623
+ * what this gives them.
624
+ */
625
+ let reloads = Promise.resolve();
626
+ const reloadsInOrder = async () => {
627
+ const next = reloads.then(loadRoster, loadRoster);
628
+ // Swallowed for the chain only — the caller still sees the rejection, but
629
+ // a roster that failed to load must not block every reload after it.
630
+ reloads = next.catch(() => undefined);
631
+ await next;
268
632
  };
269
633
  /**
270
634
  * A session pins its agent id, never a snapshot of the agent: the soul
@@ -306,6 +670,11 @@ export const createGateway = (options = {}) => {
306
670
  };
307
671
  // ---- runner pool --------------------------------------------------------
308
672
  const tools = new ToolRegistry();
673
+ // Which package each tool came from. Kernel tools are absent from this
674
+ // map, which is how `tools()` reports them as the kernel's.
675
+ const toolProvenance = new Map();
676
+ let loadedPlugins = [];
677
+ let pluginFailures = [];
309
678
  tools.register(createDemoTool());
310
679
  tools.register(createRememberTool(memory));
311
680
  // Delegation is dispatcher-backed, not runner-capturing: the target agent
@@ -357,7 +726,7 @@ export const createGateway = (options = {}) => {
357
726
  provider,
358
727
  tools,
359
728
  executor: createLocalCommandExecutor(),
360
- ...(options.approvals ? { approvals: options.approvals } : {}),
729
+ ...(approvals ? { approvals } : {}),
361
730
  store,
362
731
  bus,
363
732
  agents: registry,
@@ -379,8 +748,16 @@ export const createGateway = (options = {}) => {
379
748
  * that kills slow-but-healthy turns, so it stays off for them (their
380
749
  * tool phases are covered by executor timeouts regardless).
381
750
  */
382
- const fallbackStreamsDeltas = (fallback) => fallback.provider === 'anthropic' && Boolean(fallback.apiKey);
383
- const streamsDeltas = (config) => config.provider === 'anthropic' && Boolean(config.apiKey);
751
+ const fallbackStreamsDeltas = (fallback) =>
752
+ // The same two modes as a primary. A subscription fallback that is not
753
+ // recognised as streaming gets no watchdog at all once a session
754
+ // switches to it, so a stall there would run to the provider's own
755
+ // ten-minute timer instead of the idle timeout the operator set.
756
+ fallback.provider === 'anthropic' && Boolean(fallback.apiKey || fallback.authToken);
757
+ const streamsDeltas = (config) =>
758
+ // Both Anthropic modes stream now: an API key through the Messages
759
+ // API, a subscription token through the Agent SDK's partial messages.
760
+ config.provider === 'anthropic' && Boolean(config.apiKey || config.authToken);
384
761
  /**
385
762
  * Progress-based abort, armed only while the turn is actually awaiting a
386
763
  * streaming provider — the one phase where silence means a stall. Tool
@@ -413,6 +790,18 @@ export const createGateway = (options = {}) => {
413
790
  // the primary until a reset delta announces the sticky fallback switch.
414
791
  let streamingActive = idleEnabled;
415
792
  let pendingTools = 0;
793
+ /**
794
+ * Tool calls this turn has started and not finished.
795
+ *
796
+ * `pendingTools` above reads a count off `provider.response`, which
797
+ * only a provider that hands its calls back to the kernel loop emits.
798
+ * A provider hosting its own loop dispatches inside `generate`, so that
799
+ * count is zero for the whole of a hosted tool. These are the kernel's
800
+ * own tool events, which every path emits because every path runs its
801
+ * tools through the same executor — and they are turn-local, so an
802
+ * aborted turn takes them with it rather than leaving anything behind.
803
+ */
804
+ const openToolCalls = new Set();
416
805
  // Set at a text-only provider.response: the turn is wrapping up
417
806
  // (saves, completion events); nothing left for the timer to guard.
418
807
  let turnSettling = false;
@@ -450,9 +839,13 @@ export const createGateway = (options = {}) => {
450
839
  }
451
840
  switch (event.type) {
452
841
  case 'provider.delta':
453
- // A reset marks the mid-turn, session-sticky fallback switch:
454
- // the timer follows whether the now-active provider streams.
455
- if (event.delta.type === 'reset') {
842
+ // Only a *fallback* reset marks the mid-turn, session-sticky
843
+ // switch that the timer follows. A provider retrying its own
844
+ // attempt abandons its partial output too, but nothing about
845
+ // who is serving the turn changed — reading that as a switch
846
+ // would disarm the watchdog for the rest of a turn that is
847
+ // still running on the provider it started on.
848
+ if (event.delta.type === 'reset' && event.delta.reason === 'fallback') {
456
849
  streamingActive = fallbackStreams;
457
850
  }
458
851
  break;
@@ -466,8 +859,23 @@ export const createGateway = (options = {}) => {
466
859
  turnSettling = true;
467
860
  }
468
861
  break;
862
+ case 'tool.approval-requested':
863
+ // Redundant with the approval wrapper for a gateway-owned
864
+ // transport, and not redundant for a recovered turn, whose
865
+ // request is re-announced without going through a policy again.
866
+ openToolCalls.add(event.call.id);
867
+ break;
868
+ case 'tool.called':
869
+ openToolCalls.add(event.call.id);
870
+ break;
469
871
  case 'tool.completed':
872
+ // Completion names the call through its result; denial carries
873
+ // the call itself. Both settle it.
874
+ openToolCalls.delete(event.result.callId);
875
+ pendingTools = Math.max(0, pendingTools - 1);
876
+ break;
470
877
  case 'tool.denied':
878
+ openToolCalls.delete(event.call.id);
471
879
  pendingTools = Math.max(0, pendingTools - 1);
472
880
  break;
473
881
  default:
@@ -479,10 +887,15 @@ export const createGateway = (options = {}) => {
479
887
  if (!('sessionId' in event) || event.sessionId !== sessionId) {
480
888
  return;
481
889
  }
482
- if (streamingActive && pendingTools === 0 && !turnSettling) {
890
+ if (streamingActive
891
+ && pendingTools === 0
892
+ && openToolCalls.size === 0
893
+ && (approvalWaits.get(sessionId) ?? 0) === 0
894
+ && !turnSettling) {
483
895
  armTimer();
484
896
  }
485
897
  });
898
+ suspendWatchdog.set(sessionId, suspendTimer);
486
899
  if (streamingActive) {
487
900
  armTimer();
488
901
  }
@@ -499,6 +912,7 @@ export const createGateway = (options = {}) => {
499
912
  suspendTimer();
500
913
  unsubscribeObserver();
501
914
  unsubscribeDrain();
915
+ suspendWatchdog.delete(sessionId);
502
916
  external?.removeEventListener('abort', onExternalAbort);
503
917
  }
504
918
  };
@@ -509,23 +923,26 @@ export const createGateway = (options = {}) => {
509
923
  const sessionChains = new Map();
510
924
  const inflight = new Set();
511
925
  let stopping = false;
512
- const dispatchInternal = async (input) => {
513
- // A dispatch whose signal fired while it queued behind another turn
514
- // must not touch durable state: without this check, the runner would
515
- // load the session, append the cancelled user message, and save it as
516
- // failed — polluting future model history with input never processed.
517
- if (input.signal?.aborted) {
518
- throw new RunAbortedError();
519
- }
520
- // A session pins its agent: when the caller names none, an existing
521
- // conversation keeps the agent it was created with even if the
522
- // configured default soul has changed since — only a brand-new session
523
- // takes the current default. (Loaded here, before the watchdog, also
524
- // because whether this turn streams depends on durable session state.)
525
- const existing = await store.get(input.sessionId);
526
- const agentId = input.agentId ?? existing?.agent.id ?? await defaultAgentId();
527
- const source = await refreshAgent(agentId);
528
- const agent = source.definition;
926
+ /**
927
+ * The turn id running on each session, for callers that named one.
928
+ *
929
+ * Set when the chained work actually starts — not when the dispatch was
930
+ * queued — because a message waiting behind another turn has not begun,
931
+ * and stamping events with it would attribute the running turn's output to
932
+ * the queued one. Single-flight per session is what makes one entry per
933
+ * session sufficient.
934
+ */
935
+ const activeTurns = new Map();
936
+ /**
937
+ * The runtime one agent would run on right now — config snapshot, soul
938
+ * pins, degradation and all.
939
+ *
940
+ * Extracted so recovery resolves it exactly as a dispatch does. A second
941
+ * copy of this chain is the difference between a recovered turn finishing
942
+ * on its agent's provider and finishing on the daemon's default, and it
943
+ * would drift the first time either side gained a rule.
944
+ */
945
+ const runtimeForAgent = async (source) => {
529
946
  // Config re-resolves per dispatch, so a changed model, credential, or
530
947
  // soul pin applies without a restart. The pool dedupes runners by the
531
948
  // resolved configuration. The gateway-wide selection and the daemon's
@@ -584,7 +1001,26 @@ export const createGateway = (options = {}) => {
584
1001
  }
585
1002
  // The preset snapshot means resolution reads no config file — every
586
1003
  // failure from here is a credential or provider problem and surfaces.
587
- const config = await resolveRuntimeConfig(selection, resolveEnv);
1004
+ return resolveRuntimeConfig(selection, resolveEnv);
1005
+ };
1006
+ const dispatchInternal = async (input) => {
1007
+ // A dispatch whose signal fired while it queued behind another turn
1008
+ // must not touch durable state: without this check, the runner would
1009
+ // load the session, append the cancelled user message, and save it as
1010
+ // failed — polluting future model history with input never processed.
1011
+ if (input.signal?.aborted) {
1012
+ throw new RunAbortedError();
1013
+ }
1014
+ // A session pins its agent: when the caller names none, an existing
1015
+ // conversation keeps the agent it was created with even if the
1016
+ // configured default soul has changed since — only a brand-new session
1017
+ // takes the current default. (Loaded here, before the watchdog, also
1018
+ // because whether this turn streams depends on durable session state.)
1019
+ const existing = await store.get(input.sessionId);
1020
+ const agentId = input.agentId ?? existing?.agent.id ?? await defaultAgentId();
1021
+ const source = await refreshAgent(agentId);
1022
+ const agent = source.definition;
1023
+ const config = await runtimeForAgent(source);
588
1024
  const runner = runnerFor(config);
589
1025
  const metadata = {
590
1026
  ...(config.provider === 'demo'
@@ -626,52 +1062,328 @@ export const createGateway = (options = {}) => {
626
1062
  });
627
1063
  });
628
1064
  };
629
- const dispatch = async (input) => {
1065
+ /**
1066
+ * Finishes the turns that were parked on a human when this daemon last
1067
+ * went down.
1068
+ *
1069
+ * Run after the channels are up, deliberately: recovery re-asks, and a
1070
+ * request emitted into a gateway with no live adapter would be declined
1071
+ * for want of anywhere to put it — turning "your approval survived the
1072
+ * restart" into "your approval was denied by the restart".
1073
+ *
1074
+ * Each session is recovered independently and failures are per-session:
1075
+ * one unrecoverable turn must not stop the daemon from serving, or from
1076
+ * recovering the others.
1077
+ */
1078
+ const recoverParkedTurns = async () => {
1079
+ if (!store.listIdsByStatus) {
1080
+ return;
1081
+ }
1082
+ const parked = await store.listIdsByStatus('pending_approval');
1083
+ if (parked.length === 0) {
1084
+ return;
1085
+ }
1086
+ log(`recovering ${parked.length} turn(s) parked on approval`);
1087
+ // Started together, not one after another. Each recovery re-asks and
1088
+ // then waits on a human, so awaiting them in sequence would mean the
1089
+ // second parked turn is not even *announced* until the first is
1090
+ // answered — fifteen minutes later by default, and never at all with a
1091
+ // zero timeout. They are independent turns; only same-session work is
1092
+ // ordered, and onSessionChain already guarantees that.
1093
+ await Promise.allSettled(parked.map((sessionId) => recoverOne(sessionId)));
1094
+ };
1095
+ const recoverOne = (sessionId) => onSessionChain(sessionId, async () => {
630
1096
  if (stopping) {
631
- throw new Error('The gateway is stopping and no longer accepts new work.');
1097
+ return;
1098
+ }
1099
+ try {
1100
+ const session = await store.get(sessionId);
1101
+ if (!session) {
1102
+ return;
1103
+ }
1104
+ const record = readPendingApproval(session);
1105
+ // A wait that outlived its window while the daemon was down is
1106
+ // honoured, not restarted: the request really did go unanswered for
1107
+ // the whole time it was configured to wait, and downtime is not a
1108
+ // reason to extend a security decision. Measured from when the turn
1109
+ // parked against this daemon's timeout — the transport's original
1110
+ // deadline was chosen after the checkpoint was written and is gone
1111
+ // with the process that chose it. Denying goes through the same
1112
+ // recovery path, so the queue behind it still drains.
1113
+ const parkedAt = record ? Date.parse(record.parkedAt) : Number.NaN;
1114
+ const expired = effectiveApprovalTimeoutMs > 0
1115
+ && Number.isFinite(parkedAt)
1116
+ && Date.now() - parkedAt >= effectiveApprovalTimeoutMs;
1117
+ if (expired) {
1118
+ log(`${sessionId}: the approval for ${record?.call.toolName} outlived its window while the daemon was down; denying it`);
1119
+ }
1120
+ // Resolved the way a dispatch for this agent would resolve it, so a
1121
+ // recovered turn finishes on the same provider, model, and
1122
+ // credentials it was parked on.
1123
+ const source = await refreshAgent(session.agent.id);
1124
+ // And on the CURRENT definition, saved before recovery reads the
1125
+ // session back. An allowlist is a permission boundary: a soul that
1126
+ // dropped a tool while the daemon was down must not have that tool
1127
+ // executed by the turn that outlived the change, and
1128
+ // `allowedToolsFor` reads the definition frozen into the session
1129
+ // ahead of the registry. Dispatch refreshes it the same way before
1130
+ // resuming.
1131
+ session.agent = source.definition;
1132
+ await store.save(session);
1133
+ const runner = runnerFor(await runtimeForAgent(source));
1134
+ await runner.recoverPendingApproval(sessionId, { denyPending: expired });
1135
+ }
1136
+ catch (error) {
1137
+ warn(`could not recover parked session ${sessionId}: ${error instanceof Error ? error.message : String(error)}`);
632
1138
  }
633
- const previous = sessionChains.get(input.sessionId) ?? Promise.resolve();
634
- const turn = previous.then(() => dispatchInternal(input), () => dispatchInternal(input));
1139
+ });
1140
+ /**
1141
+ * Turns the last process was still running when it stopped being able to
1142
+ * finish them.
1143
+ *
1144
+ * `pending_approval` is the one window a turn can be picked up from, and
1145
+ * it has its own sweep above. Everything else a turn does is saved as
1146
+ * `running`: the provider call, the executor, and — on a provider that
1147
+ * hosts its own loop — the approval wait too, since a hosted call is
1148
+ * deliberately not checkpointed (`executeHostedToolCall` passes
1149
+ * `recoverable: false`, because the SDK's inner loop is not something a
1150
+ * restart can rebuild). None of those can be resumed, and nothing else
1151
+ * sweeps them, so the record goes on claiming the turn is running for as
1152
+ * long as the session exists.
1153
+ *
1154
+ * Note what this is *not* for. A graceful stop denies everything parked
1155
+ * and drains the turns those denials release, so an operator restarting
1156
+ * the daemon normally has nothing here. These are the turns a crash, a
1157
+ * SIGKILL, or a lost machine left behind.
1158
+ */
1159
+ const listAbandonedTurns = async () => {
1160
+ if (!store.listIdsByStatus) {
1161
+ return [];
1162
+ }
1163
+ return store.listIdsByStatus('running');
1164
+ };
1165
+ /**
1166
+ * Marks each of them failed, with a reason that says what happened.
1167
+ *
1168
+ * Read BEFORE the channels start and applied after, which is the only
1169
+ * ordering that is both honest and safe. Reading first means the list is
1170
+ * exactly what the last process left, with no chance of a turn this
1171
+ * process started being in it. Applying after means the failure is
1172
+ * emitted into a gateway whose surfaces are listening, rather than into
1173
+ * one where nothing is.
1174
+ *
1175
+ * The gap between the two is real, though: an inbound message for one of
1176
+ * these sessions can arrive as soon as the channels are up. What makes
1177
+ * that safe is single-flight — every writer of a session goes through
1178
+ * `onSessionChain`, so this runs either wholly before that turn (it
1179
+ * fails, and `resume()` then clears `lastError` and starts fresh) or
1180
+ * wholly after it (the status has moved off `running`, and the re-read
1181
+ * below leaves it alone). There is no interleaving in which this
1182
+ * overwrites a live turn.
1183
+ *
1184
+ * What that invariant does NOT cover is a second daemon on the same
1185
+ * database, whose turns are running with no chain of ours in front of
1186
+ * them. Two processes sharing a session store is already unsupported —
1187
+ * the parked sweep above would re-ask that daemon's approvals for the
1188
+ * same reason — and no check here would make it supported.
1189
+ */
1190
+ const failAbandonedTurns = async (abandoned) => {
1191
+ if (abandoned.length === 0) {
1192
+ return;
1193
+ }
1194
+ log(`failing ${abandoned.length} turn(s) left running by the last stratusd`);
1195
+ await Promise.allSettled(abandoned.map((id) => failAbandonedTurn(id)));
1196
+ };
1197
+ const failAbandonedTurn = (id) => onSessionChain(id, async () => {
1198
+ if (stopping) {
1199
+ return;
1200
+ }
1201
+ try {
1202
+ const session = await store.get(id);
1203
+ // Re-read on the chain, because the snapshot is from before the
1204
+ // channels came up: a message that arrived since has already taken
1205
+ // this session somewhere else, and that turn owns it now.
1206
+ if (!session || session.status !== 'running') {
1207
+ return;
1208
+ }
1209
+ session.status = 'failed';
1210
+ session.lastError = ABANDONED_TURN_ERROR;
1211
+ await store.save(session);
1212
+ await bus.emit({ type: 'session.updated', sessionId: id, status: 'failed' });
1213
+ await bus.emit({ type: 'session.failed', sessionId: id, error: ABANDONED_TURN_ERROR });
1214
+ }
1215
+ catch (error) {
1216
+ warn(`could not fail abandoned turn ${id}: ${error instanceof Error ? error.message : String(error)}`);
1217
+ }
1218
+ });
1219
+ /**
1220
+ * Runs `work` as the session's next turn: queued behind whatever that
1221
+ * session is already doing, and tracked so shutdown drains it.
1222
+ *
1223
+ * Recovery goes through here as well as dispatch, and must. Channels are
1224
+ * live before a sweep finishes, so an inbound message for a parked
1225
+ * session could otherwise `resume()` alongside `recoverPendingApproval()`
1226
+ * — one closing the parked call as interrupted while the other re-enters
1227
+ * it, both saving divergent copies of the same transcript, and the tool
1228
+ * possibly running twice. Single-flight per session is the invariant that
1229
+ * stops that, and it only holds if everything that writes a session goes
1230
+ * through it.
1231
+ */
1232
+ const onSessionChain = (sessionId, work) => {
1233
+ const previous = sessionChains.get(sessionId) ?? Promise.resolve();
1234
+ const turn = previous.then(work, work);
635
1235
  const settled = turn.catch(() => { });
636
- sessionChains.set(input.sessionId, settled);
1236
+ sessionChains.set(sessionId, settled);
637
1237
  inflight.add(settled);
638
1238
  void settled.finally(() => {
639
1239
  inflight.delete(settled);
640
- if (sessionChains.get(input.sessionId) === settled) {
641
- sessionChains.delete(input.sessionId);
1240
+ if (sessionChains.get(sessionId) === settled) {
1241
+ sessionChains.delete(sessionId);
642
1242
  }
643
1243
  });
644
1244
  return turn;
645
1245
  };
1246
+ const dispatch = async (input) => {
1247
+ if (stopping) {
1248
+ throw new Error('The gateway is stopping and no longer accepts new work.');
1249
+ }
1250
+ const { turnId } = input;
1251
+ if (turnId === undefined) {
1252
+ return onSessionChain(input.sessionId, () => dispatchInternal(input));
1253
+ }
1254
+ return onSessionChain(input.sessionId, async () => {
1255
+ activeTurns.set(input.sessionId, turnId);
1256
+ try {
1257
+ return await dispatchInternal(input);
1258
+ }
1259
+ finally {
1260
+ // Unconditional, and safe to be: the chain guarantees no other turn
1261
+ // for this session ran between the set above and here, so this can
1262
+ // only ever be clearing its own entry.
1263
+ activeTurns.delete(input.sessionId);
1264
+ }
1265
+ });
1266
+ };
1267
+ /**
1268
+ * Load what the config asked for, and say what did not load.
1269
+ *
1270
+ * A plugin that fails is a warning rather than a refusal to start: a
1271
+ * daemon that will not boot because of a mistyped package name takes the
1272
+ * whole fleet down over one line of config, and the agents whose tools
1273
+ * did load are still worth serving. What is never degraded is the
1274
+ * security half — an undeclared name or a collision refuses that plugin
1275
+ * whole, inside the loader.
1276
+ */
1277
+ const startPlugins = async () => {
1278
+ const configured = options.plugins ?? {};
1279
+ if (Object.keys(configured).length === 0) {
1280
+ return;
1281
+ }
1282
+ const result = await loadPlugins({
1283
+ config: configured,
1284
+ host: options.pluginHost ?? {
1285
+ resolve: (specifier) => import.meta.resolve(specifier),
1286
+ import: (specifier) => import(specifier),
1287
+ },
1288
+ tools,
1289
+ bus,
1290
+ workspaceRoot: workspacesDirPath(env),
1291
+ });
1292
+ loadedPlugins = result.loaded;
1293
+ pluginFailures = result.failures;
1294
+ for (const plugin of result.loaded) {
1295
+ for (const tool of plugin.tools) {
1296
+ toolProvenance.set(tool.name, { package: plugin.package, trusted: plugin.trusted });
1297
+ }
1298
+ log(`plugin ${plugin.package} loaded — ${plugin.tools.map((tool) => `${tool.name} (${tool.risk})`).join(', ') || 'no tools'}`);
1299
+ }
1300
+ for (const failure of result.failures) {
1301
+ warn(`plugin ${failure.package} did not load: ${failure.reason}`);
1302
+ }
1303
+ };
1304
+ /**
1305
+ * Everything after the plugins: the roster, the channels, and the sweeps
1306
+ * that need both. Split out so `start()` can wrap it in the cleanup a
1307
+ * failure here requires — see the call site.
1308
+ */
1309
+ const startServing = async () => {
1310
+ await loadRoster();
1311
+ const named = registry.list().map((agent) => agent.name).join(', ');
1312
+ log(`stratusd ready — ${registry.list().length} agent(s): ${named}`);
1313
+ // Read before anything can dispatch, so this is exactly what the
1314
+ // last process left running and not a turn of ours caught in flight.
1315
+ // Applied further down, once there is somewhere for the failure to
1316
+ // be heard.
1317
+ const abandoned = await listAbandonedTurns();
1318
+ // Channels come up after the roster so their first inbound message
1319
+ // already has agents to dispatch to. One failing adapter must not
1320
+ // keep the rest (or the gateway) down.
1321
+ for (const adapter of options.channels ?? []) {
1322
+ try {
1323
+ await adapter.start(gateway);
1324
+ startedChannels.push(adapter);
1325
+ }
1326
+ catch (error) {
1327
+ warn(`channel ${adapter.name} failed to start: ${error instanceof Error ? error.message : String(error)}`);
1328
+ // A start() that rejected may still hold sockets or listeners it
1329
+ // acquired before failing; it never reaches startedChannels, so
1330
+ // this is its only cleanup.
1331
+ try {
1332
+ await adapter.stop();
1333
+ }
1334
+ catch (stopError) {
1335
+ warn(`channel ${adapter.name} cleanup after failed start also failed: ${stopError instanceof Error ? stopError.message : String(stopError)}`);
1336
+ }
1337
+ }
1338
+ }
1339
+ // Last, and not awaited: recovery re-asks, so it needs the channels
1340
+ // above already listening — but a turn parked behind a slow approver
1341
+ // must not hold up start(), or a daemon with one outstanding request
1342
+ // would refuse to finish booting.
1343
+ void recoverParkedTurns().catch((error) => {
1344
+ warn(`recovering parked turns failed: ${error instanceof Error ? error.message : String(error)}`);
1345
+ });
1346
+ // Not awaited for the same reason, and independent of the sweep
1347
+ // above: these sessions are not parked, so the two never touch the
1348
+ // same one.
1349
+ void failAbandonedTurns(abandoned).catch((error) => {
1350
+ warn(`failing abandoned turns failed: ${error instanceof Error ? error.message : String(error)}`);
1351
+ });
1352
+ };
1353
+ /** Release what the plugins acquired, reporting rather than throwing. */
1354
+ const disposePlugins = async () => {
1355
+ await Promise.allSettled(loadedPlugins.map(async (plugin) => {
1356
+ try {
1357
+ await plugin.instance.dispose?.();
1358
+ }
1359
+ catch (error) {
1360
+ warn(`plugin ${plugin.package} failed to shut down: ${error instanceof Error ? error.message : String(error)}`);
1361
+ }
1362
+ }));
1363
+ loadedPlugins = [];
1364
+ };
646
1365
  const startedChannels = [];
647
1366
  const gateway = {
648
1367
  bus,
649
1368
  store,
650
1369
  async start() {
651
1370
  await migrateLegacyMemory(env);
652
- await loadRoster();
653
- const named = registry.list().map((agent) => agent.name).join(', ');
654
- log(`stratusd ready — ${registry.list().length} agent(s): ${named}`);
655
- // Channels come up after the roster so their first inbound message
656
- // already has agents to dispatch to. One failing adapter must not
657
- // keep the rest (or the gateway) down.
658
- for (const adapter of options.channels ?? []) {
659
- try {
660
- await adapter.start(gateway);
661
- startedChannels.push(adapter);
662
- }
663
- catch (error) {
664
- warn(`channel ${adapter.name} failed to start: ${error instanceof Error ? error.message : String(error)}`);
665
- // A start() that rejected may still hold sockets or listeners it
666
- // acquired before failing; it never reaches startedChannels, so
667
- // this is its only cleanup.
668
- try {
669
- await adapter.stop();
670
- }
671
- catch (stopError) {
672
- warn(`channel ${adapter.name} cleanup after failed start also failed: ${stopError instanceof Error ? stopError.message : String(stopError)}`);
673
- }
674
- }
1371
+ // Before the roster and before channels: a turn must never arrive for
1372
+ // an agent whose soul lists a tool the daemon has not registered yet,
1373
+ // which would refuse the call as "not permitted" and blame the soul.
1374
+ await startPlugins();
1375
+ try {
1376
+ await startServing();
1377
+ }
1378
+ catch (error) {
1379
+ // Everything after the plugins loaded is inside that try for one
1380
+ // reason: a `start()` that rejects never reaches its caller's
1381
+ // shutdown path — `stratus serve` awaits it *before* the try/finally
1382
+ // that calls `stop()` — so a duplicate agent id in the roster would
1383
+ // leave a plugin's browser, socket, or subscription held for the
1384
+ // life of a process that is on its way out.
1385
+ await disposePlugins();
1386
+ throw error;
675
1387
  }
676
1388
  },
677
1389
  // Drain: channels stop taking messages, in-flight turns finish, new
@@ -682,16 +1394,112 @@ export const createGateway = (options = {}) => {
682
1394
  // in-flight turns (already-started dispatches keep running), drain,
683
1395
  // and close.
684
1396
  stopping = true;
1397
+ // Deny what is parked before draining, or the drain waits out every
1398
+ // outstanding approval timeout — a shutdown would hang for as long as
1399
+ // the longest request had left. Each denial is a real decision the
1400
+ // turn continues from, so the drain below still finishes those turns.
1401
+ for (const parked of [...pendingApprovals.values()]) {
1402
+ parked.settle('deny', 'cancelled');
1403
+ }
1404
+ // Let those resolutions reach their subscribers before the channels
1405
+ // go down, or a channel that renders approvals never learns to
1406
+ // retract the buttons it is still showing.
1407
+ await Promise.allSettled([...approvalEmissions]);
685
1408
  await Promise.allSettled(startedChannels.map((adapter) => adapter.stop()));
686
1409
  startedChannels.length = 0;
687
1410
  await Promise.allSettled([...inflight]);
1411
+ // After the turns that might still be using them. A plugin holding a
1412
+ // browser is the reason this exists, and closing it out from under a
1413
+ // running screenshot would fail that turn rather than tidy up.
1414
+ await disposePlugins();
688
1415
  store.close();
689
1416
  log('stratusd stopped');
690
1417
  },
691
1418
  dispatch,
1419
+ async sessionRouting(sessionId) {
1420
+ const session = await store.get(sessionId);
1421
+ return session
1422
+ ? { agentId: session.agent.id, metadata: session.metadata ?? {} }
1423
+ : undefined;
1424
+ },
692
1425
  agents() {
693
1426
  return registry.list();
694
1427
  },
1428
+ async reloadRoster() {
1429
+ await reloadsInOrder();
1430
+ const roster = registry.list();
1431
+ log(`roster reloaded — ${roster.length} agent(s): ${roster.map((agent) => agent.name).join(', ')}`);
1432
+ return roster;
1433
+ },
1434
+ activeTurnId(sessionId) {
1435
+ return activeTurns.get(sessionId);
1436
+ },
1437
+ pendingApprovals() {
1438
+ return [...pendingApprovals.values()]
1439
+ .map((parked) => parked.pending)
1440
+ .sort((a, b) => a.parkedAt.localeCompare(b.parkedAt));
1441
+ },
1442
+ tools() {
1443
+ // From the registry, not from the load records: the risk reported is
1444
+ // the one a call will actually be judged at, floor applied, and a
1445
+ // second list assembled from manifests would drift from it the first
1446
+ // time a floor was raised.
1447
+ return tools.describe().map((descriptor) => {
1448
+ const provenance = toolProvenance.get(descriptor.name);
1449
+ return {
1450
+ name: descriptor.name,
1451
+ ...(descriptor.description ? { description: descriptor.description } : {}),
1452
+ risk: descriptor.risk ?? 'gated',
1453
+ ...(provenance ? { package: provenance.package, trusted: provenance.trusted } : {}),
1454
+ };
1455
+ });
1456
+ },
1457
+ plugins() {
1458
+ const loaded = loadedPlugins.map((plugin) => ({
1459
+ package: plugin.package,
1460
+ name: plugin.name,
1461
+ trusted: plugin.trusted,
1462
+ tools: plugin.tools.map((tool) => ({
1463
+ name: tool.name,
1464
+ ...(tool.description ? { description: tool.description } : {}),
1465
+ risk: tool.risk,
1466
+ package: plugin.package,
1467
+ trusted: plugin.trusted,
1468
+ })),
1469
+ }));
1470
+ const failed = pluginFailures.map((failure) => ({
1471
+ package: failure.package,
1472
+ error: failure.reason,
1473
+ }));
1474
+ return [...loaded, ...failed];
1475
+ },
1476
+ async servedSouls() {
1477
+ // From the registry rather than `sources` directly, so the order and
1478
+ // the membership are the roster's own — one entry per agent `agents()`
1479
+ // reports, and no entry for a source the registry no longer serves.
1480
+ return Promise.all(registry.list().map(async (agent) => {
1481
+ const source = sources.get(agent.id);
1482
+ // Re-read, not re-registered: this is a read, and a caller asking
1483
+ // what the daemon would bill must not reshape the roster as a side
1484
+ // effect. `refreshAgent` still owns that on the dispatch path.
1485
+ const fresh = source?.soulPath
1486
+ ? await loadSoulFile(source.soulPath).catch(() => undefined)
1487
+ : undefined;
1488
+ if (fresh && fresh.agent.id !== agent.id) {
1489
+ // The file was given to another agent. `refreshAgent` refuses every
1490
+ // dispatch for this id until the roster reloads, so there is no
1491
+ // runtime being served under it — reporting the cached pins would
1492
+ // claim billing for turns that cannot run, and reporting no soul at
1493
+ // all would claim the daemon-wide default instead. It is omitted.
1494
+ return undefined;
1495
+ }
1496
+ // Unreadable is the one case the cache answers for: the gateway goes
1497
+ // on dispatching from the soul it loaded, so those pins are live.
1498
+ const soul = fresh ?? source?.soul;
1499
+ return { id: agent.id, ...(soul ? { soul } : {}) };
1500
+ })).then((entries) => entries.filter((entry) => entry !== undefined));
1501
+ },
1502
+ resolveApproval,
695
1503
  };
696
1504
  return gateway;
697
1505
  };