@stratusagent/gateway 0.13.7 → 0.14.1

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
@@ -9,17 +9,21 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
9
9
  import { createHash, randomBytes, randomUUID } from 'node:crypto';
10
10
  import { mkdir, readFile, rename, rm } from 'node:fs/promises';
11
11
  import path from 'node:path';
12
- import { filePathsOf, isUnaddressedTurn, carriesKey, leastTrusted, senderTrustOf, SENDER_TRUST_METADATA_KEY, workItemState, lastResponseIsAnswer, MAX_IDEMPOTENCY_KEY_LENGTH, recordTurnFailure, completeAnsweredTurn, abortErrorFor, sessionTrustOf, sessionWriteTrust, 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, conversationContextFrom, CONVERSATION_METADATA_KEY, STRATUS_SKILL_ID, imagesWithinReplayBudget, matchesToolAllowlist, } from '@stratusagent/core';
13
- import { CREDENTIAL_REQUEST_TOOL_NAME, createCredentialRequestTool, createDelegateTool, DELEGATE_TOOL_NAME, createForgetTool, createPinTool, createMessageSendTool, createMessageReadTool, createChannelCreateTool, directMessageReadRefusal, createRecallTool, createRememberTool, createScheduleTools, DELEGATED_BY_METADATA_KEY, DEFAULT_MAX_DELEGATION_DEPTH, DELEGATION_DEPTH_METADATA_KEY, delegatingSessionIdOf, isDelegatedSession, outstandingDelegationFor, ROOT_SESSION_ID_METADATA_KEY, SCHEDULE_ID_METADATA_KEY, SCHEDULED_TURN_METADATA_KEY, withoutDelegation, loadStratusSkill, STRATUS_SKILL_PATH, } from '@stratusagent/agents';
12
+ import { filePathsOf, isUnaddressedTurn, carriesKey, leastTrusted, senderTrustOf, SENDER_TRUST_METADATA_KEY, workItemState, leftForNextStart, lastResponseIsAnswer, MAX_IDEMPOTENCY_KEY_LENGTH, recordTurnFailure, completeAnsweredTurn, abortErrorFor, sessionTrustOf, sessionWriteTrust, 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, conversationContextFrom, CONVERSATION_METADATA_KEY, STRATUS_SKILL_ID, imagesWithinReplayBudget, matchesToolAllowlist, } from '@stratusagent/core';
13
+ import { CREDENTIAL_REQUEST_TOOL_NAME, createCredentialRequestTool, createDelegateTool, DELEGATE_TOOL_NAME, createForgetTool, createPinTool, createMessageSendTool, createMessageReadTool, createChannelArchiveTool, createChannelCreateTool, createChannelInviteTool, createChannelJoinTool, directMessageReadRefusal, createRecallTool, createRememberTool, createScheduleTools, DELEGATED_BY_METADATA_KEY, DELEGATED_SESSION_ID_MARKER, DEFAULT_MAX_DELEGATION_DEPTH, DELEGATION_DEPTH_METADATA_KEY, delegatingSessionIdOf, isDelegatedSession, outstandingDelegationFor, ROOT_SESSION_ID_METADATA_KEY, SCHEDULE_ID_METADATA_KEY, SCHEDULED_TURN_METADATA_KEY, withoutDelegation, loadStratusSkill, STRATUS_SKILL_PATH, credentialVariableProblem, } from '@stratusagent/agents';
14
14
  import { createFleetReader } from "./fleet.js";
15
+ import { createEventIntake, summarizeEvents, } from "./event-intake.js";
16
+ import { SqliteEventStore } from "./events.js";
15
17
  import { createSchedulerRuntime, isScheduleSessionId, SqliteScheduleStore, SCHEDULE_SESSION_ID_PREFIX, } from "./schedules.js";
16
18
  import { ShardedSessionStore } from "./sessions.js";
17
19
  export { claimHome, homeLockPath, HomeClaimedError, } from "./lock.js";
18
20
  export { SqliteSessionStore, ShardedSessionStore, FleetSessionIndex, SessionIdTakenError, tightenSqliteFile, } from "./sessions.js";
19
21
  export { SqliteScheduleStore, createSchedulerRuntime, isScheduleSessionId, SCHEDULE_SESSION_ID_PREFIX, } from "./schedules.js";
22
+ export { SqliteEventStore, EVENT_BLOCK_MAX_BYTES, EVENT_DEDUP_WINDOW_MS, EVENT_DELIVERY_ROW_CAP, eventCoalescingKey, eventDispatchKey, eventSessionId, renderEventBlock, } from "./events.js";
23
+ export { createEventIntake, mapGithubDelivery, parseStandardWebhookEvent, verifyGithubSignature, verifyStandardWebhookSignature, DEFAULT_EVENT_MAX_BODY_BYTES, EVENT_SOURCE_REQUESTS_PER_MINUTE, } from "./event-intake.js";
20
24
  import { createLocalCommandExecutor } from '@stratusagent/executor-local';
21
25
  import { loadPlugins, } from '@stratusagent/plugins';
22
- import { BUILTIN_EXECUTOR_NAME, BUILTIN_MEMORY_STORE_NAME, BUILTIN_PROVIDER_NAMES, createDemoTool, createFileCredentialResolver, createHomeMemoryStore, drainSharedMemory, hasBracketedLegacyStateIn, createRuntimeProvider, describeServingModel, DEFAULT_STRATUS_AGENT, isRegisteredProviderName, loadChannelTransportSecrets, registeredProviderNameOf, loadOperatorSkills, loadRosterSouls, FALLBACK_ACTIVE_METADATA_KEY, loadSoulFile, grantSoulCredential, withSoulFileLock, addNamedCredential, appendCredentialAudit, replaceNamedCredential, NamedCredentialMissingError, namedCredentialSource, NamedCredentialExistsError, CREDENTIAL_NAME_PATTERN, quoteShellArg, migrateLegacyMemory, PROVIDER_STATE_METADATA_KEYS, ConfigFileError, loadConfigFile, resolveConfigLocation, resolveConfiguredSoul, resolveRuntimeConfig, runsOnHarness, applySoulPins, assertStateCompatible, stratusHomePath, intakeStatePath, fleetDbIn, createAgentWorkspaces, createPluginStateDirectories, createHostProtectedPaths, foldedAgentId, syncDirectory, writeFileSynced, } from '@stratusagent/state';
26
+ import { BUILTIN_EXECUTOR_NAME, BUILTIN_MEMORY_STORE_NAME, BUILTIN_PROVIDER_NAMES, createDemoTool, createFileCredentialResolver, resolveDaemonCredential, createHomeMemoryStore, drainSharedMemory, hasBracketedLegacyStateIn, createRuntimeProvider, describeServingModel, DEFAULT_STRATUS_AGENT, isRegisteredProviderName, loadChannelTransportSecrets, registeredProviderNameOf, loadOperatorSkills, loadRosterSouls, FALLBACK_ACTIVE_METADATA_KEY, loadSoulFile, grantSoulCredential, withSoulFileLock, addNamedCredential, appendCredentialAudit, replaceNamedCredential, NamedCredentialMissingError, namedCredentialSource, NamedCredentialExistsError, CREDENTIAL_NAME_PATTERN, quoteShellArg, migrateLegacyMemory, PROVIDER_STATE_METADATA_KEYS, ConfigFileError, loadConfigFile, resolveConfigLocation, resolveConfiguredSoul, resolveRuntimeConfig, runsOnHarness, applySoulPins, assertStateCompatible, stratusHomePath, intakeStatePath, fleetDbIn, createAgentWorkspaces, createPluginStateDirectories, createHostProtectedPaths, foldedAgentId, syncDirectory, writeFileSynced, } from '@stratusagent/state';
23
27
  const DEFAULT_IDLE_TIMEOUT_MS = 120_000;
24
28
  /**
25
29
  * How long a parked call waits for a person. Long enough to survive a
@@ -53,6 +57,8 @@ export const STOPPING_TURN_ERROR = 'Run aborted: stratusd is stopping';
53
57
  * two windows an operator can hit agree.
54
58
  */
55
59
  export const DEFAULT_RESTART_DRAIN_TIMEOUT_MS = 30_000;
60
+ /** How long a roster reload waits on one channel's `reload()`. */
61
+ export const DEFAULT_CHANNEL_RELOAD_TIMEOUT_MS = 30_000;
56
62
  /**
57
63
  * How long a stop lets in-flight turns finish before aborting them. The
58
64
  * daemon's documented drain ("up to 30 seconds"), and shorter than the
@@ -209,6 +215,24 @@ const allAborted = (signals) => {
209
215
  check();
210
216
  return controller.signal;
211
217
  };
218
+ /**
219
+ * A value from a form, without the line breaks around it.
220
+ *
221
+ * Forms take multi-line values now, so a one-line key copied with the line
222
+ * break after it would otherwise be stored with it, and fail later inside
223
+ * whatever header or command it lands in. Only line breaks: a value's own
224
+ * spaces are its own, as on the CLI.
225
+ */
226
+ const formCredentialValue = (value) => value.replace(/^[\r\n]+|[\r\n]+$/g, '');
227
+ /**
228
+ * A PEM block (a private key, a certificate) with no line breaks left, which
229
+ * is what a one-line field makes of a pasted key file. PEM readers need the
230
+ * line after the `-----BEGIN …-----` header, so storing it would only move
231
+ * the failure to the first call that reads the key. Only a value that *is*
232
+ * one: a PEM inside JSON (a service account file) escapes its line breaks,
233
+ * so it has none of its own and is fine as sent.
234
+ */
235
+ const isFlattenedPem = (value) => /^\s*-----BEGIN [A-Z0-9 ]+-----/.test(value) && !value.includes('\n');
212
236
  /**
213
237
  * Recorded on a turn the previous process was still running when it died.
214
238
  *
@@ -393,6 +417,40 @@ export const createGateway = (options = {}) => {
393
417
  // The same database sessions live in — one file to back up — through the
394
418
  // schedule store's own connection (see its docs for why).
395
419
  const scheduleStore = new SqliteScheduleStore(fleetDbIn(stateDir), { stateHome: stateDir });
420
+ // ---- events (card 590) ---------------------------------------------------
421
+ // Opened for the first source, so a daemon that never takes events never
422
+ // grows the tables: at construction for the config's sources, or when the
423
+ // first plugin feeding a source loads (phase 2), which is at start(),
424
+ // before the scheduler's start-up pass. Its own connection to fleet.db,
425
+ // like schedules'.
426
+ const eventSources = options.events?.sources ?? {};
427
+ /** Sources plugins feed: source id to the package that declared it, filled as each loads. */
428
+ const pluginEventSources = new Map();
429
+ const eventSourceIds = () => [...Object.keys(eventSources), ...pluginEventSources.keys()];
430
+ let eventStore;
431
+ let eventIntake;
432
+ const openEvents = () => {
433
+ if (!eventStore || !eventIntake) {
434
+ eventStore = new SqliteEventStore(fleetDbIn(stateDir), { stateHome: stateDir });
435
+ eventIntake = createEventIntake({
436
+ store: eventStore,
437
+ sources: () => eventSources,
438
+ pluginSources: () => [...pluginEventSources.keys()],
439
+ // The daemon's own credential, read per delivery: a stored or
440
+ // rotated secret works on the next one, with no restart.
441
+ secret: (name) => resolveDaemonCredential(env, name),
442
+ // The triggers are schedules: `schedule.on` rows on this source.
443
+ // Late-bound, because the scheduler is built further down.
444
+ triggers: (sourceId) => scheduler.eventTriggers(sourceId),
445
+ ...(options.events?.maxBodyBytes !== undefined ? { maxBodyBytes: options.events.maxBodyBytes } : {}),
446
+ warn,
447
+ });
448
+ }
449
+ return { store: eventStore, intake: eventIntake };
450
+ };
451
+ if (Object.keys(eventSources).length > 0) {
452
+ openEvents();
453
+ }
396
454
  /**
397
455
  * The started adapter of one kind that speaks for an agent and has the
398
456
  * capability asked for. `carriesOthers` says there were capable adapters
@@ -456,12 +514,28 @@ export const createGateway = (options = {}) => {
456
514
  await outboundFor(agentId, destination);
457
515
  },
458
516
  sessionStatus: async (sessionId) => (await store.get(sessionId))?.status,
517
+ // Card 858: the previous firing, for the next one's checkpoint. The
518
+ // reply and its label, never the transcript.
519
+ previousRun: async (sessionId) => {
520
+ const session = await store.get(sessionId);
521
+ if (!session) {
522
+ return undefined;
523
+ }
524
+ const reply = latestTurnReply(session);
525
+ return {
526
+ status: session.status,
527
+ ...(session.stoppedEarly !== undefined ? { stoppedEarly: session.stoppedEarly } : {}),
528
+ ...(reply !== undefined ? { reply } : {}),
529
+ trust: sessionWriteTrust(session),
530
+ };
531
+ },
459
532
  hasAgent: (agentId) => registry.get(agentId) !== undefined,
460
533
  ...(options.schedules ? { limits: options.schedules } : {}),
461
534
  // The same stamp check every turn runs, before a slot is claimed — see
462
535
  // `SchedulerRuntimeOptions.ready`.
463
536
  ready: () => assertStateCompatible(env),
464
537
  paused: () => intakeState.paused,
538
+ ...(eventStore ? { events: { store: eventStore, sources: eventSourceIds } } : {}),
465
539
  log,
466
540
  warn,
467
541
  });
@@ -1142,6 +1216,59 @@ export const createGateway = (options = {}) => {
1142
1216
  reloads = next.catch(() => undefined);
1143
1217
  await next;
1144
1218
  };
1219
+ const channelReloadTimeoutMs = options.channelReloadTimeoutMs ?? DEFAULT_CHANNEL_RELOAD_TIMEOUT_MS;
1220
+ /**
1221
+ * After a roster reload, each started channel that can picks up the
1222
+ * agents it carries now (a new agent's stored app, one whose soul or
1223
+ * secrets are gone). One at a time, in start order, so the log reads in
1224
+ * the order an operator configured them. A channel that fails is
1225
+ * logged and the rest still reload: one bad app must not hold back the
1226
+ * others, the rule `startChannels` follows. A channel still reloading
1227
+ * after the bound is left to finish on its own, and what it ends with is
1228
+ * logged then, so a hung Slack call cannot hold every later reload.
1229
+ */
1230
+ const reloadChannels = async () => {
1231
+ const outcomes = [];
1232
+ for (const adapter of [...startedChannels]) {
1233
+ if (stopping || channelsClosed) {
1234
+ return outcomes;
1235
+ }
1236
+ if (typeof adapter.reload !== 'function') {
1237
+ continue;
1238
+ }
1239
+ const reload = adapter.reload.bind(adapter);
1240
+ // Settles with the failure, never rejects: one that comes after the
1241
+ // bound has nobody awaiting it, and would take the daemon down.
1242
+ const settled = (async () => reload())().then(() => undefined, (error) => (error instanceof Error ? error.message : String(error)));
1243
+ let timer;
1244
+ const bound = new Promise((resolve) => {
1245
+ timer = setTimeout(() => resolve('timeout'), channelReloadTimeoutMs);
1246
+ timer.unref?.();
1247
+ });
1248
+ const outcome = await Promise.race([settled.then((failure) => ({ failure })), bound]);
1249
+ clearTimeout(timer);
1250
+ if (outcome === 'timeout') {
1251
+ warn(`channel ${adapter.name} is still reloading after ${channelReloadTimeoutMs} ms; the roster reload goes on without it`);
1252
+ void settled.then((failure) => {
1253
+ if (failure !== undefined) {
1254
+ warn(`channel ${adapter.name} could not reload: ${failure}`);
1255
+ }
1256
+ else {
1257
+ log(`channel ${adapter.name} finished reloading`);
1258
+ }
1259
+ });
1260
+ outcomes.push({ name: adapter.name, outcome: 'still-reloading' });
1261
+ continue;
1262
+ }
1263
+ if (outcome.failure !== undefined) {
1264
+ warn(`channel ${adapter.name} could not reload: ${outcome.failure}`);
1265
+ outcomes.push({ name: adapter.name, outcome: 'failed', reason: outcome.failure });
1266
+ continue;
1267
+ }
1268
+ outcomes.push({ name: adapter.name, outcome: 'reloaded' });
1269
+ }
1270
+ return outcomes;
1271
+ };
1145
1272
  /**
1146
1273
  * A session pins its agent id, never a snapshot of the agent: the soul
1147
1274
  * file is re-read on every dispatch, so an edited persona (or tool
@@ -1294,6 +1421,128 @@ export const createGateway = (options = {}) => {
1294
1421
  }
1295
1422
  return adapter.createConversation({ agentId, ...request });
1296
1423
  }));
1424
+ // Joining and inviting follow the same rule: the asking agent's own app
1425
+ // does it, so membership stays per app and its token stays in the adapter.
1426
+ tools.register(createChannelJoinTool(async ({ agentId, channel, conversation }) => {
1427
+ const { adapter, carriesOthers } = channelCarrying(channel, agentId, (candidate) => candidate.joinConversation !== undefined);
1428
+ if (!adapter?.joinConversation) {
1429
+ throw new Error(carriesOthers
1430
+ ? `No running '${channel}' channel carries agent ${agentId}, so it has no app to join a channel with.`
1431
+ : `No running channel can join '${channel}' conversations.`);
1432
+ }
1433
+ return adapter.joinConversation({ agentId, conversation });
1434
+ }));
1435
+ tools.register(createChannelInviteTool(async ({ agentId, channel, ...request }) => {
1436
+ const { adapter, carriesOthers } = channelCarrying(channel, agentId, (candidate) => candidate.inviteToConversation !== undefined);
1437
+ if (!adapter?.inviteToConversation) {
1438
+ throw new Error(carriesOthers
1439
+ ? `No running '${channel}' channel carries agent ${agentId}, so it has no app to invite anyone with.`
1440
+ : `No running channel can invite people to '${channel}' conversations.`);
1441
+ }
1442
+ return adapter.inviteToConversation({ agentId, ...request });
1443
+ }));
1444
+ /**
1445
+ * Why a `channel.archive` call would archive the conversation its work was
1446
+ * asked from, or can't tell, as a sentence; undefined when it may go on.
1447
+ * That conversation is never archived: the answer to it would have
1448
+ * nowhere to go, and the agent can't unarchive.
1449
+ *
1450
+ * An ordinary session is its own origin. A sub-agent's or a delegated
1451
+ * run's is the root turn that started the chain, read off the id the
1452
+ * host minted for it (each child id is its parent's plus a marker, so the
1453
+ * root is the text before the first one), never off metadata a tool in
1454
+ * the slice could rewrite. The id is the one the session had at its first
1455
+ * tool call, for the same reason. The origin's conversation is asked of
1456
+ * the adapter of this kind that carries the origin's agent. When it can't
1457
+ * be told (the root is gone; no adapter, no `sessionConversation`, a
1458
+ * lookup that fails, or no answer for a session whose id is this
1459
+ * channel's), the call is refused: an archive the agent can't undo
1460
+ * doesn't run on a guess. A session that isn't this channel's at all (an
1461
+ * API call, a schedule, a turn on another transport) has no conversation
1462
+ * here to protect.
1463
+ */
1464
+ const archiveRefusal = async (kind, conversation, session, archiving) => {
1465
+ const unknown = (why) => `${kind} ${conversation} was not archived: ${why}, so Stratus can't tell which conversation this work was asked from, `
1466
+ + 'and that one is never archived. Ask for the archive from a conversation.';
1467
+ const id = hostIdOf(session);
1468
+ const marks = [SUB_AGENT_SESSION_ID_MARKER, DELEGATED_SESSION_ID_MARKER]
1469
+ .map((marker) => id.indexOf(marker))
1470
+ .filter((at) => at >= 0);
1471
+ let origin = { agentId: session.agent.id, sessionId: id };
1472
+ let adapter = archiving;
1473
+ if (marks.length > 0) {
1474
+ const rootId = id.slice(0, Math.min(...marks));
1475
+ let root;
1476
+ try {
1477
+ root = rootId ? await store.get(rootId) : undefined;
1478
+ }
1479
+ catch (error) {
1480
+ warn(`${id}: could not read the root session ${rootId} to check a channel.archive (${error instanceof Error ? error.message : String(error)})`);
1481
+ root = undefined;
1482
+ }
1483
+ if (!root) {
1484
+ return unknown('this run was started by another session that no longer exists');
1485
+ }
1486
+ origin = { agentId: root.agent.id, sessionId: root.id };
1487
+ adapter = channelCarrying(kind, origin.agentId, () => true).adapter;
1488
+ }
1489
+ // Channel session ids lead with their kind (`channelSessionKey`).
1490
+ const onThisChannel = origin.sessionId.startsWith(`${kind}:`);
1491
+ if (!adapter) {
1492
+ return onThisChannel ? unknown(`no running '${kind}' channel carries the agent this work was asked of`) : undefined;
1493
+ }
1494
+ if (!adapter.sessionConversation) {
1495
+ return unknown(`the running '${kind}' channel can't say which conversation a session is in`);
1496
+ }
1497
+ let origins;
1498
+ try {
1499
+ origins = adapter.sessionConversation(origin.agentId, origin.sessionId);
1500
+ }
1501
+ catch (error) {
1502
+ warn(`${id}: could not tell which conversation ${origin.sessionId} is in to check a channel.archive (${error instanceof Error ? error.message : String(error)})`);
1503
+ return unknown(`the running '${kind}' channel couldn't say which conversation this work was asked from`);
1504
+ }
1505
+ if (origins === undefined) {
1506
+ return onThisChannel ? unknown(`the running '${kind}' channel can't say which of its conversations this work was asked from`) : undefined;
1507
+ }
1508
+ return origins === conversation
1509
+ ? `${kind} ${conversation} is the conversation this work was asked from, so it was not archived: `
1510
+ + 'the reply would have nowhere to go. Ask for the archive from another conversation.'
1511
+ : undefined;
1512
+ };
1513
+ const archivingAdapter = (kind, agentId) => channelCarrying(kind, agentId, (candidate) => candidate.archiveConversation !== undefined);
1514
+ const noArchivingAdapter = (kind, agentId, carriesOthers) => carriesOthers
1515
+ ? `No running '${kind}' channel carries agent ${agentId}, so it has no app to archive a channel with.`
1516
+ : `No running channel can archive '${kind}' conversations.`;
1517
+ tools.register(createChannelArchiveTool(async ({ agentId, channel, conversation }, session) => {
1518
+ const { adapter, carriesOthers } = archivingAdapter(channel, agentId);
1519
+ if (!adapter?.archiveConversation) {
1520
+ throw new Error(noArchivingAdapter(channel, agentId, carriesOthers));
1521
+ }
1522
+ const refusal = await archiveRefusal(channel, conversation, session, adapter);
1523
+ if (refusal !== undefined) {
1524
+ throw new Error(refusal);
1525
+ }
1526
+ return adapter.archiveConversation({ agentId, conversation });
1527
+ }, {
1528
+ // The same check before anyone is asked: an approval, Always allow
1529
+ // above all, for an archive that is bound to be refused is a question
1530
+ // with no useful answer. Held as an approval wait, like message.read's,
1531
+ // since it runs before any tool event and may read the store.
1532
+ refusalFor: async ({ agentId, channel, conversation }, session) => {
1533
+ const { adapter, carriesOthers } = archivingAdapter(channel, agentId);
1534
+ if (!adapter?.archiveConversation) {
1535
+ return noArchivingAdapter(channel, agentId, carriesOthers);
1536
+ }
1537
+ const release = holdApprovalWait(session.id);
1538
+ try {
1539
+ return await archiveRefusal(channel, conversation, session, adapter);
1540
+ }
1541
+ finally {
1542
+ release();
1543
+ }
1544
+ },
1545
+ }));
1297
1546
  tools.register(createMessageReadTool(async ({ agentId, source, ...window }) => {
1298
1547
  // The same carrying rule as outbound: a read goes through the app of
1299
1548
  // the agent asking, never whichever adapter of that kind is running,
@@ -1367,11 +1616,26 @@ export const createGateway = (options = {}) => {
1367
1616
  throw new Error(`${JSON.stringify(request.name)} is not a credential name. Use letters, digits, dots, dashes, or underscores, `
1368
1617
  + 'starting with a letter, the way the tool that needs it spells it: search.apiKey, github.token.');
1369
1618
  }
1619
+ // The stricter rule, for a name the model picked: checked before anyone
1620
+ // is asked, so an approver never sees a form whose binding the store
1621
+ // would refuse, and so a variable that changes what a judged command
1622
+ // does (NODE_OPTIONS) is never offered at all.
1623
+ if (request.env !== undefined) {
1624
+ const problem = credentialVariableProblem(request.env, { requested: true });
1625
+ if (problem !== undefined) {
1626
+ throw new Error(`${problem} Nothing is pending.`);
1627
+ }
1628
+ }
1370
1629
  // A key already stored is not something to ask a person for again: the
1371
1630
  // form only adds, so it could only fail. Say what is actually missing.
1372
1631
  const source = await namedCredentialSource(env, agentId, request.name);
1373
1632
  const stored = source !== undefined;
1374
1633
  const granted = session.agent.credentials?.includes(request.name) === true;
1634
+ // Binding a key the agent already holds asks for no value, and every
1635
+ // form today has a value field, so phase 1 leaves it to the machine.
1636
+ const bindOnMachine = request.env !== undefined
1637
+ ? ` To have your shell read it as ${request.env}, ask your operator to run \`stratus credential env ${request.name} ${request.env} --agent ${quoteShellArg(agentId)}\` on the machine.`
1638
+ : '';
1375
1639
  if (request.replace === true) {
1376
1640
  // Checked here, before anyone is asked, so an approver is never shown
1377
1641
  // a form whose answer could only be refused.
@@ -1394,13 +1658,18 @@ export const createGateway = (options = {}) => {
1394
1658
  }
1395
1659
  }
1396
1660
  else if (stored && granted) {
1397
- throw new Error(`You already hold ${request.name}; the tools that need it use it for you. To rotate it for a new value, ask again with replace: true.`);
1661
+ throw new Error(`You already hold ${request.name}; the tools that need it use it for you. To rotate it for a new value, ask again with replace: true.${bindOnMachine}`
1662
+ + (request.env !== undefined ? ' Nothing is pending.' : ''));
1398
1663
  }
1399
1664
  else if (stored) {
1400
1665
  throw new Error(`${request.name} is already ${source === 'environment' ? "supplied by the daemon's environment" : 'stored'} but not granted to you. `
1401
- + 'Ask your operator to add it to the credentials list in your soul; a form would only refuse to store it again.');
1666
+ + `Ask your operator to add it to the credentials list in your soul; a form would only refuse to store it again.${bindOnMachine}`);
1402
1667
  }
1403
- const onMachine = `\`stratus credential set ${request.name}${request.scope === 'agent' ? ` --agent ${quoteShellArg(agentId)}` : ''}\``;
1668
+ const onMachine = request.env === undefined
1669
+ ? `\`stratus credential set ${request.name}${request.scope === 'agent' ? ` --agent ${quoteShellArg(agentId)}` : ''}\``
1670
+ : request.scope === 'agent'
1671
+ ? `\`stratus credential set ${request.name} --agent ${quoteShellArg(agentId)} --env ${request.env}\``
1672
+ : `\`stratus credential set ${request.name}\`, then \`stratus credential env ${request.name} ${request.env} --agent ${quoteShellArg(agentId)}\``;
1404
1673
  const record = {
1405
1674
  agentId,
1406
1675
  sessionId: session.id,
@@ -1408,6 +1677,7 @@ export const createGateway = (options = {}) => {
1408
1677
  scope: request.scope,
1409
1678
  soulPath,
1410
1679
  ...(request.replace === true ? { replace: true } : {}),
1680
+ ...(request.env !== undefined ? { env: request.env } : {}),
1411
1681
  };
1412
1682
  const announce = async (requestId, via) => {
1413
1683
  await bus.emit({
@@ -1420,6 +1690,7 @@ export const createGateway = (options = {}) => {
1420
1690
  ...(request.reason !== undefined ? { reason: request.reason } : {}),
1421
1691
  via,
1422
1692
  ...(request.replace === true ? { replace: true } : {}),
1693
+ ...(request.env !== undefined ? { env: request.env } : {}),
1423
1694
  ...(session.metadata ? { metadata: session.metadata } : {}),
1424
1695
  });
1425
1696
  };
@@ -1440,11 +1711,14 @@ export const createGateway = (options = {}) => {
1440
1711
  const kind = session.metadata?.channel;
1441
1712
  const { adapter } = typeof kind === 'string'
1442
1713
  ? channelCarrying(kind, agentId, (candidate) => candidate.requestCredential !== undefined
1443
- && (request.replace !== true || candidate.credentialReplaceForms === true))
1714
+ && (request.replace !== true || candidate.credentialReplaceForms === true)
1715
+ // A form that can't say the key will be shell-readable must not
1716
+ // be the approval for it: a link says it instead.
1717
+ && (request.env === undefined || candidate.credentialEnvForms === true))
1444
1718
  : { adapter: undefined };
1445
1719
  if (!adapter?.requestCredential || !session.metadata) {
1446
1720
  formUnavailable = typeof kind === 'string'
1447
- ? `This conversation's channel (${kind}) cannot show your operator a ${request.replace === true ? 'replacement' : 'credential'} form here.`
1721
+ ? `This conversation's channel (${kind}) cannot show your operator a ${request.replace === true ? 'replacement' : 'credential'} form here${request.env !== undefined ? ' that says which variable your shell will read it from' : ''}.`
1448
1722
  : 'This conversation is not in a channel that can show your operator a form.';
1449
1723
  }
1450
1724
  else {
@@ -1463,6 +1737,7 @@ export const createGateway = (options = {}) => {
1463
1737
  scope: request.scope,
1464
1738
  ...(request.reason !== undefined ? { reason: request.reason } : {}),
1465
1739
  ...(request.replace === true ? { replace: true } : {}),
1740
+ ...(request.env !== undefined ? { env: request.env } : {}),
1466
1741
  metadata: session.metadata,
1467
1742
  });
1468
1743
  await announce(requestId, 'form');
@@ -1504,6 +1779,7 @@ export const createGateway = (options = {}) => {
1504
1779
  name: request.name,
1505
1780
  scope: request.scope,
1506
1781
  ...(request.reason !== undefined ? { reason: request.reason } : {}),
1782
+ ...(request.env !== undefined ? { env: request.env } : {}),
1507
1783
  });
1508
1784
  }
1509
1785
  catch (error) {
@@ -1558,6 +1834,7 @@ export const createGateway = (options = {}) => {
1558
1834
  ...(actor !== undefined ? { actor } : {}),
1559
1835
  via: request.via ?? 'form',
1560
1836
  requestedBy: request.agentId,
1837
+ ...(request.env !== undefined ? { env: request.env, bindingFor: request.agentId } : {}),
1561
1838
  });
1562
1839
  }
1563
1840
  catch (error) {
@@ -1600,7 +1877,24 @@ export const createGateway = (options = {}) => {
1600
1877
  message: `${request.soulPath} now belongs to ${owner}, not ${request.agentId}, so nothing was stored. Ask ${request.agentId} to request it again.`,
1601
1878
  };
1602
1879
  }
1603
- const store = request.scope === 'agent' ? { agentId: request.agentId } : {};
1880
+ const value = formCredentialValue(input.value);
1881
+ if (isFlattenedPem(value)) {
1882
+ // Refused, not retired: the same form can still take the key whole.
1883
+ const command = request.scope === 'agent'
1884
+ ? `stratus credential set ${request.name} --agent ${quoteShellArg(request.agentId)} < key.pem`
1885
+ : `stratus credential set ${request.name} < key.pem`;
1886
+ return {
1887
+ ok: false,
1888
+ message: 'That looks like a PEM key whose line breaks were lost, so nothing was stored: whatever reads it would fail. '
1889
+ + `Paste it again from the original file with its line breaks, or store it on the machine: ${command}.`,
1890
+ };
1891
+ }
1892
+ const store = {
1893
+ ...(request.scope === 'agent' ? { agentId: request.agentId } : {}),
1894
+ // The binding the form promised, written with the value, always for
1895
+ // the agent that asked: consent is per agent even for a shared key.
1896
+ ...(request.env !== undefined ? { bind: { agentId: request.agentId, variable: request.env } } : {}),
1897
+ };
1604
1898
  if (request.replace === true) {
1605
1899
  // Re-read under the soul lock, so a grant removed while the form
1606
1900
  // waited (a shared key most of all) is never rotated on its behalf.
@@ -1611,7 +1905,7 @@ export const createGateway = (options = {}) => {
1611
1905
  if (soul.agent.id !== request.agentId || soul.agent.credentials?.includes(request.name) !== true) {
1612
1906
  return false;
1613
1907
  }
1614
- await replaceNamedCredential(env, { name: request.name, value: input.value, ...store });
1908
+ await replaceNamedCredential(env, { name: request.name, value, ...store });
1615
1909
  return true;
1616
1910
  });
1617
1911
  }
@@ -1641,6 +1935,7 @@ export const createGateway = (options = {}) => {
1641
1935
  name: request.name,
1642
1936
  scope: request.scope,
1643
1937
  replace: true,
1938
+ ...(request.env !== undefined ? { env: request.env } : {}),
1644
1939
  ...(input.actor !== undefined ? { actor: input.actor } : {}),
1645
1940
  });
1646
1941
  // The soul already lists it: a replace is only asked for a key held.
@@ -1649,7 +1944,7 @@ export const createGateway = (options = {}) => {
1649
1944
  try {
1650
1945
  await addNamedCredential(env, {
1651
1946
  name: request.name,
1652
- value: input.value,
1947
+ value,
1653
1948
  ...store,
1654
1949
  });
1655
1950
  }
@@ -1699,6 +1994,7 @@ export const createGateway = (options = {}) => {
1699
1994
  requestId: input.requestId,
1700
1995
  name: request.name,
1701
1996
  scope: request.scope,
1997
+ ...(request.env !== undefined ? { env: request.env } : {}),
1702
1998
  ...(input.actor !== undefined ? { actor: input.actor } : {}),
1703
1999
  ...(grantError !== undefined ? { grantError } : {}),
1704
2000
  });
@@ -2092,7 +2388,7 @@ export const createGateway = (options = {}) => {
2092
2388
  const idle = new AbortController();
2093
2389
  trackTurn(sessionId, idle);
2094
2390
  if (abortingWith !== undefined) {
2095
- idle.abort(new RunAbortedError(abortingWith));
2391
+ idle.abort(shutdownAbort());
2096
2392
  }
2097
2393
  try {
2098
2394
  return await run(idle.signal);
@@ -2104,7 +2400,7 @@ export const createGateway = (options = {}) => {
2104
2400
  const controller = new AbortController();
2105
2401
  trackTurn(sessionId, controller);
2106
2402
  if (abortingWith !== undefined) {
2107
- controller.abort(new RunAbortedError(abortingWith));
2403
+ controller.abort(shutdownAbort());
2108
2404
  }
2109
2405
  // The external reason travels: a caller that aborted with its own
2110
2406
  // `RunAbortedError` gets it recorded on the session, like the watchdog's.
@@ -2348,6 +2644,22 @@ export const createGateway = (options = {}) => {
2348
2644
  const refusal = () => restartStatus
2349
2645
  ? new GatewayStoppingError('stratusd is restarting and will accept new work once it is back up.', true)
2350
2646
  : new GatewayStoppingError('The gateway is stopping and no longer accepts new work.', false);
2647
+ /**
2648
+ * What the caller of a keyed turn the shutdown cut short hears: the same
2649
+ * refusal, since its message is no more answered than one refused at the
2650
+ * door (a channel keeps it for the next start, as it keeps those), and
2651
+ * worded for a turn that ran and will be continued.
2652
+ */
2653
+ const leftForNext = () => restartStatus
2654
+ ? new GatewayStoppingError('stratusd is restarting; this turn was cut short and continues once it is back up.', true)
2655
+ : new GatewayStoppingError('stratusd is stopping; this turn was cut short and continues when it is started again.', false);
2656
+ /**
2657
+ * The abort a stop's or restart's window ends a turn with. Resumable: a
2658
+ * keyed turn is left for the next start to continue once, as a crash
2659
+ * leaves it, rather than failed (`RunAbortedError.resumable`); every
2660
+ * other turn is failed with the reason, as before.
2661
+ */
2662
+ const shutdownAbort = () => new RunAbortedError(abortingWith, { resumable: true });
2351
2663
  /**
2352
2664
  * The turn id running on each session, for callers that named one.
2353
2665
  *
@@ -2478,6 +2790,27 @@ export const createGateway = (options = {}) => {
2478
2790
  ? runsOnHarness(fallback)
2479
2791
  : runsOnHarness(config) || (fallback !== undefined && runsOnHarness(fallback));
2480
2792
  };
2793
+ /**
2794
+ * A keyed turn the shutdown cut short and the runner left for the next
2795
+ * start (`leftForNextStart`): its caller is told so, the way a message
2796
+ * refused at the door is, and keeps it for the next daemon, which
2797
+ * continues the turn. Read back on the chain, so it is this dispatch's
2798
+ * own message the session is still running on.
2799
+ */
2800
+ const settleLeftForNextStart = async (input, turn) => {
2801
+ try {
2802
+ return await turn;
2803
+ }
2804
+ catch (error) {
2805
+ if (input.idempotencyKey !== undefined && error instanceof RunAbortedError && error.resumable) {
2806
+ const left = await store.get(input.sessionId).catch(() => undefined);
2807
+ if (left !== undefined && workItemState(left, input.idempotencyKey) === 'unfinished' && leftForNextStart(left, error)) {
2808
+ throw leftForNext();
2809
+ }
2810
+ }
2811
+ throw error;
2812
+ }
2813
+ };
2481
2814
  /**
2482
2815
  * `subAgent` is the narrowed definition `subAgents.run` built: the turn
2483
2816
  * runs as that, on the parent's resolved provider, with no memory and no
@@ -2546,7 +2879,7 @@ export const createGateway = (options = {}) => {
2546
2879
  // has switched for good, the primary is never called again, and only
2547
2880
  // the fallback counts.
2548
2881
  const hostedLoop = input.idempotencyKey !== undefined && reachesHarness(config, switchedToFallback);
2549
- return withWatchdog(input.sessionId, input.signal, effectiveStreams, fallbackStreams, async (signal) => {
2882
+ return settleLeftForNextStart(input, withWatchdog(input.sessionId, input.signal, effectiveStreams, fallbackStreams, async (signal) => {
2550
2883
  // The preflight above (agent refresh, config resolution, session
2551
2884
  // load) awaited filesystem work; the entry check has long passed.
2552
2885
  // Recheck before the runner touches durable state, or a dispatch
@@ -2625,7 +2958,7 @@ export const createGateway = (options = {}) => {
2625
2958
  runtime: runtimeContextFor(source, config, switchedToFallback, input.metadata, subAgent !== undefined),
2626
2959
  signal,
2627
2960
  });
2628
- });
2961
+ }));
2629
2962
  };
2630
2963
  /**
2631
2964
  * Finishes the turns that were parked on a human when this daemon last
@@ -2792,7 +3125,7 @@ export const createGateway = (options = {}) => {
2792
3125
  if (record) {
2793
3126
  trackTurn(sessionId, controller);
2794
3127
  if (abortingWith !== undefined) {
2795
- controller.abort(new RunAbortedError(abortingWith));
3128
+ controller.abort(shutdownAbort());
2796
3129
  }
2797
3130
  }
2798
3131
  try {
@@ -3156,7 +3489,21 @@ export const createGateway = (options = {}) => {
3156
3489
  if (stopping) {
3157
3490
  throw refusal();
3158
3491
  }
3159
- return onSessionChain(input.sessionId, () => dispatchInternal(input));
3492
+ return onSessionChain(input.sessionId, async () => {
3493
+ // A keyed firing (an event trigger's batch) that the session already
3494
+ // holds is that turn, not a new one: a daemon that died after the
3495
+ // turn and before the firing was recorded re-sends it at the next
3496
+ // start, and must answer with the finished turn (or settle the
3497
+ // unfinished one, as the start-up sweep would) instead of running the
3498
+ // event again. The same check the public door makes, on the chain.
3499
+ if (input.idempotencyKey !== undefined) {
3500
+ const repeated = await settleRepeat(input.sessionId, input.idempotencyKey, input.agentId, input.turnId, input.onRepeat);
3501
+ if (repeated !== undefined) {
3502
+ return repeated;
3503
+ }
3504
+ }
3505
+ return dispatchInternal(input);
3506
+ });
3160
3507
  };
3161
3508
  const dispatch = async (input) => {
3162
3509
  if (stopping) {
@@ -3642,6 +3989,41 @@ export const createGateway = (options = {}) => {
3642
3989
  onRead: (method) => log(`fleet read: ${method} by plugin ${packageName}`),
3643
3990
  }),
3644
3991
  providerCallGuards,
3992
+ eventSources: {
3993
+ reserved: Object.keys(eventSources),
3994
+ register: (packageName, sourceIds) => {
3995
+ if (!eventStore) {
3996
+ // The first source on a daemon whose config has none: the
3997
+ // scheduler was built without events, so it takes them now,
3998
+ // before its start-up pass reads what is waiting. First, so a
3999
+ // store that won't open refuses the plugin with no source left
4000
+ // behind.
4001
+ scheduler.attachEvents({ store: openEvents().store, sources: eventSourceIds });
4002
+ }
4003
+ for (const sourceId of sourceIds) {
4004
+ pluginEventSources.set(sourceId, packageName);
4005
+ }
4006
+ },
4007
+ ingest: async (packageName, sourceId, event) => {
4008
+ if (pluginEventSources.get(sourceId) !== packageName) {
4009
+ throw new Error(`Plugin ${packageName} does not feed event source "${sourceId}" on this daemon.`);
4010
+ }
4011
+ // The store closes last in a stop. Until then an event is kept
4012
+ // and goes out from the next start; after, refused, so a poller
4013
+ // keeps its cursor where it was.
4014
+ if (storesClosed) {
4015
+ throw new Error('stratusd has stopped, so this event was not stored. Feed it again once the daemon is back.');
4016
+ }
4017
+ const answer = openEvents().intake.ingest(sourceId, event);
4018
+ // Sent now rather than at the next tick; the scheduler decides
4019
+ // hold or fire, and does nothing while it is stopped or not yet
4020
+ // started.
4021
+ if (answer.status === 'matched') {
4022
+ scheduler.kickEvents();
4023
+ }
4024
+ return answer;
4025
+ },
4026
+ },
3645
4027
  });
3646
4028
  loadedPlugins = result.loaded;
3647
4029
  pluginFailures = result.failures;
@@ -3654,6 +4036,7 @@ export const createGateway = (options = {}) => {
3654
4036
  ...plugin.contributions.memory.map((name) => `memory store ${name}`),
3655
4037
  ...plugin.contributions.executors.map((name) => `executor ${name}`),
3656
4038
  ...(plugin.providerCallGuards > 0 ? ['guards model calls'] : []),
4039
+ ...plugin.eventSources.map((sourceId) => `event source ${sourceId}`),
3657
4040
  ];
3658
4041
  log(`plugin ${plugin.package} loaded — ${contributed.join(', ') || 'no tools'}`);
3659
4042
  }
@@ -3948,6 +4331,7 @@ export const createGateway = (options = {}) => {
3948
4331
  }
3949
4332
  storesClosed = true;
3950
4333
  scheduleStore.close();
4334
+ eventStore?.close();
3951
4335
  store.close();
3952
4336
  };
3953
4337
  /**
@@ -4085,12 +4469,15 @@ export const createGateway = (options = {}) => {
4085
4469
  // The watchdog's own mechanism: the runner fails each session with
4086
4470
  // the reason on the signal, saves it, and emits session.failed —
4087
4471
  // so the next daemon finds a finished turn, not an abandoned one.
4472
+ // Except a keyed turn, which the resumable abort leaves running for
4473
+ // the next daemon to continue once, as a crash would have left it
4474
+ // (`shutdownAbort`); its caller is refused (`leftForNext`).
4088
4475
  // Before the loop, so a turn queued behind one aborted here, which
4089
4476
  // starts as soon as that one settles, is aborted the same way.
4090
4477
  abortingWith = kind === 'stop' ? STOPPING_TURN_ERROR : RESTARTING_TURN_ERROR;
4091
4478
  for (const running of turnControllers.values()) {
4092
4479
  for (const controller of running) {
4093
- controller.abort(new RunAbortedError(abortingWith));
4480
+ controller.abort(shutdownAbort());
4094
4481
  }
4095
4482
  }
4096
4483
  drained = await settlesWithin(drain, remaining());
@@ -4332,12 +4719,23 @@ export const createGateway = (options = {}) => {
4332
4719
  return registry.list();
4333
4720
  },
4334
4721
  async reloadRoster() {
4335
- // Under the soul lock, so a repoint cannot land between a credential
4336
- // grant's check of the served path and its write (`grantSoulCredential`).
4337
- await reloadsInOrder(() => withSoulFileLock(loadRoster));
4338
- const roster = registry.list();
4339
- log(`roster reloaded — ${roster.length} agent(s): ${roster.map((agent) => agent.name).join(', ')}`);
4340
- return roster;
4722
+ return (await gateway.reloadRosterAndChannels()).agents;
4723
+ },
4724
+ async reloadRosterAndChannels() {
4725
+ let roster = [];
4726
+ let channels = [];
4727
+ await reloadsInOrder(async () => {
4728
+ // Under the soul lock, so a repoint cannot land between a credential
4729
+ // grant's check of the served path and its write (`grantSoulCredential`).
4730
+ await withSoulFileLock(loadRoster);
4731
+ roster = registry.list();
4732
+ log(`roster reloaded — ${roster.length} agent(s): ${roster.map((agent) => agent.name).join(', ')}`);
4733
+ // Outside the soul lock, which guards files and must not wait on a
4734
+ // network; inside the reload chain, so two reloads never interleave
4735
+ // their channel work (the bound keeps one from holding it).
4736
+ channels = await reloadChannels();
4737
+ });
4738
+ return { agents: roster, channels };
4341
4739
  },
4342
4740
  async reloadSkills() {
4343
4741
  try {
@@ -4425,6 +4823,35 @@ export const createGateway = (options = {}) => {
4425
4823
  cancelSchedule(scheduleId) {
4426
4824
  return scheduler.cancel(scheduleId);
4427
4825
  },
4826
+ async receiveEvent(request) {
4827
+ if (!eventIntake) {
4828
+ return { status: 404, body: { error: 'unknown_source', message: 'No event source is configured at this path.' } };
4829
+ }
4830
+ const answer = await eventIntake.receive(request);
4831
+ // The delivery and its firings are committed; send them now rather
4832
+ // than at the next tick. The scheduler decides hold or fire, and does
4833
+ // nothing while it is stopped or not yet started.
4834
+ if (answer.body.status === 'matched') {
4835
+ scheduler.kickEvents();
4836
+ }
4837
+ return answer;
4838
+ },
4839
+ eventSummary() {
4840
+ const since = new Date(Date.now() - 86_400_000).toISOString();
4841
+ if (!eventStore || !eventIntake) {
4842
+ return { since, sources: {}, unknownSource: 0 };
4843
+ }
4844
+ return summarizeEvents({
4845
+ sourceIds: eventSourceIds(),
4846
+ plugins: Object.fromEntries(pluginEventSources),
4847
+ since,
4848
+ activity: eventStore.activity(since),
4849
+ counters: eventIntake.counters(),
4850
+ });
4851
+ },
4852
+ eventDeliveries(options = {}) {
4853
+ return eventStore?.deliveries(options) ?? [];
4854
+ },
4428
4855
  tools() {
4429
4856
  // From the registry, not from the load records: the risk reported is
4430
4857
  // the one a call will actually be judged at, floor applied, and a