@stratusagent/gateway 0.14.0 → 0.14.2

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,8 +9,8 @@ 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, 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, 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, credentialVariableProblem, } from '@stratusagent/agents';
12
+ import { filePathsOf, isUnaddressedTurn, carriesKey, leastTrusted, senderTrustOf, SENDER_TRUST_METADATA_KEY, workItemState, turnMessageOf, turnKeyOf, 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, CONTEXT_SUMMARY_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
15
  import { createEventIntake, summarizeEvents, } from "./event-intake.js";
16
16
  import { SqliteEventStore } from "./events.js";
@@ -57,6 +57,8 @@ export const STOPPING_TURN_ERROR = 'Run aborted: stratusd is stopping';
57
57
  * two windows an operator can hit agree.
58
58
  */
59
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;
60
62
  /**
61
63
  * How long a stop lets in-flight turns finish before aborting them. The
62
64
  * daemon's documented drain ("up to 30 seconds"), and shorter than the
@@ -200,10 +202,19 @@ const MERGED_SEPARATOR = '\n\n';
200
202
  */
201
203
  const fitsReplayBudget = (images) => images.length === 0 || imagesWithinReplayBudget([{ id: '', createdAt: '', role: 'user', content: '', images: [...images] }]).size === images.length;
202
204
  /** A signal that aborts once every one of `signals` has. */
203
- const allAborted = (signals) => {
205
+ const allAborted = (signals) => joinableSignal(signals).signal;
206
+ /**
207
+ * `allAborted` for callers that keep joining: a turn that takes in messages
208
+ * at its tool steps (card 948) is cancelled only once every caller in it
209
+ * is, the ones who joined late included. A caller joining with no signal
210
+ * can never cancel, so from then on neither can the turn.
211
+ */
212
+ const joinableSignal = (initial) => {
204
213
  const controller = new AbortController();
214
+ const signals = [...initial];
215
+ let uncancellable = false;
205
216
  const check = () => {
206
- if (!controller.signal.aborted && signals.every((signal) => signal.aborted)) {
217
+ if (!uncancellable && !controller.signal.aborted && signals.every((signal) => signal.aborted)) {
207
218
  controller.abort(signals[signals.length - 1]?.reason);
208
219
  }
209
220
  };
@@ -211,8 +222,36 @@ const allAborted = (signals) => {
211
222
  signal.addEventListener('abort', check, { once: true });
212
223
  }
213
224
  check();
214
- return controller.signal;
225
+ return {
226
+ signal: controller.signal,
227
+ join: (signal) => {
228
+ if (signal === undefined) {
229
+ uncancellable = true;
230
+ return;
231
+ }
232
+ signals.push(signal);
233
+ signal.addEventListener('abort', check, { once: true });
234
+ },
235
+ };
215
236
  };
237
+ /**
238
+ * A value from a form, without the line breaks around it.
239
+ *
240
+ * Forms take multi-line values now, so a one-line key copied with the line
241
+ * break after it would otherwise be stored with it, and fail later inside
242
+ * whatever header or command it lands in. Only line breaks: a value's own
243
+ * spaces are its own, as on the CLI.
244
+ */
245
+ const formCredentialValue = (value) => value.replace(/^[\r\n]+|[\r\n]+$/g, '');
246
+ /**
247
+ * A PEM block (a private key, a certificate) with no line breaks left, which
248
+ * is what a one-line field makes of a pasted key file. PEM readers need the
249
+ * line after the `-----BEGIN …-----` header, so storing it would only move
250
+ * the failure to the first call that reads the key. Only a value that *is*
251
+ * one: a PEM inside JSON (a service account file) escapes its line breaks,
252
+ * so it has none of its own and is fine as sent.
253
+ */
254
+ const isFlattenedPem = (value) => /^\s*-----BEGIN [A-Z0-9 ]+-----/.test(value) && !value.includes('\n');
216
255
  /**
217
256
  * Recorded on a turn the previous process was still running when it died.
218
257
  *
@@ -335,6 +374,10 @@ export const RESERVED_SESSION_METADATA_KEYS = [
335
374
  // no messages and, once it grows past that index, silently stops sending
336
375
  // its own earliest turns, with no overflow and no trim event to say so.
337
376
  CONTEXT_FLOOR_METADATA_KEY,
377
+ // The summary that stands in below that floor, for both of the same
378
+ // reasons, and one more: the model reads it as something it wrote itself,
379
+ // so a caller who could seed it could put words in the agent's mouth.
380
+ CONTEXT_SUMMARY_METADATA_KEY,
338
381
  SUB_AGENT_METADATA_KEY,
339
382
  ];
340
383
  /**
@@ -398,26 +441,39 @@ export const createGateway = (options = {}) => {
398
441
  // schedule store's own connection (see its docs for why).
399
442
  const scheduleStore = new SqliteScheduleStore(fleetDbIn(stateDir), { stateHome: stateDir });
400
443
  // ---- events (card 590) ---------------------------------------------------
401
- // Opened only for a daemon with sources, so one that never takes events
402
- // never grows the tables. Its own connection to fleet.db, like schedules'.
444
+ // Opened for the first source, so a daemon that never takes events never
445
+ // grows the tables: at construction for the config's sources, or when the
446
+ // first plugin feeding a source loads (phase 2), which is at start(),
447
+ // before the scheduler's start-up pass. Its own connection to fleet.db,
448
+ // like schedules'.
403
449
  const eventSources = options.events?.sources ?? {};
404
- const eventStore = Object.keys(eventSources).length > 0
405
- ? new SqliteEventStore(fleetDbIn(stateDir), { stateHome: stateDir })
406
- : undefined;
407
- const eventIntake = eventStore
408
- ? createEventIntake({
409
- store: eventStore,
410
- sources: () => eventSources,
411
- // The daemon's own credential, read per delivery: a stored or
412
- // rotated secret works on the next one, with no restart.
413
- secret: (name) => resolveDaemonCredential(env, name),
414
- // The triggers are schedules: `schedule.on` rows on this source.
415
- // Late-bound, because the scheduler is built further down.
416
- triggers: (sourceId) => scheduler.eventTriggers(sourceId),
417
- ...(options.events?.maxBodyBytes !== undefined ? { maxBodyBytes: options.events.maxBodyBytes } : {}),
418
- warn,
419
- })
420
- : undefined;
450
+ /** Sources plugins feed: source id to the package that declared it, filled as each loads. */
451
+ const pluginEventSources = new Map();
452
+ const eventSourceIds = () => [...Object.keys(eventSources), ...pluginEventSources.keys()];
453
+ let eventStore;
454
+ let eventIntake;
455
+ const openEvents = () => {
456
+ if (!eventStore || !eventIntake) {
457
+ eventStore = new SqliteEventStore(fleetDbIn(stateDir), { stateHome: stateDir });
458
+ eventIntake = createEventIntake({
459
+ store: eventStore,
460
+ sources: () => eventSources,
461
+ pluginSources: () => [...pluginEventSources.keys()],
462
+ // The daemon's own credential, read per delivery: a stored or
463
+ // rotated secret works on the next one, with no restart.
464
+ secret: (name) => resolveDaemonCredential(env, name),
465
+ // The triggers are schedules: `schedule.on` rows on this source.
466
+ // Late-bound, because the scheduler is built further down.
467
+ triggers: (sourceId) => scheduler.eventTriggers(sourceId),
468
+ ...(options.events?.maxBodyBytes !== undefined ? { maxBodyBytes: options.events.maxBodyBytes } : {}),
469
+ warn,
470
+ });
471
+ }
472
+ return { store: eventStore, intake: eventIntake };
473
+ };
474
+ if (Object.keys(eventSources).length > 0) {
475
+ openEvents();
476
+ }
421
477
  /**
422
478
  * The started adapter of one kind that speaks for an agent and has the
423
479
  * capability asked for. `carriesOthers` says there were capable adapters
@@ -481,13 +537,28 @@ export const createGateway = (options = {}) => {
481
537
  await outboundFor(agentId, destination);
482
538
  },
483
539
  sessionStatus: async (sessionId) => (await store.get(sessionId))?.status,
540
+ // Card 858: the previous firing, for the next one's checkpoint. The
541
+ // reply and its label, never the transcript.
542
+ previousRun: async (sessionId) => {
543
+ const session = await store.get(sessionId);
544
+ if (!session) {
545
+ return undefined;
546
+ }
547
+ const reply = latestTurnReply(session);
548
+ return {
549
+ status: session.status,
550
+ ...(session.stoppedEarly !== undefined ? { stoppedEarly: session.stoppedEarly } : {}),
551
+ ...(reply !== undefined ? { reply } : {}),
552
+ trust: sessionWriteTrust(session),
553
+ };
554
+ },
484
555
  hasAgent: (agentId) => registry.get(agentId) !== undefined,
485
556
  ...(options.schedules ? { limits: options.schedules } : {}),
486
557
  // The same stamp check every turn runs, before a slot is claimed — see
487
558
  // `SchedulerRuntimeOptions.ready`.
488
559
  ready: () => assertStateCompatible(env),
489
560
  paused: () => intakeState.paused,
490
- ...(eventStore ? { events: { store: eventStore, sources: () => Object.keys(eventSources) } } : {}),
561
+ ...(eventStore ? { events: { store: eventStore, sources: eventSourceIds } } : {}),
491
562
  log,
492
563
  warn,
493
564
  });
@@ -1168,6 +1239,59 @@ export const createGateway = (options = {}) => {
1168
1239
  reloads = next.catch(() => undefined);
1169
1240
  await next;
1170
1241
  };
1242
+ const channelReloadTimeoutMs = options.channelReloadTimeoutMs ?? DEFAULT_CHANNEL_RELOAD_TIMEOUT_MS;
1243
+ /**
1244
+ * After a roster reload, each started channel that can picks up the
1245
+ * agents it carries now (a new agent's stored app, one whose soul or
1246
+ * secrets are gone). One at a time, in start order, so the log reads in
1247
+ * the order an operator configured them. A channel that fails is
1248
+ * logged and the rest still reload: one bad app must not hold back the
1249
+ * others, the rule `startChannels` follows. A channel still reloading
1250
+ * after the bound is left to finish on its own, and what it ends with is
1251
+ * logged then, so a hung Slack call cannot hold every later reload.
1252
+ */
1253
+ const reloadChannels = async () => {
1254
+ const outcomes = [];
1255
+ for (const adapter of [...startedChannels]) {
1256
+ if (stopping || channelsClosed) {
1257
+ return outcomes;
1258
+ }
1259
+ if (typeof adapter.reload !== 'function') {
1260
+ continue;
1261
+ }
1262
+ const reload = adapter.reload.bind(adapter);
1263
+ // Settles with the failure, never rejects: one that comes after the
1264
+ // bound has nobody awaiting it, and would take the daemon down.
1265
+ const settled = (async () => reload())().then(() => undefined, (error) => (error instanceof Error ? error.message : String(error)));
1266
+ let timer;
1267
+ const bound = new Promise((resolve) => {
1268
+ timer = setTimeout(() => resolve('timeout'), channelReloadTimeoutMs);
1269
+ timer.unref?.();
1270
+ });
1271
+ const outcome = await Promise.race([settled.then((failure) => ({ failure })), bound]);
1272
+ clearTimeout(timer);
1273
+ if (outcome === 'timeout') {
1274
+ warn(`channel ${adapter.name} is still reloading after ${channelReloadTimeoutMs} ms; the roster reload goes on without it`);
1275
+ void settled.then((failure) => {
1276
+ if (failure !== undefined) {
1277
+ warn(`channel ${adapter.name} could not reload: ${failure}`);
1278
+ }
1279
+ else {
1280
+ log(`channel ${adapter.name} finished reloading`);
1281
+ }
1282
+ });
1283
+ outcomes.push({ name: adapter.name, outcome: 'still-reloading' });
1284
+ continue;
1285
+ }
1286
+ if (outcome.failure !== undefined) {
1287
+ warn(`channel ${adapter.name} could not reload: ${outcome.failure}`);
1288
+ outcomes.push({ name: adapter.name, outcome: 'failed', reason: outcome.failure });
1289
+ continue;
1290
+ }
1291
+ outcomes.push({ name: adapter.name, outcome: 'reloaded' });
1292
+ }
1293
+ return outcomes;
1294
+ };
1171
1295
  /**
1172
1296
  * A session pins its agent id, never a snapshot of the agent: the soul
1173
1297
  * file is re-read on every dispatch, so an edited persona (or tool
@@ -1320,6 +1444,128 @@ export const createGateway = (options = {}) => {
1320
1444
  }
1321
1445
  return adapter.createConversation({ agentId, ...request });
1322
1446
  }));
1447
+ // Joining and inviting follow the same rule: the asking agent's own app
1448
+ // does it, so membership stays per app and its token stays in the adapter.
1449
+ tools.register(createChannelJoinTool(async ({ agentId, channel, conversation }) => {
1450
+ const { adapter, carriesOthers } = channelCarrying(channel, agentId, (candidate) => candidate.joinConversation !== undefined);
1451
+ if (!adapter?.joinConversation) {
1452
+ throw new Error(carriesOthers
1453
+ ? `No running '${channel}' channel carries agent ${agentId}, so it has no app to join a channel with.`
1454
+ : `No running channel can join '${channel}' conversations.`);
1455
+ }
1456
+ return adapter.joinConversation({ agentId, conversation });
1457
+ }));
1458
+ tools.register(createChannelInviteTool(async ({ agentId, channel, ...request }) => {
1459
+ const { adapter, carriesOthers } = channelCarrying(channel, agentId, (candidate) => candidate.inviteToConversation !== undefined);
1460
+ if (!adapter?.inviteToConversation) {
1461
+ throw new Error(carriesOthers
1462
+ ? `No running '${channel}' channel carries agent ${agentId}, so it has no app to invite anyone with.`
1463
+ : `No running channel can invite people to '${channel}' conversations.`);
1464
+ }
1465
+ return adapter.inviteToConversation({ agentId, ...request });
1466
+ }));
1467
+ /**
1468
+ * Why a `channel.archive` call would archive the conversation its work was
1469
+ * asked from, or can't tell, as a sentence; undefined when it may go on.
1470
+ * That conversation is never archived: the answer to it would have
1471
+ * nowhere to go, and the agent can't unarchive.
1472
+ *
1473
+ * An ordinary session is its own origin. A sub-agent's or a delegated
1474
+ * run's is the root turn that started the chain, read off the id the
1475
+ * host minted for it (each child id is its parent's plus a marker, so the
1476
+ * root is the text before the first one), never off metadata a tool in
1477
+ * the slice could rewrite. The id is the one the session had at its first
1478
+ * tool call, for the same reason. The origin's conversation is asked of
1479
+ * the adapter of this kind that carries the origin's agent. When it can't
1480
+ * be told (the root is gone; no adapter, no `sessionConversation`, a
1481
+ * lookup that fails, or no answer for a session whose id is this
1482
+ * channel's), the call is refused: an archive the agent can't undo
1483
+ * doesn't run on a guess. A session that isn't this channel's at all (an
1484
+ * API call, a schedule, a turn on another transport) has no conversation
1485
+ * here to protect.
1486
+ */
1487
+ const archiveRefusal = async (kind, conversation, session, archiving) => {
1488
+ const unknown = (why) => `${kind} ${conversation} was not archived: ${why}, so Stratus can't tell which conversation this work was asked from, `
1489
+ + 'and that one is never archived. Ask for the archive from a conversation.';
1490
+ const id = hostIdOf(session);
1491
+ const marks = [SUB_AGENT_SESSION_ID_MARKER, DELEGATED_SESSION_ID_MARKER]
1492
+ .map((marker) => id.indexOf(marker))
1493
+ .filter((at) => at >= 0);
1494
+ let origin = { agentId: session.agent.id, sessionId: id };
1495
+ let adapter = archiving;
1496
+ if (marks.length > 0) {
1497
+ const rootId = id.slice(0, Math.min(...marks));
1498
+ let root;
1499
+ try {
1500
+ root = rootId ? await store.get(rootId) : undefined;
1501
+ }
1502
+ catch (error) {
1503
+ warn(`${id}: could not read the root session ${rootId} to check a channel.archive (${error instanceof Error ? error.message : String(error)})`);
1504
+ root = undefined;
1505
+ }
1506
+ if (!root) {
1507
+ return unknown('this run was started by another session that no longer exists');
1508
+ }
1509
+ origin = { agentId: root.agent.id, sessionId: root.id };
1510
+ adapter = channelCarrying(kind, origin.agentId, () => true).adapter;
1511
+ }
1512
+ // Channel session ids lead with their kind (`channelSessionKey`).
1513
+ const onThisChannel = origin.sessionId.startsWith(`${kind}:`);
1514
+ if (!adapter) {
1515
+ return onThisChannel ? unknown(`no running '${kind}' channel carries the agent this work was asked of`) : undefined;
1516
+ }
1517
+ if (!adapter.sessionConversation) {
1518
+ return unknown(`the running '${kind}' channel can't say which conversation a session is in`);
1519
+ }
1520
+ let origins;
1521
+ try {
1522
+ origins = adapter.sessionConversation(origin.agentId, origin.sessionId);
1523
+ }
1524
+ catch (error) {
1525
+ warn(`${id}: could not tell which conversation ${origin.sessionId} is in to check a channel.archive (${error instanceof Error ? error.message : String(error)})`);
1526
+ return unknown(`the running '${kind}' channel couldn't say which conversation this work was asked from`);
1527
+ }
1528
+ if (origins === undefined) {
1529
+ return onThisChannel ? unknown(`the running '${kind}' channel can't say which of its conversations this work was asked from`) : undefined;
1530
+ }
1531
+ return origins === conversation
1532
+ ? `${kind} ${conversation} is the conversation this work was asked from, so it was not archived: `
1533
+ + 'the reply would have nowhere to go. Ask for the archive from another conversation.'
1534
+ : undefined;
1535
+ };
1536
+ const archivingAdapter = (kind, agentId) => channelCarrying(kind, agentId, (candidate) => candidate.archiveConversation !== undefined);
1537
+ const noArchivingAdapter = (kind, agentId, carriesOthers) => carriesOthers
1538
+ ? `No running '${kind}' channel carries agent ${agentId}, so it has no app to archive a channel with.`
1539
+ : `No running channel can archive '${kind}' conversations.`;
1540
+ tools.register(createChannelArchiveTool(async ({ agentId, channel, conversation }, session) => {
1541
+ const { adapter, carriesOthers } = archivingAdapter(channel, agentId);
1542
+ if (!adapter?.archiveConversation) {
1543
+ throw new Error(noArchivingAdapter(channel, agentId, carriesOthers));
1544
+ }
1545
+ const refusal = await archiveRefusal(channel, conversation, session, adapter);
1546
+ if (refusal !== undefined) {
1547
+ throw new Error(refusal);
1548
+ }
1549
+ return adapter.archiveConversation({ agentId, conversation });
1550
+ }, {
1551
+ // The same check before anyone is asked: an approval, Always allow
1552
+ // above all, for an archive that is bound to be refused is a question
1553
+ // with no useful answer. Held as an approval wait, like message.read's,
1554
+ // since it runs before any tool event and may read the store.
1555
+ refusalFor: async ({ agentId, channel, conversation }, session) => {
1556
+ const { adapter, carriesOthers } = archivingAdapter(channel, agentId);
1557
+ if (!adapter?.archiveConversation) {
1558
+ return noArchivingAdapter(channel, agentId, carriesOthers);
1559
+ }
1560
+ const release = holdApprovalWait(session.id);
1561
+ try {
1562
+ return await archiveRefusal(channel, conversation, session, adapter);
1563
+ }
1564
+ finally {
1565
+ release();
1566
+ }
1567
+ },
1568
+ }));
1323
1569
  tools.register(createMessageReadTool(async ({ agentId, source, ...window }) => {
1324
1570
  // The same carrying rule as outbound: a read goes through the app of
1325
1571
  // the agent asking, never whichever adapter of that kind is running,
@@ -1654,6 +1900,18 @@ export const createGateway = (options = {}) => {
1654
1900
  message: `${request.soulPath} now belongs to ${owner}, not ${request.agentId}, so nothing was stored. Ask ${request.agentId} to request it again.`,
1655
1901
  };
1656
1902
  }
1903
+ const value = formCredentialValue(input.value);
1904
+ if (isFlattenedPem(value)) {
1905
+ // Refused, not retired: the same form can still take the key whole.
1906
+ const command = request.scope === 'agent'
1907
+ ? `stratus credential set ${request.name} --agent ${quoteShellArg(request.agentId)} < key.pem`
1908
+ : `stratus credential set ${request.name} < key.pem`;
1909
+ return {
1910
+ ok: false,
1911
+ message: 'That looks like a PEM key whose line breaks were lost, so nothing was stored: whatever reads it would fail. '
1912
+ + `Paste it again from the original file with its line breaks, or store it on the machine: ${command}.`,
1913
+ };
1914
+ }
1657
1915
  const store = {
1658
1916
  ...(request.scope === 'agent' ? { agentId: request.agentId } : {}),
1659
1917
  // The binding the form promised, written with the value, always for
@@ -1670,7 +1928,7 @@ export const createGateway = (options = {}) => {
1670
1928
  if (soul.agent.id !== request.agentId || soul.agent.credentials?.includes(request.name) !== true) {
1671
1929
  return false;
1672
1930
  }
1673
- await replaceNamedCredential(env, { name: request.name, value: input.value, ...store });
1931
+ await replaceNamedCredential(env, { name: request.name, value, ...store });
1674
1932
  return true;
1675
1933
  });
1676
1934
  }
@@ -1709,7 +1967,7 @@ export const createGateway = (options = {}) => {
1709
1967
  try {
1710
1968
  await addNamedCredential(env, {
1711
1969
  name: request.name,
1712
- value: input.value,
1970
+ value,
1713
1971
  ...store,
1714
1972
  });
1715
1973
  }
@@ -2330,6 +2588,23 @@ export const createGateway = (options = {}) => {
2330
2588
  // behind the in-flight turn instead of interleaving with it. Sessions
2331
2589
  // are independent — different conversations run concurrently.
2332
2590
  const sessionChains = new Map();
2591
+ /**
2592
+ * Work queued on each session's chain that has not started, of every
2593
+ * kind (a dispatch, an overheard message, a recovery). What says whether
2594
+ * a merged-turn batch is first in line behind the running turn, which
2595
+ * steering needs (card 948): a batch with anything ahead of it would be
2596
+ * answered before what was said first.
2597
+ */
2598
+ const queuedWork = new Map();
2599
+ const releaseQueued = (sessionId) => {
2600
+ const left = (queuedWork.get(sessionId) ?? 1) - 1;
2601
+ if (left > 0) {
2602
+ queuedWork.set(sessionId, left);
2603
+ }
2604
+ else {
2605
+ queuedWork.delete(sessionId);
2606
+ }
2607
+ };
2333
2608
  /**
2334
2609
  * Dispatches queued behind a session's running turn, per session: what
2335
2610
  * tells an auto-continuing turn that somebody wrote meanwhile, so it
@@ -2567,9 +2842,13 @@ export const createGateway = (options = {}) => {
2567
2842
  return await turn;
2568
2843
  }
2569
2844
  catch (error) {
2570
- if (input.idempotencyKey !== undefined && error instanceof RunAbortedError && error.resumable) {
2845
+ if (error instanceof RunAbortedError && error.resumable) {
2571
2846
  const left = await store.get(input.sessionId).catch(() => undefined);
2572
- if (left !== undefined && workItemState(left, input.idempotencyKey) === 'unfinished' && leftForNextStart(left, error)) {
2847
+ // The dispatch's own key, or, for an unkeyed one whose turn took
2848
+ // keyed messages in at a tool step (card 948), the key the turn has
2849
+ // from them: its steered callers are told what a keyed caller is.
2850
+ const key = input.idempotencyKey ?? (left === undefined ? undefined : turnKeyOf(turnMessageOf(left)));
2851
+ if (left !== undefined && key !== undefined && workItemState(left, key) === 'unfinished' && leftForNextStart(left, error)) {
2573
2852
  throw leftForNext();
2574
2853
  }
2575
2854
  }
@@ -2581,7 +2860,9 @@ export const createGateway = (options = {}) => {
2581
2860
  * runs as that, on the parent's resolved provider, with no memory and no
2582
2861
  * soul. Only that path passes it.
2583
2862
  */
2584
- const dispatchInternal = async (input, absorbedKeys = [], subAgent) => {
2863
+ const dispatchInternal = async (input, absorbedKeys = [], subAgent,
2864
+ /** See `RunInput.steer`; passed by `dispatch` for an opted-in caller. */
2865
+ steer) => {
2585
2866
  // Re-read on every turn, not once at start: a newer build that stamps
2586
2867
  // the home while this daemon is serving has formats this build does not
2587
2868
  // understand, and a daemon that kept writing into them would be the
@@ -2706,6 +2987,7 @@ export const createGateway = (options = {}) => {
2706
2987
  ...(hostedLoop ? { hostedLoop } : {}),
2707
2988
  ...(input.metadata ? { metadata: input.metadata } : {}),
2708
2989
  runtime: runtimeContextFor(source, config, switchedToFallback, input.metadata),
2990
+ ...(steer !== undefined ? { steer } : {}),
2709
2991
  signal,
2710
2992
  });
2711
2993
  }
@@ -2721,6 +3003,7 @@ export const createGateway = (options = {}) => {
2721
3003
  ...(input.earlier !== undefined && input.earlier.length > 0 ? { earlier: input.earlier } : {}),
2722
3004
  metadata,
2723
3005
  runtime: runtimeContextFor(source, config, switchedToFallback, input.metadata, subAgent !== undefined),
3006
+ ...(steer !== undefined ? { steer } : {}),
2724
3007
  signal,
2725
3008
  });
2726
3009
  }));
@@ -2920,7 +3203,7 @@ export const createGateway = (options = {}) => {
2920
3203
  // otherwise stay parked for every later start and redelivery to try
2921
3204
  // again. An unkeyed one keeps waiting for a sweep that can, as before.
2922
3205
  const after = await store.get(sessionId).catch(() => undefined);
2923
- const key = after?.messages.findLast((candidate) => candidate.role === 'user')?.idempotencyKey;
3206
+ const key = after === undefined ? undefined : turnKeyOf(turnMessageOf(after));
2924
3207
  if (after !== undefined && key !== undefined && after.status === 'pending_approval' && workItemState(after, key) === 'unfinished') {
2925
3208
  await failParked(after, ABANDONED_TURN_ERROR).catch(() => { });
2926
3209
  }
@@ -3031,8 +3314,8 @@ export const createGateway = (options = {}) => {
3031
3314
  * whose caller was never promised anything and is told to send again.
3032
3315
  */
3033
3316
  const settleAbandonedTurn = async (session) => {
3034
- const message = session.messages.findLast((candidate) => candidate.role === 'user');
3035
- if (message?.idempotencyKey === undefined) {
3317
+ const message = turnMessageOf(session);
3318
+ if (message === undefined || turnKeyOf(message) === undefined) {
3036
3319
  return failAbandoned(session);
3037
3320
  }
3038
3321
  // An answer already in the transcript is completed whatever came before
@@ -3184,6 +3467,100 @@ export const createGateway = (options = {}) => {
3184
3467
  }
3185
3468
  return settleAbandonedTurn(session);
3186
3469
  };
3470
+ /**
3471
+ * What a running turn takes in at its next tool step (card 948): the
3472
+ * merged-turn batch waiting behind it, whole — leader and absorbed alike
3473
+ * — when it is for the same agent. The dispatches it takes join `into`,
3474
+ * which the running turn settles with its own outcome (`runSteerable`).
3475
+ *
3476
+ * A part cancelled while it waited is refused, as `runMerged` refuses it.
3477
+ * A part whose key this session (or the archive it rolled over from)
3478
+ * already holds is a repeat, and judging a repeat can mean settling a
3479
+ * turn — never something to do from inside one — so such a batch is not
3480
+ * taken: it waits and runs next, merged, where that is judged. Nor while
3481
+ * the gateway is stopping, when the batch is what the drain refuses.
3482
+ */
3483
+ const takeSteered = async (sessionId, agentId, into,
3484
+ /**
3485
+ * Asked last, with nothing awaited after it: whether the running turn
3486
+ * still takes these callers, joining them to its cancellation if so. A
3487
+ * turn cancelled while the store was read takes nothing, and the batch
3488
+ * waits and runs next, as it would have.
3489
+ */
3490
+ admit) => {
3491
+ const batch = openBatches.get(sessionId);
3492
+ if (stopping || batch === undefined || !batch.firstInLine || batch.agentId !== agentId) {
3493
+ return undefined;
3494
+ }
3495
+ // Until every key in it is checked with nothing awaited since: a message
3496
+ // can join while the store is read, and is checked on the next pass; a
3497
+ // batch closed meanwhile is no longer this turn's to take.
3498
+ const checked = new Set();
3499
+ let session;
3500
+ for (;;) {
3501
+ if (stopping || openBatches.get(sessionId) !== batch) {
3502
+ return undefined;
3503
+ }
3504
+ const unchecked = [batch.leader, ...batch.absorbed].flatMap((part) => (part.input.idempotencyKey !== undefined && !checked.has(part.input.idempotencyKey) ? [part.input.idempotencyKey] : []));
3505
+ if (unchecked.length === 0) {
3506
+ break;
3507
+ }
3508
+ session ??= await store.get(sessionId);
3509
+ for (const key of unchecked) {
3510
+ checked.add(key);
3511
+ if (session !== undefined && (workItemState(session, key) !== undefined || await archivedRepeat(session, key) !== undefined)) {
3512
+ return undefined;
3513
+ }
3514
+ }
3515
+ }
3516
+ const all = [batch.leader, ...batch.absorbed];
3517
+ if (!admit(all.filter((part) => part.input.signal?.aborted !== true))) {
3518
+ return undefined;
3519
+ }
3520
+ openBatches.delete(sessionId);
3521
+ batch.steered = true;
3522
+ // Its place on the chain runs nothing now, so it is not ahead of a batch
3523
+ // opening behind it, which can then be steered at a later step.
3524
+ releaseQueued(sessionId);
3525
+ // Not waiting any more; the leader's place on the chain does not count it again.
3526
+ const left = (waitingDispatches.get(sessionId) ?? all.length) - all.length;
3527
+ if (left > 0) {
3528
+ waitingDispatches.set(sessionId, left);
3529
+ }
3530
+ else {
3531
+ waitingDispatches.delete(sessionId);
3532
+ }
3533
+ const runnable = [];
3534
+ for (const part of all) {
3535
+ if (part.input.signal?.aborted) {
3536
+ part.reject(abortErrorFor(part.input.signal));
3537
+ continue;
3538
+ }
3539
+ runnable.push(part);
3540
+ }
3541
+ if (runnable.length === 0) {
3542
+ return undefined;
3543
+ }
3544
+ const keys = runnable.flatMap((part) => (part.input.idempotencyKey !== undefined ? [part.input.idempotencyKey] : []));
3545
+ const items = liveWorkItems.get(sessionId);
3546
+ for (const key of keys) {
3547
+ const item = items?.get(key);
3548
+ if (item !== undefined) {
3549
+ item.confirmed = true;
3550
+ }
3551
+ }
3552
+ into.push(...runnable);
3553
+ const labelled = runnable.some((part) => part.input.metadata !== undefined && SENDER_TRUST_METADATA_KEY in part.input.metadata);
3554
+ const lowest = leastTrusted(...runnable.map((part) => senderTrustOf(part.input.metadata)));
3555
+ const images = runnable.flatMap((part) => part.input.images ?? []);
3556
+ log(`${sessionId}: ${runnable.length} message(s) sent during the running turn reached it at its next step`);
3557
+ return {
3558
+ userMessage: runnable.map((part) => part.input.userMessage).join(MERGED_SEPARATOR),
3559
+ ...(images.length > 0 ? { images } : {}),
3560
+ ...(labelled ? { metadata: { [SENDER_TRUST_METADATA_KEY]: lowest } } : {}),
3561
+ ...(keys.length > 0 ? { keys } : {}),
3562
+ };
3563
+ };
3187
3564
  /**
3188
3565
  * Settles a keyed item the last process left unfinished before any other
3189
3566
  * message joins its session. Channels are up before the sweeps reach it,
@@ -3196,7 +3573,7 @@ export const createGateway = (options = {}) => {
3196
3573
  */
3197
3574
  const settleOrphanedItem = async (sessionId) => {
3198
3575
  const session = await store.get(sessionId);
3199
- const key = session?.messages.findLast((candidate) => candidate.role === 'user')?.idempotencyKey;
3576
+ const key = session === undefined ? undefined : turnKeyOf(turnMessageOf(session));
3200
3577
  if (session !== undefined && key !== undefined && workItemState(session, key) === 'unfinished') {
3201
3578
  // A continuation that fails has failed its session, which is settled
3202
3579
  // too; the message waiting behind it runs either way.
@@ -3227,9 +3604,18 @@ export const createGateway = (options = {}) => {
3227
3604
  * stops that, and it only holds if everything that writes a session goes
3228
3605
  * through it.
3229
3606
  */
3230
- const onSessionChain = (sessionId, work) => {
3607
+ const onSessionChain = (sessionId, work,
3608
+ /** True once this work no longer counts as queued: a batch steered into the turn ahead (`takeSteered` released it). */
3609
+ released) => {
3231
3610
  const previous = sessionChains.get(sessionId) ?? Promise.resolve();
3232
- const turn = previous.then(work, work);
3611
+ queuedWork.set(sessionId, (queuedWork.get(sessionId) ?? 0) + 1);
3612
+ const start = () => {
3613
+ if (released?.() !== true) {
3614
+ releaseQueued(sessionId);
3615
+ }
3616
+ return work();
3617
+ };
3618
+ const turn = previous.then(start, start);
3233
3619
  const settled = turn.catch(() => { });
3234
3620
  sessionChains.set(sessionId, settled);
3235
3621
  inflight.add(settled);
@@ -3438,7 +3824,16 @@ export const createGateway = (options = {}) => {
3438
3824
  // Otherwise it leads a new batch, if it is waiting behind a turn at all:
3439
3825
  // a message to an idle session runs at once, alone.
3440
3826
  const batch = mergeable && sessionChains.has(input.sessionId)
3441
- ? { agentId: input.agentId, absorbed: [], images: [...(input.images ?? [])], chars: input.userMessage.length }
3827
+ ? {
3828
+ agentId: input.agentId,
3829
+ // Settled through `settleLeader`, read when called: it is assigned below.
3830
+ leader: { input, resolve: (session) => settleLeader({ session }), reject: (error) => settleLeader({ error }) },
3831
+ firstInLine: (queuedWork.get(input.sessionId) ?? 0) === 0,
3832
+ absorbed: [],
3833
+ steered: false,
3834
+ images: [...(input.images ?? [])],
3835
+ chars: input.userMessage.length,
3836
+ }
3442
3837
  : undefined;
3443
3838
  if (batch !== undefined) {
3444
3839
  openBatches.set(input.sessionId, batch);
@@ -3540,7 +3935,7 @@ export const createGateway = (options = {}) => {
3540
3935
  activeTurns.set(input.sessionId, merged.turnId);
3541
3936
  }
3542
3937
  try {
3543
- outcome = { session: await dispatchInternal(merged, absorbedKeys) };
3938
+ outcome = { session: await runSteerable(merged, absorbedKeys) };
3544
3939
  }
3545
3940
  catch (error) {
3546
3941
  outcome = { error };
@@ -3574,7 +3969,59 @@ export const createGateway = (options = {}) => {
3574
3969
  settleLeader = (outcome) => ('error' in outcome ? reject(outcome.error) : resolve(outcome.session));
3575
3970
  });
3576
3971
  waitingDispatches.set(input.sessionId, (waitingDispatches.get(input.sessionId) ?? 0) + 1);
3972
+ /**
3973
+ * Runs this dispatch's turn, handing it whatever queues behind it at
3974
+ * each tool step (`takeSteered`), then settles every dispatch it took
3975
+ * with the turn's outcome — the outcome they joined. Only for a caller
3976
+ * that opted into merged turns: it is the one that renders an answer
3977
+ * covering messages other than its own.
3978
+ */
3979
+ const runSteerable = async (turnInput, absorbedKeys = []) => {
3980
+ const steered = [];
3981
+ // Cancelled only when every caller in it is, as a merged turn is, the
3982
+ // ones steered in later included: one caller giving up must not take
3983
+ // the others' answer with it. A turn with a caller who cannot cancel
3984
+ // has no signal to join.
3985
+ const joint = turnInput.signal !== undefined ? joinableSignal([turnInput.signal]) : undefined;
3986
+ const steer = mergeable
3987
+ ? () => takeSteered(input.sessionId, input.agentId, steered, (parts) => {
3988
+ if (joint?.signal.aborted === true) {
3989
+ return false;
3990
+ }
3991
+ for (const part of parts) {
3992
+ joint?.join(part.input.signal);
3993
+ }
3994
+ return true;
3995
+ })
3996
+ : undefined;
3997
+ const running = joint !== undefined ? { ...turnInput, signal: joint.signal } : turnInput;
3998
+ let outcome;
3999
+ try {
4000
+ outcome = { session: await dispatchInternal(running, absorbedKeys, undefined, steer) };
4001
+ }
4002
+ catch (error) {
4003
+ outcome = { error };
4004
+ }
4005
+ for (const part of steered) {
4006
+ part.input.onAbsorbed?.();
4007
+ if ('session' in outcome) {
4008
+ part.resolve(outcome.session);
4009
+ }
4010
+ else {
4011
+ part.reject(outcome.error);
4012
+ }
4013
+ }
4014
+ if ('error' in outcome) {
4015
+ throw outcome.error;
4016
+ }
4017
+ return outcome.session;
4018
+ };
3577
4019
  const chained = onSessionChain(input.sessionId, async () => {
4020
+ // Steered into the turn that ran ahead of it, which has answered and
4021
+ // settled everyone in the batch already — this caller included.
4022
+ if (batch?.steered === true) {
4023
+ return turn;
4024
+ }
3578
4025
  // The batch closes here: anything arriving from now on waits behind
3579
4026
  // this turn and leads the next one.
3580
4027
  if (batch !== undefined && openBatches.get(input.sessionId) === batch) {
@@ -3609,11 +4056,11 @@ export const createGateway = (options = {}) => {
3609
4056
  }
3610
4057
  await settleOrphanedItem(input.sessionId);
3611
4058
  if (turnId === undefined) {
3612
- return dispatchInternal(input);
4059
+ return runSteerable(input);
3613
4060
  }
3614
4061
  activeTurns.set(input.sessionId, turnId);
3615
4062
  try {
3616
- return await dispatchInternal(input);
4063
+ return await runSteerable(input);
3617
4064
  }
3618
4065
  finally {
3619
4066
  // Unconditional, and safe to be: the chain guarantees no other turn
@@ -3621,7 +4068,7 @@ export const createGateway = (options = {}) => {
3621
4068
  // only ever be clearing its own entry.
3622
4069
  activeTurns.delete(input.sessionId);
3623
4070
  }
3624
- });
4071
+ }, () => batch?.steered === true);
3625
4072
  // A second settle (a leader already settled early) is a no-op.
3626
4073
  chained.then((session) => settleLeader({ session }), (error) => settleLeader({ error }));
3627
4074
  if (idempotencyKey !== undefined) {
@@ -3754,6 +4201,41 @@ export const createGateway = (options = {}) => {
3754
4201
  onRead: (method) => log(`fleet read: ${method} by plugin ${packageName}`),
3755
4202
  }),
3756
4203
  providerCallGuards,
4204
+ eventSources: {
4205
+ reserved: Object.keys(eventSources),
4206
+ register: (packageName, sourceIds) => {
4207
+ if (!eventStore) {
4208
+ // The first source on a daemon whose config has none: the
4209
+ // scheduler was built without events, so it takes them now,
4210
+ // before its start-up pass reads what is waiting. First, so a
4211
+ // store that won't open refuses the plugin with no source left
4212
+ // behind.
4213
+ scheduler.attachEvents({ store: openEvents().store, sources: eventSourceIds });
4214
+ }
4215
+ for (const sourceId of sourceIds) {
4216
+ pluginEventSources.set(sourceId, packageName);
4217
+ }
4218
+ },
4219
+ ingest: async (packageName, sourceId, event) => {
4220
+ if (pluginEventSources.get(sourceId) !== packageName) {
4221
+ throw new Error(`Plugin ${packageName} does not feed event source "${sourceId}" on this daemon.`);
4222
+ }
4223
+ // The store closes last in a stop. Until then an event is kept
4224
+ // and goes out from the next start; after, refused, so a poller
4225
+ // keeps its cursor where it was.
4226
+ if (storesClosed) {
4227
+ throw new Error('stratusd has stopped, so this event was not stored. Feed it again once the daemon is back.');
4228
+ }
4229
+ const answer = openEvents().intake.ingest(sourceId, event);
4230
+ // Sent now rather than at the next tick; the scheduler decides
4231
+ // hold or fire, and does nothing while it is stopped or not yet
4232
+ // started.
4233
+ if (answer.status === 'matched') {
4234
+ scheduler.kickEvents();
4235
+ }
4236
+ return answer;
4237
+ },
4238
+ },
3757
4239
  });
3758
4240
  loadedPlugins = result.loaded;
3759
4241
  pluginFailures = result.failures;
@@ -3766,6 +4248,7 @@ export const createGateway = (options = {}) => {
3766
4248
  ...plugin.contributions.memory.map((name) => `memory store ${name}`),
3767
4249
  ...plugin.contributions.executors.map((name) => `executor ${name}`),
3768
4250
  ...(plugin.providerCallGuards > 0 ? ['guards model calls'] : []),
4251
+ ...plugin.eventSources.map((sourceId) => `event source ${sourceId}`),
3769
4252
  ];
3770
4253
  log(`plugin ${plugin.package} loaded — ${contributed.join(', ') || 'no tools'}`);
3771
4254
  }
@@ -4448,12 +4931,23 @@ export const createGateway = (options = {}) => {
4448
4931
  return registry.list();
4449
4932
  },
4450
4933
  async reloadRoster() {
4451
- // Under the soul lock, so a repoint cannot land between a credential
4452
- // grant's check of the served path and its write (`grantSoulCredential`).
4453
- await reloadsInOrder(() => withSoulFileLock(loadRoster));
4454
- const roster = registry.list();
4455
- log(`roster reloaded — ${roster.length} agent(s): ${roster.map((agent) => agent.name).join(', ')}`);
4456
- return roster;
4934
+ return (await gateway.reloadRosterAndChannels()).agents;
4935
+ },
4936
+ async reloadRosterAndChannels() {
4937
+ let roster = [];
4938
+ let channels = [];
4939
+ await reloadsInOrder(async () => {
4940
+ // Under the soul lock, so a repoint cannot land between a credential
4941
+ // grant's check of the served path and its write (`grantSoulCredential`).
4942
+ await withSoulFileLock(loadRoster);
4943
+ roster = registry.list();
4944
+ log(`roster reloaded — ${roster.length} agent(s): ${roster.map((agent) => agent.name).join(', ')}`);
4945
+ // Outside the soul lock, which guards files and must not wait on a
4946
+ // network; inside the reload chain, so two reloads never interleave
4947
+ // their channel work (the bound keeps one from holding it).
4948
+ channels = await reloadChannels();
4949
+ });
4950
+ return { agents: roster, channels };
4457
4951
  },
4458
4952
  async reloadSkills() {
4459
4953
  try {
@@ -4559,7 +5053,13 @@ export const createGateway = (options = {}) => {
4559
5053
  if (!eventStore || !eventIntake) {
4560
5054
  return { since, sources: {}, unknownSource: 0 };
4561
5055
  }
4562
- return summarizeEvents({ sourceIds: Object.keys(eventSources), since, activity: eventStore.activity(since), counters: eventIntake.counters() });
5056
+ return summarizeEvents({
5057
+ sourceIds: eventSourceIds(),
5058
+ plugins: Object.fromEntries(pluginEventSources),
5059
+ since,
5060
+ activity: eventStore.activity(since),
5061
+ counters: eventIntake.counters(),
5062
+ });
4563
5063
  },
4564
5064
  eventDeliveries(options = {}) {
4565
5065
  return eventStore?.deliveries(options) ?? [];