@stratusagent/gateway 0.11.3 → 0.11.5

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
@@ -7,25 +7,16 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
7
7
  return path;
8
8
  };
9
9
  import { createHash, randomUUID } from 'node:crypto';
10
- import { chmodSync, mkdirSync } from 'node:fs';
11
- import path from 'node:path';
12
- import { DatabaseSync } from 'node:sqlite';
13
- import { filePathsOf, isUnaddressedTurn, abortErrorFor, AgentRegistry, AgentRunner, EventBus, RunAbortedError, SkillRegistry, ToolRegistry, createSkillReadTool, missingSkillRequirements, describeToolAllowlistFinding, unmatchedToolAllowlist, PENDING_APPROVAL_METADATA_KEY, SESSION_TAINTED_BY_METADATA_KEY, SESSION_TRUST_METADATA_KEY, latestTurnReply, readPendingApproval, } from '@stratusagent/core';
14
- import { createDelegateTool, createForgetTool, createMessageSendTool, createRecallTool, createRememberTool, createScheduleTools, DELEGATED_BY_METADATA_KEY, DELEGATION_DEPTH_METADATA_KEY, delegatingSessionIdOf, isDelegatedSession, outstandingDelegationFor, ROOT_SESSION_ID_METADATA_KEY, SCHEDULE_ID_METADATA_KEY, SCHEDULED_TURN_METADATA_KEY, withoutDelegation, } from '@stratusagent/agents';
10
+ import { filePathsOf, isUnaddressedTurn, abortErrorFor, AgentRegistry, AgentRunner, ChannelRegistry, ContributionRegistry, createRoutedMemoryStore, EventBus, RunAbortedError, SkillRegistry, ToolRegistry, createSkillReadTool, missingSkillRequirements, describeToolAllowlistFinding, unmatchedToolAllowlist, CONTEXT_FLOOR_METADATA_KEY, PENDING_APPROVAL_METADATA_KEY, SESSION_TAINTED_BY_METADATA_KEY, SESSION_TRUST_METADATA_KEY, latestTurnReply, readPendingApproval, } from '@stratusagent/core';
11
+ import { createDelegateTool, createForgetTool, createPinTool, createMessageSendTool, createRecallTool, createRememberTool, createScheduleTools, DELEGATED_BY_METADATA_KEY, DELEGATION_DEPTH_METADATA_KEY, delegatingSessionIdOf, isDelegatedSession, outstandingDelegationFor, ROOT_SESSION_ID_METADATA_KEY, SCHEDULE_ID_METADATA_KEY, SCHEDULED_TURN_METADATA_KEY, withoutDelegation, } from '@stratusagent/agents';
15
12
  import { createSchedulerRuntime, isScheduleSessionId, SqliteScheduleStore, SCHEDULE_SESSION_ID_PREFIX, } from "./schedules.js";
13
+ import { ShardedSessionStore } from "./sessions.js";
16
14
  export { claimHome, homeLockPath, HomeClaimedError, } from "./lock.js";
15
+ export { SqliteSessionStore, ShardedSessionStore, FleetSessionIndex, SessionIdTakenError, tightenSqliteFile, } from "./sessions.js";
17
16
  export { SqliteScheduleStore, createSchedulerRuntime, isScheduleSessionId, SCHEDULE_SESSION_ID_PREFIX, } from "./schedules.js";
18
17
  import { createLocalCommandExecutor } from '@stratusagent/executor-local';
19
18
  import { loadPlugins, } from '@stratusagent/plugins';
20
- import { createDemoTool, createFileCredentialResolver, createFileMemoryStore, createRuntimeProvider, DEFAULT_STRATUS_AGENT, loadOperatorSkills, loadRosterSouls, FALLBACK_ACTIVE_METADATA_KEY, loadSoulFile, memoryFilePath, migrateLegacyMemory, PROVIDER_STATE_METADATA_KEYS, ConfigFileError, loadConfigFile, resolveConfigLocation, resolveConfiguredSoul, resolveRuntimeConfig, applySoulPins, assertStateCompatible, stratusHomePath, workspacesDirPath, withLegacyDefaultMemories, } from '@stratusagent/state';
21
- const SESSIONS_DB_FILENAME = 'sessions.db';
22
- /**
23
- * Where a daemon with no explicit `sessionDbPath` keeps its database — the
24
- * one rule for it, exported because `stratus schedules` opens the same file
25
- * from another process and a re-derived copy of this join is how the CLI
26
- * ends up auditing a database no daemon writes.
27
- */
28
- export const defaultSessionDbPath = (env = {}) => path.join(stratusHomePath(env), SESSIONS_DB_FILENAME);
19
+ import { BUILTIN_EXECUTOR_NAME, BUILTIN_MEMORY_STORE_NAME, BUILTIN_PROVIDER_NAMES, createDemoTool, createFileCredentialResolver, createHomeMemoryStore, drainSharedMemory, hasBracketedLegacyStateIn, createRuntimeProvider, DEFAULT_STRATUS_AGENT, isRegisteredProviderName, loadChannelTransportSecrets, registeredProviderNameOf, loadOperatorSkills, loadRosterSouls, FALLBACK_ACTIVE_METADATA_KEY, loadSoulFile, migrateLegacyMemory, PROVIDER_STATE_METADATA_KEYS, ConfigFileError, loadConfigFile, resolveConfigLocation, resolveConfiguredSoul, resolveRuntimeConfig, applySoulPins, assertStateCompatible, stratusHomePath, fleetDbIn, createAgentWorkspaces, foldedAgentId, } from '@stratusagent/state';
29
20
  const DEFAULT_IDLE_TIMEOUT_MS = 120_000;
30
21
  /**
31
22
  * How long a parked call waits for a person. Long enough to survive a
@@ -39,179 +30,6 @@ const DEFAULT_APPROVAL_TIMEOUT_MS = 900_000;
39
30
  * 30-day approval window would expire every request almost immediately.
40
31
  */
41
32
  const MAX_TIMER_DELAY_MS = 2_147_483_647;
42
- export class SqliteSessionStore {
43
- db;
44
- constructor(filePath, options = {}) {
45
- // Sessions hold complete conversations (prompts, replies, tool output,
46
- // provider replay state) — owner-only, like the credentials file. The
47
- // file chmods below cover databases created earlier under a looser
48
- // umask too; directories the store creates are born 0700.
49
- const dir = path.dirname(filePath);
50
- mkdirSync(dir, { recursive: true, mode: 0o700 });
51
- if (options.ownedDirectory) {
52
- chmodSync(dir, 0o700);
53
- }
54
- this.db = new DatabaseSync(filePath);
55
- // The database file is tightened FIRST: SQLite derives sidecar
56
- // permissions from the main file's mode, so everything created later
57
- // inherits owner-only.
58
- try {
59
- chmodSync(filePath, 0o600);
60
- }
61
- catch (error) {
62
- // A database that cannot be tightened must not be used: conversation
63
- // bodies would stay readable by other local users for the daemon's
64
- // whole lifetime, silently.
65
- if (error.code !== 'ENOENT') {
66
- throw error;
67
- }
68
- }
69
- // WAL, not the default DELETE journal: DELETE mode recreates a
70
- // rollback journal on EVERY write, which a one-time chmod could never
71
- // cover in a traversable caller-supplied directory. WAL keeps one
72
- // persistent sidecar pair per connection — forced into existence here
73
- // (the user_version pragma is a real page-one write) so the loop
74
- // below covers them for the connection's whole lifetime.
75
- this.db.exec('PRAGMA journal_mode = WAL');
76
- // A busy timeout, because this file has a second writer: `stratus
77
- // schedules cancel` opens its own connection to the schedules table
78
- // living here, and WAL serializes writers file-wide — a session save
79
- // racing that delete would otherwise throw `database is locked` rather
80
- // than wait its brief turn.
81
- this.db.exec('PRAGMA busy_timeout = 5000');
82
- this.db.exec(`
83
- CREATE TABLE IF NOT EXISTS sessions (
84
- id TEXT PRIMARY KEY,
85
- agent_id TEXT NOT NULL,
86
- status TEXT NOT NULL,
87
- body TEXT NOT NULL,
88
- created_at TEXT NOT NULL,
89
- updated_at TEXT NOT NULL
90
- )
91
- `);
92
- this.db.exec('PRAGMA user_version = 0');
93
- for (const sensitive of [filePath, `${filePath}-wal`, `${filePath}-shm`, `${filePath}-journal`]) {
94
- try {
95
- chmodSync(sensitive, 0o600);
96
- }
97
- catch (error) {
98
- // A sidecar that does not exist has nothing to tighten; anything
99
- // else means session data stays readable — refuse to run over it.
100
- if (error.code !== 'ENOENT') {
101
- throw error;
102
- }
103
- }
104
- }
105
- }
106
- async create(input) {
107
- const now = new Date().toISOString();
108
- const session = { ...input, createdAt: now, updatedAt: now };
109
- this.db
110
- .prepare('INSERT OR REPLACE INTO sessions (id, agent_id, status, body, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)')
111
- .run(session.id, session.agent.id, session.status, JSON.stringify(session), session.createdAt, session.updatedAt);
112
- return session;
113
- }
114
- async get(id) {
115
- const row = this.db.prepare('SELECT body FROM sessions WHERE id = ?').get(id);
116
- return row ? JSON.parse(row.body) : undefined;
117
- }
118
- async save(session) {
119
- const updated = { ...session, updatedAt: new Date().toISOString() };
120
- this.db
121
- .prepare('INSERT OR REPLACE INTO sessions (id, agent_id, status, body, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)')
122
- .run(updated.id, updated.agent.id, updated.status, JSON.stringify(updated), updated.createdAt, updated.updatedAt);
123
- }
124
- /**
125
- * Session ids in a state, oldest first — the index a restarting daemon
126
- * sweeps for turns parked on a human. The `status` column carries it, so
127
- * no conversation body is deserialized to answer the question.
128
- */
129
- async listIdsByStatus(status) {
130
- const rows = this.db
131
- .prepare('SELECT id FROM sessions WHERE status = ? ORDER BY updated_at ASC')
132
- .all(status);
133
- return rows.map((row) => row.id);
134
- }
135
- /**
136
- * When each agent last did anything, and how many of its sessions are live
137
- * right now.
138
- *
139
- * One aggregate over the indexed columns — no conversation body is
140
- * deserialized, for the same reason `listIdsByStatus` returns ids: a
141
- * roster view asks this on every load, and it must not cost a JSON parse
142
- * per session to answer.
143
- *
144
- * Both halves are needed and neither is sufficient. `lastActiveAt` alone
145
- * reads a turn parked on a human as idle, because the save that recorded
146
- * the park is the last thing that touched the row — and a turn waiting
147
- * twenty minutes on an approval is exactly when someone wants to see the
148
- * agent lit. `activeSessions` alone loses an agent that finished a moment
149
- * ago. The caller decides what window counts as "recent"; a daemon that
150
- * baked one in would need upgrading to change it.
151
- */
152
- /**
153
- * How many sessions are in each state.
154
- *
155
- * A grouped count, not a listing that gets counted. The table grows for the
156
- * life of an install and a health endpoint is polled, so materialising one
157
- * object per historical session to produce five numbers gets steadily
158
- * slower at exactly the thing meant to report that the daemon is fine.
159
- */
160
- countByStatus() {
161
- const rows = this.db
162
- .prepare('SELECT status, COUNT(*) AS total FROM sessions GROUP BY status')
163
- .all();
164
- const counts = {};
165
- for (const row of rows) {
166
- counts[row.status] = Number(row.total);
167
- }
168
- return counts;
169
- }
170
- lastActivityByAgent() {
171
- const rows = this.db
172
- .prepare(`
173
- SELECT agent_id,
174
- MAX(updated_at) AS last_active_at,
175
- SUM(CASE WHEN status IN ('running', 'pending_approval') THEN 1 ELSE 0 END) AS active_sessions
176
- FROM sessions
177
- GROUP BY agent_id
178
- `)
179
- .all();
180
- const activity = {};
181
- for (const row of rows) {
182
- activity[row.agent_id] = {
183
- lastActiveAt: row.last_active_at,
184
- activeSessions: Number(row.active_sessions),
185
- };
186
- }
187
- return activity;
188
- }
189
- /**
190
- * Newest-first session listing for one agent (or all agents).
191
- *
192
- * `limit` is not decoration: this table grows for the life of an install,
193
- * and a surface that renders "recent conversations" would otherwise pull
194
- * every session anyone has ever had to show ten of them.
195
- */
196
- list(agentId, limit) {
197
- // -1 is SQLite's "no limit", so one prepared statement serves both cases
198
- // rather than four.
199
- const bound = limit !== undefined && Number.isInteger(limit) && limit >= 0 ? limit : -1;
200
- const rows = (agentId
201
- ? 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)
202
- : this.db.prepare('SELECT id, agent_id, status, created_at, updated_at FROM sessions ORDER BY updated_at DESC LIMIT ?').all(bound));
203
- return rows.map((row) => ({
204
- id: row.id,
205
- agentId: row.agent_id,
206
- status: row.status,
207
- createdAt: row.created_at,
208
- updatedAt: row.updated_at,
209
- }));
210
- }
211
- close() {
212
- this.db.close();
213
- }
214
- }
215
33
  /**
216
34
  * A soul's provider/model pins, and the daemon-wide defaults they demote.
217
35
  *
@@ -288,6 +106,8 @@ export const ROLLED_OVER_FROM_METADATA_KEY = 'rolledOverFrom';
288
106
  export const ROLLED_OVER_TO_METADATA_KEY = 'rolledOverTo';
289
107
  /** The segment a rollover mints into the archived transcript's id. */
290
108
  export const ROLLED_OVER_SESSION_ID_MARKER = ':rolledover:';
109
+ /** Where the session's tool calls ran: `local-command`, or a contributed executor's registered name. */
110
+ export const EXECUTOR_METADATA_KEY = 'executor';
291
111
  export const RESERVED_SESSION_METADATA_KEYS = [
292
112
  PENDING_APPROVAL_METADATA_KEY,
293
113
  FALLBACK_ACTIVE_METADATA_KEY,
@@ -302,6 +122,17 @@ export const RESERVED_SESSION_METADATA_KEYS = [
302
122
  SESSION_TAINTED_BY_METADATA_KEY,
303
123
  ROLLED_OVER_FROM_METADATA_KEY,
304
124
  ROLLED_OVER_TO_METADATA_KEY,
125
+ // Which executor ran the turn's tool calls is the daemon's record of
126
+ // where a command ran, and the one audit question a caller must not be
127
+ // able to answer for it.
128
+ EXECUTOR_METADATA_KEY,
129
+ // The context floor is an index into a specific transcript, so it means
130
+ // nothing next to a different one. Reserved for both halves of that: a
131
+ // caller cannot seed it, and a rollover drops it with the transcript it
132
+ // indexed — otherwise the fresh session inherits an absolute floor over
133
+ // no messages and, once it grows past that index, silently stops sending
134
+ // its own earliest turns, with no overflow and no trim event to say so.
135
+ CONTEXT_FLOOR_METADATA_KEY,
305
136
  ];
306
137
  /**
307
138
  * The first reserved key `metadata` carries, or undefined when it carries
@@ -326,18 +157,39 @@ export const createGateway = (options = {}) => {
326
157
  const bus = new EventBus({
327
158
  onError: (error) => warn(`event handler failed: ${error instanceof Error ? error.message : String(error)}`),
328
159
  });
329
- // The default database lives in the gateway-owned ~/.stratus, which is
330
- // tightened to owner-only; a caller-supplied path may sit in a shared
160
+ // The default state directory is the gateway-owned ~/.stratus, which is
161
+ // tightened to owner-only; a caller-supplied one may sit in a shared
331
162
  // directory that must not be chmodded from under other processes.
332
- const sessionDbPath = options.sessionDbPath ?? defaultSessionDbPath(env);
333
- const store = options.sessionDbPath
334
- ? new SqliteSessionStore(sessionDbPath)
335
- : new SqliteSessionStore(sessionDbPath, { ownedDirectory: true });
336
- const memory = withLegacyDefaultMemories(createFileMemoryStore(memoryFilePath(env)));
163
+ const stateDir = options.stateDir ?? stratusHomePath(env);
164
+ const store = new ShardedSessionStore({
165
+ stateDir,
166
+ ...(options.stateDir ? {} : { ownedDirectory: true }),
167
+ });
168
+ // The built-in store, behind a route that can change its mind: the memory
169
+ // tools and every runner are built before a plugin has loaded, so the
170
+ // store they hold is the one that answers *per call* with whichever was
171
+ // selected once the plugins are up. Per call, not per start, is also
172
+ // what keeps a contributed store per-agent-resolvable rather than
173
+ // process-global.
174
+ const fileMemory = createHomeMemoryStore(env);
175
+ let selectedMemory = fileMemory;
176
+ const memory = createRoutedMemoryStore(() => selectedMemory);
177
+ let selectedExecutor;
178
+ // What loaded plugins contributed besides tools and skills. The loader
179
+ // commits into these; the gateway reads them by name — a provider when a
180
+ // runner is built, the executor and memory store once at start, the
181
+ // channels when serving begins.
182
+ const providerContributions = new ContributionRegistry();
183
+ const channelContributions = new ChannelRegistry();
184
+ for (const claim of options.hostChannelClaims ?? []) {
185
+ channelContributions.claim(claim.kind, claim.agents);
186
+ }
187
+ const memoryContributions = new ContributionRegistry();
188
+ const executorContributions = new ContributionRegistry();
337
189
  // ---- schedules ----------------------------------------------------------
338
190
  // The same database sessions live in — one file to back up — through the
339
191
  // schedule store's own connection (see its docs for why).
340
- const scheduleStore = new SqliteScheduleStore(sessionDbPath);
192
+ const scheduleStore = new SqliteScheduleStore(fleetDbIn(stateDir));
341
193
  /**
342
194
  * The write side of an addressable destination, through whichever
343
195
  * started channel serves its kind. Resolution happens per call against
@@ -346,7 +198,31 @@ export const createGateway = (options = {}) => {
346
198
  * both callers (schedule creation, `message.send`) hear why.
347
199
  */
348
200
  const outboundFor = async (agentId, destination) => {
349
- const adapter = startedChannels.find((candidate) => candidate.name === destination.channel && candidate.resolveOutbound);
201
+ const candidates = startedChannels.filter((candidate) => candidate.name === destination.channel && candidate.resolveOutbound);
202
+ // One kind may be served by several adapters carrying disjoint
203
+ // agents — two plugins, or the host's adapter beside a plugin's — and
204
+ // the one that speaks for this agent is the one whose claims include
205
+ // it: a contribution's agent list, or the host's declared claims for
206
+ // an adapter no plugin contributed. A host that declared no claims for
207
+ // the kind left its adapter unclaimed, and an unclaimed adapter speaks
208
+ // for anyone, as the single host adapter always did. A claim miss is
209
+ // a refusal, never the first adapter that happens to be running: that
210
+ // adapter posts under another agent's transport identity, and it is
211
+ // exactly what a failed start of the right adapter would leave behind.
212
+ const contributed = channelContributions.list();
213
+ const hostClaims = (options.hostChannelClaims ?? []).filter((claim) => claim.kind === destination.channel);
214
+ const carries = (candidate) => {
215
+ const entry = contributed.find((contribution) => contribution.adapter === candidate);
216
+ if (entry) {
217
+ return entry.agents.includes(agentId);
218
+ }
219
+ return hostClaims.length === 0 || hostClaims.some((claim) => claim.agents.includes(agentId));
220
+ };
221
+ const adapter = candidates.find(carries);
222
+ if (!adapter?.resolveOutbound && candidates.length > 0) {
223
+ throw new Error(`No running '${destination.channel}' channel carries agent ${agentId} — `
224
+ + `the running ${destination.channel} adapters carry other agents, so a message from ${agentId} has no transport identity to post under.`);
225
+ }
350
226
  if (!adapter?.resolveOutbound) {
351
227
  throw new Error(`No running channel can deliver to '${destination.channel}' destinations — `
352
228
  + (startedChannels.length === 0
@@ -656,6 +532,24 @@ export const createGateway = (options = {}) => {
656
532
  registry.register(source.definition);
657
533
  sources.set(source.definition.id, source);
658
534
  };
535
+ /**
536
+ * An id already registered that would open `agentId`'s state directory
537
+ * while being a different id — `ava` when asked about `Ava`, on the
538
+ * filesystems that fold them together.
539
+ *
540
+ * `foldedAgentId` owns which pairs those are. What this adds is the
541
+ * "already registered" half, for the one registration that does not come
542
+ * through `loadRosterSouls` and so was never folded against anything.
543
+ */
544
+ const stateDirectoryPeer = (agentId) => {
545
+ const folded = foldedAgentId(agentId);
546
+ for (const other of sources.keys()) {
547
+ if (other !== agentId && foldedAgentId(other) === folded) {
548
+ return other;
549
+ }
550
+ }
551
+ return undefined;
552
+ };
659
553
  /**
660
554
  * The agent an agentId-less dispatch routes to: the configured default
661
555
  * soul when one is set (what `stratus setup` writes to config.json),
@@ -666,9 +560,27 @@ export const createGateway = (options = {}) => {
666
560
  * built-in definition.
667
561
  */
668
562
  let lastDefaultAgentId;
563
+ /**
564
+ * What the last default resolution read, for the roster load to judge
565
+ * — the soul is re-read on every resolution, so a `tools:` key removed
566
+ * after startup is seen here, where the registered source would still
567
+ * carry the old definition until the next dispatch refreshes it.
568
+ */
569
+ let lastDefaultResolved;
669
570
  // The last successfully loaded config snapshot, serving dispatches while
670
571
  // the file on disk is temporarily broken (an operator mid-edit).
671
572
  let lastGoodConfigSnapshot;
573
+ /**
574
+ * Said at load, because the allowlist fails open: `tools` omitted is
575
+ * every registered tool, the opposite of what `skills` and `credentials`
576
+ * do when omitted, and a soul that never wrote the key holds `shell.run`
577
+ * the moment that plugin is enabled. The built-in agent is exempt — it
578
+ * has no file to add the key to.
579
+ */
580
+ const warnNoToolsList = (agentId, soulPath) => {
581
+ warn(`agent ${agentId} has no tools: list, so it may call every tool this daemon loads — `
582
+ + `add tools: [...] to ${soulPath} to say which`);
583
+ };
672
584
  const defaultAgentId = async () => {
673
585
  // Identity only — never full runtime resolution: credential checks do
674
586
  // not belong here, or a daemon default provider without installed keys
@@ -691,9 +603,33 @@ export const createGateway = (options = {}) => {
691
603
  // now the cached answer too, so a later transient read failure
692
604
  // cannot resurrect the retired soul's identity.
693
605
  lastDefaultAgentId = DEFAULT_STRATUS_AGENT.id;
606
+ lastDefaultResolved = undefined;
694
607
  return DEFAULT_STRATUS_AGENT.id;
695
608
  }
696
609
  const id = resolved.soul.agent.id;
610
+ // The roster refuses two *souls* whose ids name one state directory,
611
+ // but this soul never went through it: a config-only default is
612
+ // resolved here, by path, and registered by exact id. So `Ava` in
613
+ // config beside `ava` on the roster — or `Stratus` beside the built-in
614
+ // — passed as two agents sharing one `agents/<id>/`, which is one
615
+ // `whitelist.json` and therefore one agent acting on the other's
616
+ // unattended grants.
617
+ //
618
+ // Warned and dropped rather than thrown: this runs on every
619
+ // agentId-less dispatch, and a configuration mistake must not turn
620
+ // every such turn into an error. Dropping routes to the built-in,
621
+ // which is the documented answer when there is no usable default, and
622
+ // leaves the colliding agent serving from its own directory alone.
623
+ const peer = stateDirectoryPeer(id);
624
+ if (peer !== undefined) {
625
+ warn(`the configured default soul at ${resolved.path} declares the agent id ${id}, which names the same `
626
+ + `state directory as ${peer} — sessions, memories, and the grants that say what may run unattended. `
627
+ + `Ignoring it and routing to ${DEFAULT_STRATUS_AGENT.id}; rename one of the two ids.`);
628
+ lastDefaultAgentId = DEFAULT_STRATUS_AGENT.id;
629
+ lastDefaultResolved = undefined;
630
+ return DEFAULT_STRATUS_AGENT.id;
631
+ }
632
+ lastDefaultResolved = resolved;
697
633
  // The normal setup layout has the default soul in ~/.stratus/agents
698
634
  // too: keep the roster registration — its soulPath drives per-dispatch
699
635
  // refresh. A config-only soul registers with the path it resolved
@@ -739,6 +675,9 @@ export const createGateway = (options = {}) => {
739
675
  // no correct winner, and refusing to serve beats letting sort order
740
676
  // decide whose sessions, memory, and credentials an agent inherits.
741
677
  const entries = await loadRosterSouls(env, warn);
678
+ // Which souls this pass named for having no tools: list, so the
679
+ // configured default is not named twice when it is one of them.
680
+ const namedThisPass = new Set();
742
681
  for (const entry of entries) {
743
682
  // Against ids claimed by THIS pass, not by any earlier one: a reload
744
683
  // must be free to re-register the agent it just re-read, and only the
@@ -752,12 +691,28 @@ export const createGateway = (options = {}) => {
752
691
  continue;
753
692
  }
754
693
  registerFresh({ definition: entry.soul.agent, soulPath: entry.path, soul: entry.soul });
694
+ if (entry.soul.agent.tools === undefined) {
695
+ warnNoToolsList(entry.soul.agent.id, entry.path);
696
+ namedThisPass.add(entry.path);
697
+ }
755
698
  }
756
699
  // The configured default soul is part of the roster too — it is what
757
700
  // an agentId-less dispatch answers as. It may live outside the agents
758
701
  // directory, and it survives a read failure by name, so what it
759
702
  // resolves to is kept whether or not this pass re-registered it.
760
- seen.add(await defaultAgentId());
703
+ const defaultId = await defaultAgentId();
704
+ seen.add(defaultId);
705
+ // The same notice for a default soul that lives outside the agents
706
+ // directory — the one path into the roster that skips the loop above
707
+ // — on every load, judged on what the resolution just read rather
708
+ // than on the registered source, so a key removed after startup is
709
+ // reported at the next reload. A roster-backed default was named by
710
+ // the loop, and is not named twice.
711
+ if (lastDefaultResolved !== undefined
712
+ && lastDefaultResolved.soul.agent.tools === undefined
713
+ && !namedThisPass.has(lastDefaultResolved.path)) {
714
+ warnNoToolsList(defaultId, lastDefaultResolved.path);
715
+ }
761
716
  for (const id of [...sources.keys()]) {
762
717
  if (!seen.has(id)) {
763
718
  sources.delete(id);
@@ -932,6 +887,7 @@ export const createGateway = (options = {}) => {
932
887
  tools.register(createRememberTool(memory));
933
888
  tools.register(createRecallTool(memory));
934
889
  tools.register(createForgetTool(memory));
890
+ tools.register(createPinTool(memory));
935
891
  // Registered here rather than left to the first runner, so `tools()`
936
892
  // lists the reader before anything dispatches. The allowlist resolver
937
893
  // mirrors the runner's own sourcing — the definition travelling with the
@@ -995,11 +951,14 @@ export const createGateway = (options = {}) => {
995
951
  // The sticky-fallback switch is durable the moment it happens, not
996
952
  // when the turn's next save lands — a daemon killed mid-fallback
997
953
  // must not retry the primary on restart.
998
- (session) => store.save(session));
954
+ (session) => store.save(session), providerContributions);
999
955
  const runner = new AgentRunner({
1000
956
  provider,
1001
957
  tools,
1002
- executor: createLocalCommandExecutor(),
958
+ // Runners are built on dispatch, after the plugins loaded and the
959
+ // selection below ran, so a contributed executor is in place for
960
+ // the first turn.
961
+ executor: selectedExecutor ?? createLocalCommandExecutor(),
1003
962
  ...(approvals ? { approvals } : {}),
1004
963
  store,
1005
964
  bus,
@@ -1055,13 +1014,23 @@ export const createGateway = (options = {}) => {
1055
1014
  // ten-minute timer instead of the idle timeout the operator set.
1056
1015
  (fallback.provider === 'anthropic' && Boolean(fallback.apiKey || fallback.authToken))
1057
1016
  // The codex harness streams item snapshots in every auth mode.
1058
- || fallback.provider === 'codex';
1017
+ || fallback.provider === 'codex'
1018
+ // A contributed fallback says for itself, exactly as a contributed
1019
+ // primary does below; a session that switched to one must not lose
1020
+ // the watchdog the plugin declared it could have.
1021
+ || (isRegisteredProviderName(fallback.provider)
1022
+ && providerContributions.get(registeredProviderNameOf(fallback.provider))?.streams === true);
1059
1023
  const streamsDeltas = (config) =>
1060
1024
  // Both Anthropic modes stream now: an API key through the Messages
1061
1025
  // API, a subscription token through the Agent SDK's partial messages —
1062
- // and the codex harness streams item snapshots in every auth mode.
1026
+ // and the codex harness streams item snapshots in every auth mode. A
1027
+ // contributed provider says for itself, and one that did not say is
1028
+ // read as not streaming: a watchdog armed over a silent-until-done
1029
+ // call would abort every long turn.
1063
1030
  (config.provider === 'anthropic' && Boolean(config.apiKey || config.authToken))
1064
- || config.provider === 'codex';
1031
+ || config.provider === 'codex'
1032
+ || (isRegisteredProviderName(config.provider)
1033
+ && providerContributions.get(registeredProviderNameOf(config.provider))?.streams === true);
1065
1034
  /**
1066
1035
  * Progress-based abort, armed only while the turn is actually awaiting a
1067
1036
  * streaming provider — the one phase where silence means a stall. Tool
@@ -1388,11 +1357,21 @@ export const createGateway = (options = {}) => {
1388
1357
  }
1389
1358
  const config = await runtimeForAgent(source);
1390
1359
  const runner = runnerFor(config);
1360
+ // Which executor the turn's tool calls run through — the built-in
1361
+ // under the name `stratus run` has always recorded, a contributed one
1362
+ // under its registered name — so a transcript says where a command
1363
+ // ran, not only which model asked for it.
1364
+ const executorRecord = selectedExecutor && options.executor !== undefined ? options.executor : 'local-command';
1391
1365
  const metadata = {
1392
- ...(config.provider === 'demo'
1393
- ? { provider: 'demo' }
1394
- : { provider: config.provider, model: config.model }),
1366
+ provider: config.provider,
1367
+ // A contributed provider may leave the model to its own default, in
1368
+ // which case there is none to record.
1369
+ ...(config.provider !== 'demo' && config.model !== undefined ? { model: config.model } : {}),
1395
1370
  ...input.metadata,
1371
+ // After the caller's metadata, and reserved besides: a dispatch that
1372
+ // could write it could make a transcript claim a sandbox ran what
1373
+ // the host ran.
1374
+ [EXECUTOR_METADATA_KEY]: executorRecord,
1396
1375
  };
1397
1376
  if (existing && existing.agent.id !== agent.id) {
1398
1377
  throw new Error(`Session ${input.sessionId} belongs to agent ${existing.agent.id}, not ${agent.id} — sessions never cross agent identities.`);
@@ -1424,6 +1403,13 @@ export const createGateway = (options = {}) => {
1424
1403
  if (isDelegatedSession(existing) && existing.metadata) {
1425
1404
  existing.metadata = withoutDelegation(existing.metadata);
1426
1405
  }
1406
+ // The executor record follows the daemon, not the session's first
1407
+ // turn: a restart that selected another executor runs this turn's
1408
+ // commands there, and a session from before the record existed
1409
+ // gets one now rather than never.
1410
+ if (existing.metadata?.[EXECUTOR_METADATA_KEY] !== executorRecord) {
1411
+ existing.metadata = { ...existing.metadata, [EXECUTOR_METADATA_KEY]: executorRecord };
1412
+ }
1427
1413
  await store.save(existing);
1428
1414
  // The turn's metadata rides along for the sender's trust: without
1429
1415
  // it a resumed turn has no way to say who sent it, and a stranger
@@ -1853,11 +1839,20 @@ export const createGateway = (options = {}) => {
1853
1839
  tools,
1854
1840
  skills: skillCatalog,
1855
1841
  bus,
1842
+ providers: providerContributions,
1843
+ channels: channelContributions,
1844
+ memory: memoryContributions,
1845
+ executors: executorContributions,
1846
+ // The host-owned path for a channel's transport secrets: the same
1847
+ // `channels.<kind>.<agentId>` namespace Slack's tokens live in, read
1848
+ // for the kind the plugin declared, and never through the
1849
+ // agent-scoped resolver below.
1850
+ channelSecrets: (kind) => loadChannelTransportSecrets(env, kind),
1856
1851
  // The file-backed resolver, so a plugin needing a key reaches the
1857
1852
  // *calling* agent's — its allowlist checked, its own entry before the
1858
1853
  // fleet's shared one.
1859
1854
  credentials: createFileCredentialResolver(env),
1860
- workspaceRoot: workspacesDirPath(env),
1855
+ workspaces: createAgentWorkspaces(env),
1861
1856
  // The structured log, so a plugin's lifecycle lines — an MCP server
1862
1857
  // that dropped, a reconnect that failed — are in `stratus logs` and
1863
1858
  // not only on a stderr the service manager owns.
@@ -1870,6 +1865,10 @@ export const createGateway = (options = {}) => {
1870
1865
  const contributed = [
1871
1866
  ...plugin.tools.map((tool) => `${tool.name} (${tool.risk})`),
1872
1867
  ...plugin.skills.map((skill) => `skill ${skill.id}`),
1868
+ ...plugin.contributions.providers.map((name) => `provider ${name}`),
1869
+ ...plugin.contributions.channels.map((channel) => `channel ${channel.kind} for ${channel.agents.join(', ') || 'no agent'}`),
1870
+ ...plugin.contributions.memory.map((name) => `memory store ${name}`),
1871
+ ...plugin.contributions.executors.map((name) => `executor ${name}`),
1873
1872
  ];
1874
1873
  log(`plugin ${plugin.package} loaded — ${contributed.join(', ') || 'no tools'}`);
1875
1874
  }
@@ -1877,6 +1876,43 @@ export const createGateway = (options = {}) => {
1877
1876
  warn(`plugin ${failure.package} did not load: ${failure.reason}`);
1878
1877
  }
1879
1878
  };
1879
+ /**
1880
+ * Which package registered a name, for the log line and the refusal.
1881
+ * From the load records rather than a second map: they are the record.
1882
+ */
1883
+ const contributorOf = (kind, name) => loadedPlugins.find((plugin) => plugin.contributions[kind].includes(name))?.package;
1884
+ /**
1885
+ * Put the operator's executor and memory-store selections into effect,
1886
+ * or refuse to start. Refuse, not warn: the built-in executor runs
1887
+ * commands on this host, and an operator who selected a sandbox that
1888
+ * failed to load must not find their agents on the host anyway; a
1889
+ * memory store the same, one write later. A name no plugin registered
1890
+ * is named with what is, so a typo reads as one.
1891
+ */
1892
+ const applyContributionSelections = () => {
1893
+ const executorName = options.executor;
1894
+ if (executorName !== undefined && executorName !== BUILTIN_EXECUTOR_NAME) {
1895
+ const contribution = executorContributions.get(executorName);
1896
+ if (!contribution) {
1897
+ throw new Error(`The config selects executor ${executorName}, which no loaded plugin registers`
1898
+ + (executorContributions.names().length > 0 ? ` (registered: ${executorContributions.names().join(', ')})` : '')
1899
+ + `. Enable the plugin that contributes it, or set executor to ${BUILTIN_EXECUTOR_NAME}.`);
1900
+ }
1901
+ selectedExecutor = contribution.executor;
1902
+ log(`executor ${executorName} selected, from ${contributorOf('executors', executorName) ?? 'a plugin'}`);
1903
+ }
1904
+ const memoryName = options.memoryStore;
1905
+ if (memoryName !== undefined && memoryName !== BUILTIN_MEMORY_STORE_NAME) {
1906
+ const contribution = memoryContributions.get(memoryName);
1907
+ if (!contribution) {
1908
+ throw new Error(`The config selects memoryStore ${memoryName}, which no loaded plugin registers`
1909
+ + (memoryContributions.names().length > 0 ? ` (registered: ${memoryContributions.names().join(', ')})` : '')
1910
+ + `. Enable the plugin that contributes it, or set memoryStore to ${BUILTIN_MEMORY_STORE_NAME}.`);
1911
+ }
1912
+ selectedMemory = contribution.store;
1913
+ log(`memory store ${memoryName} selected, from ${contributorOf('memory', memoryName) ?? 'a plugin'}`);
1914
+ }
1915
+ };
1880
1916
  /**
1881
1917
  * Load the operator's `~/.stratus/skills/` into the catalog. Before the
1882
1918
  * plugins, deliberately: an operator's bare id outranks a plugin's bare
@@ -1907,6 +1943,42 @@ export const createGateway = (options = {}) => {
1907
1943
  // it is synchronous for the same reason: the check is a file read, and
1908
1944
  // an async one in that stretch failed CI's restart tests twice.
1909
1945
  assertStateCompatible(env);
1946
+ // A home whose pre-15a state has not been moved yet is refused rather
1947
+ // than served, because serving it is the quiet failure: the stores open
1948
+ // on the new paths, find nothing, and the fleet starts writing a second
1949
+ // population beside every session, schedule, and grant it already had.
1950
+ //
1951
+ // Refused rather than migrated, even though this process is the daemon:
1952
+ // the move needs the home to *itself*, and holding the claim is
1953
+ // something the host does (`claimHome`) — a gateway that migrated on
1954
+ // its own would be asserting an exclusivity nobody gave it. `stratus
1955
+ // serve` runs the migration before it ever gets here, so this only
1956
+ // fires for a host wiring the gateway up itself.
1957
+ // Against `stateDir` — the directory the stores were actually opened
1958
+ // on — rather than the one `env` names. A host that pointed them
1959
+ // somewhere else would otherwise be told its home is fine while the
1960
+ // sessions it is stranding sit in the directory it chose.
1961
+ if (await hasBracketedLegacyStateIn(stateDir)) {
1962
+ throw new Error(`${stateDir} still holds pre-per-agent state (a shared sessions.db, or an <id>.whitelist.json beside the souls), `
1963
+ + 'and serving it now would strand every session, schedule, and grant in it. '
1964
+ + (stateDir === stratusHomePath(env)
1965
+ ? 'Run `stratus update`, or — for a host starting the gateway itself — claim the home with `claimHome` and '
1966
+ + 'await `runStateMigrations(env, { exclusive: true })` from @stratusagent/state before calling start().'
1967
+ : 'This is a state directory of your own choosing, which `stratus update` does not migrate: move that state '
1968
+ + 'into the layout yourself, or point `stateDir` at a directory that has been through it.'));
1969
+ }
1970
+ // Before the sweeps below read a single session id: the index and the
1971
+ // per-agent stores are two durable writes where the shared database had
1972
+ // one primary key, and a crash between them left them disagreeing.
1973
+ // Reconciling here is what makes that disagreement resolve one way —
1974
+ // a claim with no conversation released, a conversation the index lost
1975
+ // re-indexed — rather than leaving an abandoned turn unreachable by the
1976
+ // sweep that was supposed to fail it honestly.
1977
+ const reconciled = await store.reconcile();
1978
+ if (reconciled.released.length > 0 || reconciled.reindexed.length > 0) {
1979
+ log(`session index reconciled: ${reconciled.reindexed.length} re-indexed, `
1980
+ + `${reconciled.released.length} claim(s) released`);
1981
+ }
1910
1982
  await loadRoster();
1911
1983
  const named = registry.list().map((agent) => agent.name).join(', ');
1912
1984
  log(`stratusd ready — ${registry.list().length} agent(s): ${named}`);
@@ -1921,8 +1993,14 @@ export const createGateway = (options = {}) => {
1921
1993
  const orphaned = await listOrphanedDelegations();
1922
1994
  // Channels come up after the roster so their first inbound message
1923
1995
  // already has agents to dispatch to. One failing adapter must not
1924
- // keep the rest (or the gateway) down.
1925
- for (const adapter of options.channels ?? []) {
1996
+ // keep the rest (or the gateway) down. The host's adapters first, then
1997
+ // the plugins' in load order — the `plugins` block's order, which is
1998
+ // the one an operator can read.
1999
+ const adapters = [
2000
+ ...(options.channels ?? []),
2001
+ ...channelContributions.list().map((contribution) => contribution.adapter),
2002
+ ];
2003
+ for (const adapter of adapters) {
1926
2004
  try {
1927
2005
  await adapter.start(gateway);
1928
2006
  startedChannels.push(adapter);
@@ -2138,6 +2216,13 @@ export const createGateway = (options = {}) => {
2138
2216
  async start() {
2139
2217
  try {
2140
2218
  await migrateLegacyMemory(env);
2219
+ // And anything still in the shared `memory.jsonl` a pre-15a build
2220
+ // left (or a still-running one of those recreated). Not a
2221
+ // migration and never stamped — see `drainSharedMemory`.
2222
+ const drained = await drainSharedMemory(env);
2223
+ if (drained) {
2224
+ log(`shared memory drained: ${drained}`);
2225
+ }
2141
2226
  // Before the roster and before channels: a turn must never arrive
2142
2227
  // for an agent whose soul lists a tool the daemon has not
2143
2228
  // registered yet, which would refuse the call as "not permitted"
@@ -2146,6 +2231,7 @@ export const createGateway = (options = {}) => {
2146
2231
  // startSkills).
2147
2232
  await startSkills();
2148
2233
  await startPlugins();
2234
+ applyContributionSelections();
2149
2235
  await startServing();
2150
2236
  }
2151
2237
  catch (error) {
@@ -2455,6 +2541,10 @@ export const createGateway = (options = {}) => {
2455
2541
  path: skill.path,
2456
2542
  };
2457
2543
  }),
2544
+ providers: plugin.contributions.providers,
2545
+ channels: plugin.contributions.channels,
2546
+ memory: plugin.contributions.memory,
2547
+ executors: plugin.contributions.executors,
2458
2548
  }));
2459
2549
  const failed = pluginFailures.map((failure) => ({
2460
2550
  package: failure.package,
@@ -2462,6 +2552,10 @@ export const createGateway = (options = {}) => {
2462
2552
  }));
2463
2553
  return [...loaded, ...failed];
2464
2554
  },
2555
+ providers() {
2556
+ const contributed = loadedPlugins.flatMap((plugin) => plugin.contributions.providers.map((name) => ({ name, package: plugin.package })));
2557
+ return [...BUILTIN_PROVIDER_NAMES.map((name) => ({ name })), ...contributed];
2558
+ },
2465
2559
  async servedSouls() {
2466
2560
  // From the registry rather than `sources` directly, so the order and
2467
2561
  // the membership are the roster's own — one entry per agent `agents()`