@stratusagent/gateway 0.11.6 → 0.11.8

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,8 +7,8 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
7
7
  return path;
8
8
  };
9
9
  import { createHash, randomUUID } from 'node:crypto';
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';
10
+ import { filePathsOf, isUnaddressedTurn, workItemState, lastResponseIsAnswer, MAX_IDEMPOTENCY_KEY_LENGTH, recordTurnFailure, completeAnsweredTurn, 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, conversationContextFrom, CONVERSATION_METADATA_KEY, STRATUS_SKILL_ID, } from '@stratusagent/core';
11
+ import { createCredentialRequestTool, 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, loadStratusSkill, STRATUS_SKILL_PATH, } from '@stratusagent/agents';
12
12
  import { createSchedulerRuntime, isScheduleSessionId, SqliteScheduleStore, SCHEDULE_SESSION_ID_PREFIX, } from "./schedules.js";
13
13
  import { ShardedSessionStore } from "./sessions.js";
14
14
  export { claimHome, homeLockPath, HomeClaimedError, } from "./lock.js";
@@ -16,7 +16,7 @@ export { SqliteSessionStore, ShardedSessionStore, FleetSessionIndex, SessionIdTa
16
16
  export { SqliteScheduleStore, createSchedulerRuntime, isScheduleSessionId, SCHEDULE_SESSION_ID_PREFIX, } from "./schedules.js";
17
17
  import { createLocalCommandExecutor } from '@stratusagent/executor-local';
18
18
  import { loadPlugins, } from '@stratusagent/plugins';
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';
19
+ 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, namedCredentialSource, NamedCredentialExistsError, CREDENTIAL_NAME_PATTERN, quoteShellArg, migrateLegacyMemory, PROVIDER_STATE_METADATA_KEYS, ConfigFileError, loadConfigFile, resolveConfigLocation, resolveConfiguredSoul, resolveRuntimeConfig, runsOnHarness, applySoulPins, assertStateCompatible, stratusHomePath, fleetDbIn, createAgentWorkspaces, createPluginStateDirectories, foldedAgentId, } from '@stratusagent/state';
20
20
  const DEFAULT_IDLE_TIMEOUT_MS = 120_000;
21
21
  /**
22
22
  * How long a parked call waits for a person. Long enough to survive a
@@ -70,12 +70,17 @@ export class RestartUnsupportedError extends Error {
70
70
  * its own guess at the wording.
71
71
  */
72
72
  export const ABANDONED_TURN_ERROR = 'stratusd stopped while this turn was still running; it was not resumed. Send the message again.';
73
+ /**
74
+ * The longest `DispatchInput.idempotencyKey` accepted. A platform message id
75
+ * is far shorter; the bound is so a key cannot be used to grow every row it
76
+ * is stored on.
77
+ */
73
78
  /**
74
79
  * Recorded on a delegated sub-session that was parked on a human when the
75
80
  * previous process died.
76
81
  *
77
82
  * Its parent — the turn whose `agent.delegate` call was awaiting the reply
78
- * — was running, so the restart fails it as abandoned (above). Recovering
83
+ * — was running, so the restart settles it as abandoned (above). Recovering
79
84
  * the child anyway would re-ask a person to approve a call whose result no
80
85
  * turn will ever read, and then run it: a command executed, tokens spent,
81
86
  * and a reply delivered to nobody. Distinguishable from the parent's error
@@ -106,6 +111,29 @@ export const ROLLED_OVER_FROM_METADATA_KEY = 'rolledOverFrom';
106
111
  export const ROLLED_OVER_TO_METADATA_KEY = 'rolledOverTo';
107
112
  /** The segment a rollover mints into the archived transcript's id. */
108
113
  export const ROLLED_OVER_SESSION_ID_MARKER = ':rolledover:';
114
+ /**
115
+ * How long after a rollover a repeated key is still looked for in the
116
+ * archived transcript. A rollover moves every keyed message out of the
117
+ * live row, so a redelivery arriving after one would otherwise read as a
118
+ * new message and run again. Platforms redeliver within minutes — Slack
119
+ * gives up after about five — and past the window the archive is not
120
+ * loaded at all, since every new message to the fresh row would pay for a
121
+ * read that can no longer find anything.
122
+ */
123
+ const ROLLOVER_REPEAT_WINDOW_MS = 60 * 60 * 1000;
124
+ /**
125
+ * The one refusal for a dispatch naming an agent other than its session's.
126
+ * Its own type so a repeat attached to a claimant can tell the claimant was
127
+ * refused for who it named — the item then still has no owner — from the
128
+ * claimant's turn failing. Internal: callers read the message.
129
+ */
130
+ class CrossIdentityError extends Error {
131
+ constructor(sessionId, owner, claimed) {
132
+ super(`Session ${sessionId} belongs to agent ${owner}, not ${claimed} — sessions never cross agent identities.`);
133
+ this.name = 'CrossIdentityError';
134
+ }
135
+ }
136
+ const crossIdentityError = (sessionId, owner, claimed) => new CrossIdentityError(sessionId, owner, claimed);
109
137
  /** Where the session's tool calls ran: `local-command`, or a contributed executor's registered name. */
110
138
  export const EXECUTOR_METADATA_KEY = 'executor';
111
139
  export const RESERVED_SESSION_METADATA_KEYS = [
@@ -191,26 +219,26 @@ export const createGateway = (options = {}) => {
191
219
  // schedule store's own connection (see its docs for why).
192
220
  const scheduleStore = new SqliteScheduleStore(fleetDbIn(stateDir), { stateHome: stateDir });
193
221
  /**
194
- * The write side of an addressable destination, through whichever
195
- * started channel serves its kind. Resolution happens per call against
196
- * the live adapter list, so a channel that failed to start is honestly
197
- * "cannot address" rather than a stale reference — and rejecting is how
198
- * both callers (schedule creation, `message.send`) hear why.
222
+ * The started adapter of one kind that speaks for an agent and has the
223
+ * capability asked for. `carriesOthers` says there were capable adapters
224
+ * of that kind, just none carrying this agent, which callers word
225
+ * differently from there being none at all.
226
+ *
227
+ * One kind may be served by several adapters carrying disjoint
228
+ * agents — two plugins, or the host's adapter beside a plugin's — and
229
+ * the one that speaks for this agent is the one whose claims include
230
+ * it: a contribution's agent list, or the host's declared claims for
231
+ * an adapter no plugin contributed. A host that declared no claims for
232
+ * the kind left its adapter unclaimed, and an unclaimed adapter speaks
233
+ * for anyone, as the single host adapter always did. A claim miss is
234
+ * a refusal, never the first adapter that happens to be running: that
235
+ * adapter posts under another agent's transport identity, and it is
236
+ * exactly what a failed start of the right adapter would leave behind.
199
237
  */
200
- const outboundFor = async (agentId, destination) => {
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.
238
+ const channelCarrying = (kind, agentId, capable) => {
239
+ const candidates = startedChannels.filter((candidate) => candidate.name === kind && capable(candidate));
212
240
  const contributed = channelContributions.list();
213
- const hostClaims = (options.hostChannelClaims ?? []).filter((claim) => claim.kind === destination.channel);
241
+ const hostClaims = (options.hostChannelClaims ?? []).filter((claim) => claim.kind === kind);
214
242
  const carries = (candidate) => {
215
243
  const entry = contributed.find((contribution) => contribution.adapter === candidate);
216
244
  if (entry) {
@@ -219,7 +247,18 @@ export const createGateway = (options = {}) => {
219
247
  return hostClaims.length === 0 || hostClaims.some((claim) => claim.agents.includes(agentId));
220
248
  };
221
249
  const adapter = candidates.find(carries);
222
- if (!adapter?.resolveOutbound && candidates.length > 0) {
250
+ return adapter ? { adapter, carriesOthers: false } : { carriesOthers: candidates.length > 0 };
251
+ };
252
+ /**
253
+ * The write side of an addressable destination, through whichever
254
+ * started channel serves its kind. Resolution happens per call against
255
+ * the live adapter list, so a channel that failed to start is honestly
256
+ * "cannot address" rather than a stale reference — and rejecting is how
257
+ * both callers (schedule creation, `message.send`) hear why.
258
+ */
259
+ const outboundFor = async (agentId, destination) => {
260
+ const { adapter, carriesOthers } = channelCarrying(destination.channel, agentId, (candidate) => candidate.resolveOutbound !== undefined);
261
+ if (!adapter?.resolveOutbound && carriesOthers) {
223
262
  throw new Error(`No running '${destination.channel}' channel carries agent ${agentId} — `
224
263
  + `the running ${destination.channel} adapters carry other agents, so a message from ${agentId} has no transport identity to post under.`);
225
264
  }
@@ -765,6 +804,7 @@ export const createGateway = (options = {}) => {
765
804
  */
766
805
  const loadSkills = async () => {
767
806
  const next = new SkillRegistry();
807
+ await registerBuiltinSkills(next, true);
768
808
  const operator = await loadOperatorSkills(env, next, warn, { strict: true });
769
809
  for (const plugin of loadedPlugins) {
770
810
  for (const record of plugin.skills) {
@@ -862,6 +902,36 @@ export const createGateway = (options = {}) => {
862
902
  }
863
903
  return source;
864
904
  };
905
+ const agentWorkspaces = createAgentWorkspaces(env);
906
+ /**
907
+ * What an agent is told about how it runs, from the source this dispatch
908
+ * refreshed and never from `sources` again. The persona the turn runs on
909
+ * came from that snapshot, and a repointed default soul can replace the
910
+ * registered source while the turn is still in preflight: read afresh,
911
+ * the agent would be told a file it is not running on is its soul.
912
+ *
913
+ * `soulReloads` is true because `refreshAgent` re-reads the file on every
914
+ * dispatch. A refresh that failed serves from cache and carries no path,
915
+ * so the agent is told nothing about a soul file for that turn rather
916
+ * than something the daemon could not just read. The built-in agent has
917
+ * no file, so it is told only where its workspace is.
918
+ *
919
+ * The language and the model come from the config this same dispatch
920
+ * resolved for that source, for the same reason: the turn runs on it.
921
+ */
922
+ const runtimeContextFor = (source, config, onFallback,
923
+ // The turn's own metadata, where a channel records who can read the
924
+ // conversation now; a recovered turn has none and passes the session's.
925
+ metadata) => {
926
+ const conversation = conversationContextFrom(metadata);
927
+ return {
928
+ ...(source.soulPath !== undefined ? { soulPath: source.soulPath, soulReloads: true } : {}),
929
+ workspace: agentWorkspaces.forAgent(source.definition.id),
930
+ ...(config.language !== undefined ? { language: config.language } : {}),
931
+ model: describeServingModel(config, onFallback),
932
+ ...(conversation !== undefined ? { conversation } : {}),
933
+ };
934
+ };
865
935
  // ---- runner pool --------------------------------------------------------
866
936
  const tools = new ToolRegistry();
867
937
  // Which package each tool came from. Kernel tools are absent from this
@@ -883,6 +953,23 @@ export const createGateway = (options = {}) => {
883
953
  let pluginFailures = [];
884
954
  const skillCatalog = new SkillRegistry();
885
955
  let operatorSkills = [];
956
+ /**
957
+ * The skills that ship with Stratus, first into every catalog, so the id
958
+ * is theirs before an operator directory or a plugin can claim it. A file
959
+ * missing from a broken install is said once and costs the agents their
960
+ * reference, never the daemon its start.
961
+ */
962
+ const registerBuiltinSkills = async (registry, reloading = false) => {
963
+ try {
964
+ registry.register(await loadStratusSkill());
965
+ }
966
+ catch (error) {
967
+ // On a reload the swap keeps the copy already serving
968
+ // (`SkillRegistry.replaceWith`), so only a start goes without it.
969
+ const consequence = reloading ? 'the copy loaded at start keeps serving' : 'agents will not have it';
970
+ warn(`the built-in stratus skill did not load (${error instanceof Error ? error.message : String(error)}); ${consequence}. Reinstall Stratus.`);
971
+ }
972
+ };
886
973
  tools.register(createDemoTool());
887
974
  tools.register(createRememberTool(memory));
888
975
  tools.register(createRecallTool(memory));
@@ -910,6 +997,259 @@ export const createGateway = (options = {}) => {
910
997
  const connection = await outboundFor(agentId, destination);
911
998
  await connection.post(text);
912
999
  }));
1000
+ /**
1001
+ * Credential requests waiting on a person, by request id.
1002
+ *
1003
+ * In memory on purpose: a request is a question somebody is being asked
1004
+ * now, and a restart that forgot it only means the agent asks again. What
1005
+ * matters is that the entry carries everything the answer needs, so a
1006
+ * channel quoting the id back decides nothing about where the key goes.
1007
+ */
1008
+ const credentialRequests = new Map();
1009
+ tools.register(createCredentialRequestTool(async (request, session) => {
1010
+ const agentId = session.agent.id;
1011
+ const soulPath = sources.get(agentId)?.soulPath;
1012
+ if (soulPath === undefined) {
1013
+ throw new Error(`${session.agent.name} is the built-in agent and has no soul file to grant a credential in. `
1014
+ + `Your operator can store one on the machine with \`stratus credential set ${request.name}\`.`);
1015
+ }
1016
+ if (!CREDENTIAL_NAME_PATTERN.test(request.name)) {
1017
+ throw new Error(`${JSON.stringify(request.name)} is not a credential name. Use letters, digits, dots, dashes, or underscores, `
1018
+ + 'starting with a letter, the way the tool that needs it spells it: search.apiKey, github.token.');
1019
+ }
1020
+ // A key already stored is not something to ask a person for again: the
1021
+ // form only adds, so it could only fail. Say what is actually missing.
1022
+ const source = await namedCredentialSource(env, agentId, request.name);
1023
+ const stored = source !== undefined;
1024
+ const granted = session.agent.credentials?.includes(request.name) === true;
1025
+ if (stored && granted) {
1026
+ throw new Error(`You already hold ${request.name}; the tools that need it use it for you. There is nothing to ask for.`);
1027
+ }
1028
+ if (stored) {
1029
+ throw new Error(`${request.name} is already ${source === 'environment' ? "supplied by the daemon's environment" : 'stored'} but not granted to you. `
1030
+ + 'Ask your operator to add it to the credentials list in your soul; a form would only refuse to store it again.');
1031
+ }
1032
+ const onMachine = `\`stratus credential set ${request.name}${request.scope === 'agent' ? ` --agent ${quoteShellArg(agentId)}` : ''}\``;
1033
+ const record = { agentId, sessionId: session.id, name: request.name, scope: request.scope, soulPath };
1034
+ const announce = async (requestId, via) => {
1035
+ await bus.emit({
1036
+ type: 'credential.requested',
1037
+ sessionId: session.id,
1038
+ agentId,
1039
+ requestId,
1040
+ name: request.name,
1041
+ scope: request.scope,
1042
+ ...(request.reason !== undefined ? { reason: request.reason } : {}),
1043
+ via,
1044
+ ...(session.metadata ? { metadata: session.metadata } : {}),
1045
+ });
1046
+ };
1047
+ // Why no form went up, said to the agent beside the link that replaces
1048
+ // it, so it can tell the person why they are getting a link instead.
1049
+ let formUnavailable;
1050
+ if (request.via !== 'link') {
1051
+ // Only a conversation a channel started has anywhere to show a form,
1052
+ // and only a channel that renders one can show it: a scheduled or
1053
+ // HTTP turn, or a channel with no form, would record a request nobody
1054
+ // sees while the agent tells its user the operator was asked.
1055
+ const kind = session.metadata?.channel;
1056
+ const { adapter } = typeof kind === 'string'
1057
+ ? channelCarrying(kind, agentId, (candidate) => candidate.requestCredential !== undefined)
1058
+ : { adapter: undefined };
1059
+ if (!adapter?.requestCredential || !session.metadata) {
1060
+ formUnavailable = typeof kind === 'string'
1061
+ ? `This conversation's channel (${kind}) cannot show your operator a credential form here.`
1062
+ : 'This conversation is not in a channel that can show your operator a form.';
1063
+ }
1064
+ else {
1065
+ const requestId = randomUUID();
1066
+ credentialRequests.set(requestId, record);
1067
+ // Delivered before it is announced, and dropped if delivery fails: a
1068
+ // request whose post never landed has no button to answer it, so
1069
+ // keeping it would leave it pending for good behind a tool result
1070
+ // saying the operator was asked.
1071
+ try {
1072
+ await adapter.requestCredential({
1073
+ sessionId: session.id,
1074
+ agentId,
1075
+ requestId,
1076
+ name: request.name,
1077
+ scope: request.scope,
1078
+ ...(request.reason !== undefined ? { reason: request.reason } : {}),
1079
+ metadata: session.metadata,
1080
+ });
1081
+ await announce(requestId, 'form');
1082
+ return { requestId, via: 'form' };
1083
+ }
1084
+ catch (error) {
1085
+ credentialRequests.delete(requestId);
1086
+ formUnavailable = `The form could not be shown here: ${error instanceof Error ? error.message : String(error)}`;
1087
+ }
1088
+ }
1089
+ if (request.via === 'form') {
1090
+ throw new Error(`${formUnavailable} Nothing is pending. Ask for a link instead (via: "link"), or ask your operator to store ${request.name} on the machine with ${onMachine} and grant it to you.`);
1091
+ }
1092
+ }
1093
+ // A link where no form can go, from whichever adapter issues them: the
1094
+ // control API, which is not tied to any one agent's transport.
1095
+ const linker = startedChannels.find((candidate) => candidate.requestCredentialLink !== undefined);
1096
+ if (!linker?.requestCredentialLink) {
1097
+ throw new Error(`${formUnavailable !== undefined ? `${formUnavailable} ` : ''}No control API is running to issue a link either. `
1098
+ + `Ask your operator to store ${request.name} on the machine with ${onMachine} and grant it to you.`);
1099
+ }
1100
+ const requestId = randomUUID();
1101
+ credentialRequests.set(requestId, record);
1102
+ let link;
1103
+ try {
1104
+ link = await linker.requestCredentialLink({
1105
+ sessionId: session.id,
1106
+ agentId,
1107
+ agentName: session.agent.name,
1108
+ requestId,
1109
+ name: request.name,
1110
+ scope: request.scope,
1111
+ ...(request.reason !== undefined ? { reason: request.reason } : {}),
1112
+ });
1113
+ }
1114
+ catch (error) {
1115
+ credentialRequests.delete(requestId);
1116
+ throw new Error(`A link for ${request.name} could not be issued: ${error instanceof Error ? error.message : String(error)} `
1117
+ + `Nothing is pending. Ask your operator to store it on the machine with ${onMachine}.`);
1118
+ }
1119
+ await announce(requestId, 'link');
1120
+ return {
1121
+ requestId,
1122
+ via: 'link',
1123
+ url: link.url,
1124
+ expiresAt: link.expiresAt,
1125
+ ...(link.localOnly === true ? { localOnly: true } : {}),
1126
+ ...(formUnavailable !== undefined ? { formUnavailable } : {}),
1127
+ };
1128
+ }));
1129
+ /**
1130
+ * Requests an answer is being stored for now. Claimed before the first
1131
+ * await: two approvers submitting one form at once would otherwise both
1132
+ * start storing, and the second's add-only conflict would retire a
1133
+ * request the first was about to settle as added.
1134
+ */
1135
+ const answering = new Set();
1136
+ const provideCredential = async (input) => {
1137
+ const request = credentialRequests.get(input.requestId);
1138
+ if (!request) {
1139
+ return { ok: false, retired: true, message: 'That credential request is no longer pending. Ask the agent to request it again.' };
1140
+ }
1141
+ if (answering.has(input.requestId)) {
1142
+ return { ok: false, message: 'Someone else\'s answer to this request is being stored right now. Wait a moment before trying again.' };
1143
+ }
1144
+ answering.add(input.requestId);
1145
+ try {
1146
+ return await answerCredentialRequest(input, request);
1147
+ }
1148
+ finally {
1149
+ answering.delete(input.requestId);
1150
+ }
1151
+ };
1152
+ const answerCredentialRequest = async (input, request) => {
1153
+ // The agent must still be served from the file the request captured: a
1154
+ // repointed default soul (same id, another file) replaces the source,
1155
+ // and a grant landing in the old file would report a key the agent now
1156
+ // running cannot use.
1157
+ const served = sources.get(request.agentId)?.soulPath;
1158
+ if (served !== request.soulPath) {
1159
+ credentialRequests.delete(input.requestId);
1160
+ return {
1161
+ ok: false,
1162
+ retired: true,
1163
+ message: `${request.agentId} is no longer served from ${request.soulPath}, so nothing was stored. Ask ${request.agentId} to request it again.`,
1164
+ };
1165
+ }
1166
+ // The soul the grant will land in must still be the requester's, and is
1167
+ // checked before anything is stored: a file reassigned to another agent
1168
+ // while the request waited would otherwise receive a key nobody meant
1169
+ // to give it. `grantSoulCredential` checks again as it writes.
1170
+ let owner;
1171
+ try {
1172
+ owner = (await loadSoulFile(request.soulPath)).agent.id;
1173
+ }
1174
+ catch (error) {
1175
+ return {
1176
+ ok: false,
1177
+ message: `${request.agentId}'s soul could not be read (${error instanceof Error ? error.message : String(error)}), so nothing was stored.`,
1178
+ };
1179
+ }
1180
+ if (owner !== request.agentId) {
1181
+ credentialRequests.delete(input.requestId);
1182
+ return {
1183
+ ok: false,
1184
+ retired: true,
1185
+ message: `${request.soulPath} now belongs to ${owner}, not ${request.agentId}, so nothing was stored. Ask ${request.agentId} to request it again.`,
1186
+ };
1187
+ }
1188
+ try {
1189
+ await addNamedCredential(env, {
1190
+ name: request.name,
1191
+ value: input.value,
1192
+ ...(request.scope === 'agent' ? { agentId: request.agentId } : {}),
1193
+ });
1194
+ }
1195
+ catch (error) {
1196
+ // A name stored since the request was made (another form for the
1197
+ // same shared key, or the machine) is final under add-only: no value
1198
+ // typed into this form can ever land, so the request is retired
1199
+ // rather than left answering every submission with the same refusal.
1200
+ if (error instanceof NamedCredentialExistsError) {
1201
+ credentialRequests.delete(input.requestId);
1202
+ return { ok: false, retired: true, message: error.message };
1203
+ }
1204
+ // Otherwise refused, not failed: the request stays pending, so a
1205
+ // person who typed an empty value can try again from the same form.
1206
+ return { ok: false, message: error instanceof Error ? error.message : String(error) };
1207
+ }
1208
+ credentialRequests.delete(input.requestId);
1209
+ // Stored first, granted second: a key nobody may use is recoverable by
1210
+ // editing the soul, while a soul naming a key that was never stored
1211
+ // fails every call that reaches for it. Announced either way, because
1212
+ // the store already changed and a shared key is already usable.
1213
+ //
1214
+ // The served path is checked again under the soul lock, which roster
1215
+ // reloads take too: a default repointed while the key was being
1216
+ // written, or while the grant queued behind another soul edit, would
1217
+ // otherwise be granted in a file the running agent no longer reads.
1218
+ let granted = false;
1219
+ let grantError;
1220
+ try {
1221
+ granted = await grantSoulCredential(request.soulPath, request.name, request.agentId, {
1222
+ check: () => {
1223
+ const servedNow = sources.get(request.agentId)?.soulPath;
1224
+ if (servedNow !== request.soulPath) {
1225
+ throw new Error(`${request.agentId} is now served from ${servedNow ?? 'no soul file'}, not ${request.soulPath}`);
1226
+ }
1227
+ },
1228
+ });
1229
+ }
1230
+ catch (error) {
1231
+ grantError = error instanceof Error ? error.message : String(error);
1232
+ }
1233
+ await bus.emit({
1234
+ type: 'credential.provided',
1235
+ sessionId: request.sessionId,
1236
+ agentId: request.agentId,
1237
+ requestId: input.requestId,
1238
+ name: request.name,
1239
+ scope: request.scope,
1240
+ ...(input.actor !== undefined ? { actor: input.actor } : {}),
1241
+ ...(grantError !== undefined ? { grantError } : {}),
1242
+ });
1243
+ if (grantError !== undefined) {
1244
+ return {
1245
+ ok: false,
1246
+ retired: true,
1247
+ message: `${request.name} was stored, but could not be added to ${request.soulPath} `
1248
+ + `(${grantError}). Add it to the soul's credentials list by hand.`,
1249
+ };
1250
+ }
1251
+ return { ok: true, name: request.name, scope: request.scope, agentId: request.agentId, granted };
1252
+ };
913
1253
  tools.register(createDelegateTool({
914
1254
  registry,
915
1255
  dispatch: (input) => dispatchInternal({
@@ -1095,6 +1435,16 @@ export const createGateway = (options = {}) => {
1095
1435
  // Set at a text-only provider.response: the turn is wrapping up
1096
1436
  // (saves, completion events); nothing left for the timer to guard.
1097
1437
  let turnSettling = false;
1438
+ /**
1439
+ * What the watchdog last heard, for the warning it logs when it fires.
1440
+ *
1441
+ * "No activity for 120000ms" alone could not tell a provider that went
1442
+ * quiet mid-stream from one whose progress never reached the bus at
1443
+ * all, and that is the first question every abort raised. Event and
1444
+ * delta types only, never content: this lands in the daemon log.
1445
+ */
1446
+ let lastHeard = 'nothing since the turn began';
1447
+ let deltasHeard = 0;
1098
1448
  const suspendTimer = () => {
1099
1449
  if (timer) {
1100
1450
  clearTimeout(timer);
@@ -1109,7 +1459,8 @@ export const createGateway = (options = {}) => {
1109
1459
  // Deliberately not unref'd: while a provider await is in flight, this
1110
1460
  // timer is what guarantees the process can always make progress on it.
1111
1461
  timer = setTimeout(() => {
1112
- warn(`watchdog: no activity on session ${sessionId} for ${effectiveIdleMs}ms; aborting the turn`);
1462
+ warn(`watchdog: no activity on session ${sessionId} for ${effectiveIdleMs}ms; aborting the turn `
1463
+ + `(last heard: ${lastHeard}; ${deltasHeard} provider deltas this turn)`);
1113
1464
  // The reason rides on the signal, because the runner is what fails
1114
1465
  // the session: it writes `lastError` and emits `session.failed`
1115
1466
  // before this wrapper ever sees the rejection. Rethrowing a better
@@ -1132,6 +1483,13 @@ export const createGateway = (options = {}) => {
1132
1483
  if (!('sessionId' in event) || event.sessionId !== sessionId) {
1133
1484
  return;
1134
1485
  }
1486
+ if (event.type === 'provider.delta') {
1487
+ deltasHeard += 1;
1488
+ lastHeard = `provider.delta ${event.delta.type}`;
1489
+ }
1490
+ else {
1491
+ lastHeard = event.type;
1492
+ }
1135
1493
  switch (event.type) {
1136
1494
  case 'provider.delta':
1137
1495
  // Only a *fallback* reset marks the mid-turn, session-sticky
@@ -1254,6 +1612,22 @@ export const createGateway = (options = {}) => {
1254
1612
  * session sufficient.
1255
1613
  */
1256
1614
  const activeTurns = new Map();
1615
+ /**
1616
+ * Keyed dispatches not yet settled, by session and key — what a repeat
1617
+ * attaches to. In memory on purpose: it only has to cover the turns this
1618
+ * process is running, and the transcript covers the rest.
1619
+ */
1620
+ // Nested rather than one joined string: both halves are the caller's, and
1621
+ // no separator is one a caller cannot also put inside a key. The agent
1622
+ // the dispatch named rides along, so a repeat naming another is not
1623
+ // handed a session that is not its agent's.
1624
+ // `confirmed` once the chain has seen the dispatch past the identity
1625
+ // check with a key the session had not seen (`claimLiveItem`); until
1626
+ // then the entry is a claim that may yet be refused. `addressed` is false
1627
+ // for a turn nobody asked for, which `holdsMessage` does not count.
1628
+ const liveWorkItems = new Map();
1629
+ /** The start-up snapshot `recoverParkedTurns` judges orphans from, for a repeat that gets there first. */
1630
+ let orphanedAtStart = new Set();
1257
1631
  /**
1258
1632
  * The runtime one agent would run on right now — config snapshot, soul
1259
1633
  * pins, degradation and all.
@@ -1324,6 +1698,33 @@ export const createGateway = (options = {}) => {
1324
1698
  // failure from here is a credential or provider problem and surfaces.
1325
1699
  return resolveRuntimeConfig(selection, resolveEnv);
1326
1700
  };
1701
+ /**
1702
+ * Whether the turn about to run streams deltas, which arms the idle
1703
+ * watchdog — on the sticky fallback when the session already switched to
1704
+ * it, since that is the provider the turn will be served by.
1705
+ */
1706
+ const watchdogStreamsFor = (session, config) => {
1707
+ const switchedToFallback = session?.metadata?.[FALLBACK_ACTIVE_METADATA_KEY] === true;
1708
+ const fallbackStreams = config.provider !== 'demo' && config.fallback
1709
+ ? fallbackStreamsDeltas(config.fallback)
1710
+ : false;
1711
+ const effectiveStreams = switchedToFallback && config.provider !== 'demo' && config.fallback
1712
+ ? fallbackStreamsDeltas(config.fallback)
1713
+ : streamsDeltas(config);
1714
+ return { switchedToFallback, effectiveStreams, fallbackStreams };
1715
+ };
1716
+ /**
1717
+ * Whether a turn on this config could be served by a harness
1718
+ * (`runsOnHarness`): the primary or, since a turn can switch mid-run, the
1719
+ * fallback. Once a session has switched for good the primary is never
1720
+ * called again, and only the fallback counts.
1721
+ */
1722
+ const reachesHarness = (config, switchedToFallback) => {
1723
+ const fallback = config.provider !== 'demo' ? config.fallback : undefined;
1724
+ return switchedToFallback && fallback !== undefined
1725
+ ? runsOnHarness(fallback)
1726
+ : runsOnHarness(config) || (fallback !== undefined && runsOnHarness(fallback));
1727
+ };
1327
1728
  const dispatchInternal = async (input) => {
1328
1729
  // Re-read on every turn, not once at start: a newer build that stamps
1329
1730
  // the home while this daemon is serving has formats this build does not
@@ -1374,15 +1775,15 @@ export const createGateway = (options = {}) => {
1374
1775
  [EXECUTOR_METADATA_KEY]: executorRecord,
1375
1776
  };
1376
1777
  if (existing && existing.agent.id !== agent.id) {
1377
- throw new Error(`Session ${input.sessionId} belongs to agent ${existing.agent.id}, not ${agent.id} — sessions never cross agent identities.`);
1378
- }
1379
- const switchedToFallback = existing?.metadata?.[FALLBACK_ACTIVE_METADATA_KEY] === true;
1380
- const fallbackStreams = config.provider !== 'demo' && config.fallback
1381
- ? fallbackStreamsDeltas(config.fallback)
1382
- : false;
1383
- const effectiveStreams = switchedToFallback && config.provider !== 'demo' && config.fallback
1384
- ? fallbackStreamsDeltas(config.fallback)
1385
- : streamsDeltas(config);
1778
+ throw crossIdentityError(input.sessionId, existing.agent.id, agent.id);
1779
+ }
1780
+ const { switchedToFallback, effectiveStreams, fallbackStreams } = watchdogStreamsFor(existing, config);
1781
+ // Recorded with a keyed message so a restart judges the turn by what it
1782
+ // could have run on, not by whatever the config resolves to by then.
1783
+ // The fallback counts: a turn can switch to it mid-run. Once a session
1784
+ // has switched for good, the primary is never called again, and only
1785
+ // the fallback counts.
1786
+ const hostedLoop = input.idempotencyKey !== undefined && reachesHarness(config, switchedToFallback);
1386
1787
  return withWatchdog(input.sessionId, input.signal, effectiveStreams, fallbackStreams, async (signal) => {
1387
1788
  // The preflight above (agent refresh, config resolution, session
1388
1789
  // load) awaited filesystem work; the entry check has long passed.
@@ -1410,6 +1811,19 @@ export const createGateway = (options = {}) => {
1410
1811
  if (existing.metadata?.[EXECUTOR_METADATA_KEY] !== executorRecord) {
1411
1812
  existing.metadata = { ...existing.metadata, [EXECUTOR_METADATA_KEY]: executorRecord };
1412
1813
  }
1814
+ // The room rides on the turn, but two readers have only the
1815
+ // session's: a turn parked on approval and recovered after a
1816
+ // restart, and a turn that brings no room of its own (a schedule
1817
+ // firing into the thread). Both get the latest room this way, not
1818
+ // the first turn's, which a channel made public since would make
1819
+ // read as more private than it is. A turn from a channel that
1820
+ // could not tell the room (its lookup failed) clears it instead:
1821
+ // no room is the generic line, and an old one is a claim.
1822
+ if (typeof input.metadata?.channel === 'string') {
1823
+ const room = input.metadata[CONVERSATION_METADATA_KEY];
1824
+ const { [CONVERSATION_METADATA_KEY]: _stale, ...rest } = existing.metadata ?? {};
1825
+ existing.metadata = room !== undefined ? { ...rest, [CONVERSATION_METADATA_KEY]: room } : rest;
1826
+ }
1413
1827
  await store.save(existing);
1414
1828
  // The turn's metadata rides along for the sender's trust: without
1415
1829
  // it a resumed turn has no way to say who sent it, and a stranger
@@ -1420,7 +1834,10 @@ export const createGateway = (options = {}) => {
1420
1834
  userMessage: input.userMessage,
1421
1835
  ...(input.images !== undefined ? { images: input.images } : {}),
1422
1836
  ...(input.addressed !== undefined ? { addressed: input.addressed } : {}),
1837
+ ...(input.idempotencyKey !== undefined ? { idempotencyKey: input.idempotencyKey } : {}),
1838
+ ...(hostedLoop ? { hostedLoop } : {}),
1423
1839
  ...(input.metadata ? { metadata: input.metadata } : {}),
1840
+ runtime: runtimeContextFor(source, config, switchedToFallback, input.metadata),
1424
1841
  signal,
1425
1842
  });
1426
1843
  }
@@ -1430,7 +1847,10 @@ export const createGateway = (options = {}) => {
1430
1847
  userMessage: input.userMessage,
1431
1848
  ...(input.images !== undefined ? { images: input.images } : {}),
1432
1849
  ...(input.addressed !== undefined ? { addressed: input.addressed } : {}),
1850
+ ...(input.idempotencyKey !== undefined ? { idempotencyKey: input.idempotencyKey } : {}),
1851
+ ...(hostedLoop ? { hostedLoop } : {}),
1433
1852
  metadata,
1853
+ runtime: runtimeContextFor(source, config, switchedToFallback, input.metadata),
1434
1854
  signal,
1435
1855
  });
1436
1856
  });
@@ -1512,7 +1932,13 @@ export const createGateway = (options = {}) => {
1512
1932
  }
1513
1933
  return new Set([...claimants.values()].filter((ids) => ids.length === 1).map(([id]) => id));
1514
1934
  };
1515
- const recoverOne = (sessionId, orphaned) => onSessionChain(sessionId, async () => {
1935
+ const recoverOne = (sessionId, orphaned) => onSessionChain(sessionId, () => recoverParked(sessionId, orphaned));
1936
+ /**
1937
+ * `recoverOne`'s work, for a caller already on the session's chain: a
1938
+ * repeated idempotency key that reached the chain before the parked sweep
1939
+ * did, and would otherwise wait on a turn nothing is running yet.
1940
+ */
1941
+ const recoverParked = async (sessionId, orphaned) => {
1516
1942
  if (stopping) {
1517
1943
  return;
1518
1944
  }
@@ -1571,7 +1997,8 @@ export const createGateway = (options = {}) => {
1571
1997
  // resuming.
1572
1998
  session.agent = source.definition;
1573
1999
  await store.save(session);
1574
- const runner = runnerFor(await runtimeForAgent(source));
2000
+ const recoveredConfig = await runtimeForAgent(source);
2001
+ const runner = runnerFor(recoveredConfig);
1575
2002
  // Tracked like a dispatched turn's controller: a recovered turn
1576
2003
  // that runs on past its approval is a turn like any other, and a
1577
2004
  // restart's window has to be able to cut it short with the same
@@ -1583,7 +2010,11 @@ export const createGateway = (options = {}) => {
1583
2010
  controller.abort(new RunAbortedError(RESTARTING_TURN_ERROR));
1584
2011
  }
1585
2012
  try {
1586
- await runner.recoverPendingApproval(sessionId, { denyPending: expired, signal: controller.signal });
2013
+ await runner.recoverPendingApproval(sessionId, {
2014
+ denyPending: expired,
2015
+ runtime: runtimeContextFor(source, recoveredConfig, session.metadata?.[FALLBACK_ACTIVE_METADATA_KEY] === true, session.metadata),
2016
+ signal: controller.signal,
2017
+ });
1587
2018
  }
1588
2019
  finally {
1589
2020
  turnControllers.delete(controller);
@@ -1600,22 +2031,31 @@ export const createGateway = (options = {}) => {
1600
2031
  }
1601
2032
  catch (error) {
1602
2033
  warn(`could not recover parked session ${sessionId}: ${error instanceof Error ? error.message : String(error)}`);
2034
+ // A keyed item is a promise to its caller, and one whose recovery
2035
+ // cannot even start (its agent gone, its provider unresolvable) would
2036
+ // otherwise stay parked for every later start and redelivery to try
2037
+ // again. An unkeyed one keeps waiting for a sweep that can, as before.
2038
+ const after = await store.get(sessionId).catch(() => undefined);
2039
+ const key = after?.messages.findLast((candidate) => candidate.role === 'user')?.idempotencyKey;
2040
+ if (after !== undefined && key !== undefined && after.status === 'pending_approval' && workItemState(after, key) === 'unfinished') {
2041
+ await failParked(after, ABANDONED_TURN_ERROR).catch(() => { });
2042
+ }
1603
2043
  }
1604
- });
2044
+ };
1605
2045
  /**
1606
2046
  * Closes an orphaned sub-session the way the abandoned sweep closes its
1607
2047
  * parent: durably failed, with the checkpoint retired so no later sweep
1608
2048
  * can find a call to re-enter, and announced where surfaces can hear it.
1609
2049
  */
1610
- const failOrphanedDelegation = async (session) => {
2050
+ const failOrphanedDelegation = (session) => failParked(session, ORPHANED_DELEGATION_ERROR);
2051
+ const failParked = async (session, error) => {
1611
2052
  const metadata = { ...(session.metadata ?? {}) };
1612
2053
  delete metadata[PENDING_APPROVAL_METADATA_KEY];
1613
2054
  session.metadata = metadata;
1614
- session.status = 'failed';
1615
- session.lastError = ORPHANED_DELEGATION_ERROR;
2055
+ recordTurnFailure(session, error);
1616
2056
  await store.save(session);
1617
2057
  await bus.emit({ type: 'session.updated', sessionId: session.id, status: 'failed' });
1618
- await bus.emit({ type: 'session.failed', sessionId: session.id, error: ORPHANED_DELEGATION_ERROR });
2058
+ await bus.emit({ type: 'session.failed', sessionId: session.id, error });
1619
2059
  };
1620
2060
  /**
1621
2061
  * Turns the last process was still running when it stopped being able to
@@ -1627,9 +2067,12 @@ export const createGateway = (options = {}) => {
1627
2067
  * hosts its own loop — the approval wait too, since a hosted call is
1628
2068
  * deliberately not checkpointed (`executeHostedToolCall` passes
1629
2069
  * `recoverable: false`, because the SDK's inner loop is not something a
1630
- * restart can rebuild). None of those can be resumed, and nothing else
1631
- * sweeps them, so the record goes on claiming the turn is running for as
1632
- * long as the session exists.
2070
+ * restart can rebuild). None of those can be re-entered where they
2071
+ * stopped, and nothing else sweeps them, so the record would go on
2072
+ * claiming the turn is running for as long as the session exists. A turn
2073
+ * whose caller gave it an idempotency key is continued from its
2074
+ * transcript instead, with its dangling calls closed as interrupted
2075
+ * (`settleAbandonedTurn`); the rest are failed.
1633
2076
  *
1634
2077
  * Note what this is *not* for. A graceful stop denies everything parked
1635
2078
  * and drains the turns those denials release, so an operator restarting
@@ -1643,7 +2086,8 @@ export const createGateway = (options = {}) => {
1643
2086
  return store.listIdsByStatus('running');
1644
2087
  };
1645
2088
  /**
1646
- * Marks each of them failed, with a reason that says what happened.
2089
+ * Settles each of them — continued if keyed, otherwise marked failed with
2090
+ * a reason that says what happened.
1647
2091
  *
1648
2092
  * Read BEFORE the channels start and applied after, which is the only
1649
2093
  * ordering that is both honest and safe. Reading first means the list is
@@ -1671,7 +2115,7 @@ export const createGateway = (options = {}) => {
1671
2115
  if (abandoned.length === 0) {
1672
2116
  return;
1673
2117
  }
1674
- log(`failing ${abandoned.length} turn(s) left running by the last stratusd`);
2118
+ log(`settling ${abandoned.length} turn(s) left running by the last stratusd`);
1675
2119
  await Promise.allSettled(abandoned.map((id) => failAbandonedTurn(id)));
1676
2120
  };
1677
2121
  const failAbandonedTurn = (id) => onSessionChain(id, async () => {
@@ -1686,16 +2130,200 @@ export const createGateway = (options = {}) => {
1686
2130
  if (!session || session.status !== 'running') {
1687
2131
  return;
1688
2132
  }
1689
- session.status = 'failed';
1690
- session.lastError = ABANDONED_TURN_ERROR;
1691
- await store.save(session);
1692
- await bus.emit({ type: 'session.updated', sessionId: id, status: 'failed' });
1693
- await bus.emit({ type: 'session.failed', sessionId: id, error: ABANDONED_TURN_ERROR });
2133
+ await settleAbandonedTurn(session);
1694
2134
  }
1695
2135
  catch (error) {
1696
- warn(`could not fail abandoned turn ${id}: ${error instanceof Error ? error.message : String(error)}`);
2136
+ warn(`could not settle abandoned turn ${id}: ${error instanceof Error ? error.message : String(error)}`);
1697
2137
  }
1698
2138
  });
2139
+ /**
2140
+ * A turn left `running` with nothing running it, read on its chain.
2141
+ *
2142
+ * One whose message carries an idempotency key is a work item its caller
2143
+ * was promised would finish, so it is continued from the transcript
2144
+ * (`AgentRunner.continueTurn`) — once. A turn that already was, and is
2145
+ * here again, took the last process down with it, and continuing it again
2146
+ * would do the same on every start; it is failed like an unkeyed one,
2147
+ * whose caller was never promised anything and is told to send again.
2148
+ */
2149
+ const settleAbandonedTurn = async (session) => {
2150
+ const message = session.messages.findLast((candidate) => candidate.role === 'user');
2151
+ if (message?.idempotencyKey === undefined) {
2152
+ return failAbandoned(session);
2153
+ }
2154
+ // An answer already in the transcript is completed whatever came before
2155
+ // — a second crash, a harness, an agent or provider gone since —
2156
+ // because completing it needs none of them and runs nothing again;
2157
+ // failing it would throw away a finished reply.
2158
+ if (lastResponseIsAnswer(session)) {
2159
+ log(`${session.id}: a keyed turn the last stratusd was running had saved its answer; completing it`);
2160
+ return completeAnsweredTurn(session, store, bus);
2161
+ }
2162
+ if (message.continuedAfterCrash === true) {
2163
+ return failAbandoned(session);
2164
+ }
2165
+ if (message.hostedLoop === true) {
2166
+ log(`${session.id}: a keyed turn the last stratusd was running could reach a harness, which cannot be continued; failing it`);
2167
+ return failAbandoned(session);
2168
+ }
2169
+ return continueAbandonedTurn(session);
2170
+ };
2171
+ const failAbandoned = async (session) => {
2172
+ recordTurnFailure(session, ABANDONED_TURN_ERROR);
2173
+ await store.save(session);
2174
+ await bus.emit({ type: 'session.updated', sessionId: session.id, status: 'failed' });
2175
+ await bus.emit({ type: 'session.failed', sessionId: session.id, error: ABANDONED_TURN_ERROR });
2176
+ return session;
2177
+ };
2178
+ /**
2179
+ * Continues an abandoned turn on the configuration a dispatch for its
2180
+ * agent would resolve now, and under the same watchdog — it is a provider
2181
+ * call like any turn's. The definition is refreshed and saved first, as
2182
+ * `recoverOne` does and for the same reason: an allowlist is a permission
2183
+ * boundary, and a tool dropped while the daemon was down must not run.
2184
+ *
2185
+ * Not on a harness (`runsOnHarness`) — neither one the turn could have
2186
+ * run on (`Message.hostedLoop`, recorded when it was accepted) nor one it
2187
+ * would continue on. A harness runs its own tool loop inside a single
2188
+ * provider call and keeps its own conversation, so the prompt the dead
2189
+ * turn sent may already have run tools there that the transcript never
2190
+ * saw, and continuing would send it again. That turn is failed as an
2191
+ * unkeyed one is — the same answer as before keys existed. (A turn whose
2192
+ * answer is already saved never gets here: `settleAbandonedTurn`
2193
+ * completes it first.)
2194
+ */
2195
+ const continueAbandonedTurn = async (session) => {
2196
+ // Settled either way: a turn whose agent left the roster, or whose
2197
+ // provider no longer resolves, must not stay `running` for every later
2198
+ // start and every redelivery to find unfinished and trip over again.
2199
+ let source;
2200
+ let config;
2201
+ try {
2202
+ source = await refreshAgent(session.agent.id);
2203
+ config = await runtimeForAgent(source);
2204
+ }
2205
+ catch (error) {
2206
+ log(`${session.id}: a keyed turn the last stratusd was running cannot be continued (${error instanceof Error ? error.message : String(error)}); failing it`);
2207
+ return failAbandoned(session);
2208
+ }
2209
+ const { switchedToFallback, effectiveStreams, fallbackStreams } = watchdogStreamsFor(session, config);
2210
+ // The message's own record covers the runtimes the turn could have run
2211
+ // on; this covers the ones it would continue on — a fallback included —
2212
+ // which a changed config may make a harness.
2213
+ if (reachesHarness(config, switchedToFallback)) {
2214
+ log(`${session.id}: a keyed turn the last stratusd was running would continue on a harness; failing it`);
2215
+ return failAbandoned(session);
2216
+ }
2217
+ log(`${session.id}: continuing a keyed turn the last stratusd was still running`);
2218
+ session.agent = source.definition;
2219
+ await store.save(session);
2220
+ const runner = runnerFor(config);
2221
+ return withWatchdog(session.id, undefined, effectiveStreams, fallbackStreams, async (signal) => await runner.continueTurn(session.id, {
2222
+ runtime: runtimeContextFor(source, config, switchedToFallback, session.metadata),
2223
+ signal,
2224
+ }) ?? session);
2225
+ };
2226
+ /**
2227
+ * A repeated idempotency key that reached the session's chain, so nothing
2228
+ * else is running on the session. `undefined` means the key is new here
2229
+ * and the dispatch runs as any other; otherwise this is the session to
2230
+ * resolve with, and no new turn starts.
2231
+ *
2232
+ * An unfinished item here has no live turn — the live map would have
2233
+ * answered first — so it is one the last process left, reached before
2234
+ * the start-up sweep that would have settled it: settled now, the way
2235
+ * that sweep would, which then finds it no longer needs anything.
2236
+ */
2237
+ const settleRepeat = async (sessionId, idempotencyKey, agentId, turnId, onRepeat) => {
2238
+ const session = await store.get(sessionId);
2239
+ if (session === undefined) {
2240
+ return undefined;
2241
+ }
2242
+ // Before anything is returned: the turn a dispatch would run is refused
2243
+ // for naming another agent, and the one it would repeat is no less that
2244
+ // agent's — a repeat is not a way to read its transcript.
2245
+ if (agentId !== undefined && session.agent.id !== agentId) {
2246
+ throw crossIdentityError(sessionId, session.agent.id, agentId);
2247
+ }
2248
+ const state = workItemState(session, idempotencyKey);
2249
+ if (state === undefined) {
2250
+ const archived = await archivedRepeat(session, idempotencyKey);
2251
+ if (archived !== undefined) {
2252
+ onRepeat?.('finished');
2253
+ }
2254
+ return archived;
2255
+ }
2256
+ if (state === 'finished') {
2257
+ onRepeat?.('finished');
2258
+ return session;
2259
+ }
2260
+ // Run now, on this caller's behalf, so its events are this caller's
2261
+ // turn — the adapter waiting on them has no other way to claim them.
2262
+ if (turnId !== undefined) {
2263
+ activeTurns.set(sessionId, turnId);
2264
+ }
2265
+ try {
2266
+ return await settleUnfinished(session);
2267
+ }
2268
+ finally {
2269
+ if (turnId !== undefined) {
2270
+ activeTurns.delete(sessionId);
2271
+ }
2272
+ }
2273
+ };
2274
+ /**
2275
+ * The archived transcript holding a key the live row does not, when the
2276
+ * row is a rollover's fresh one made inside `ROLLOVER_REPEAT_WINDOW_MS`.
2277
+ * Only the one rollover back: a second inside the window is a redelivery
2278
+ * outliving two deliberate resets, and the chain is not walked for it.
2279
+ * Always finished — a rollover refuses a session with a turn in flight.
2280
+ */
2281
+ const archivedRepeat = async (session, idempotencyKey) => {
2282
+ const archivedAs = session.metadata?.[ROLLED_OVER_FROM_METADATA_KEY];
2283
+ if (typeof archivedAs !== 'string' || Date.now() - Date.parse(session.createdAt) > ROLLOVER_REPEAT_WINDOW_MS) {
2284
+ return undefined;
2285
+ }
2286
+ const archived = await store.get(archivedAs);
2287
+ return archived !== undefined && workItemState(archived, idempotencyKey) !== undefined ? archived : undefined;
2288
+ };
2289
+ /** An unfinished item with no live turn, settled the way the start-up sweeps would. */
2290
+ const settleUnfinished = async (session) => {
2291
+ if (session.status === 'pending_approval') {
2292
+ await recoverParked(session.id, orphanedAtStart.has(session.id));
2293
+ return (await store.get(session.id)) ?? session;
2294
+ }
2295
+ return settleAbandonedTurn(session);
2296
+ };
2297
+ /**
2298
+ * Settles a keyed item the last process left unfinished before any other
2299
+ * message joins its session. Channels are up before the sweeps reach it,
2300
+ * and a different message resumed onto it would become the session's
2301
+ * newest, so `workItemState` would call the old item finished: its
2302
+ * redelivery would then run nothing although its turn never finished.
2303
+ *
2304
+ * Keyed items only. An unkeyed turn's caller was promised nothing, and a
2305
+ * message resuming it is the long-standing way such a session recovers.
2306
+ */
2307
+ const settleOrphanedItem = async (sessionId) => {
2308
+ const session = await store.get(sessionId);
2309
+ const key = session?.messages.findLast((candidate) => candidate.role === 'user')?.idempotencyKey;
2310
+ if (session !== undefined && key !== undefined && workItemState(session, key) === 'unfinished') {
2311
+ // A continuation that fails has failed its session, which is settled
2312
+ // too; the message waiting behind it runs either way.
2313
+ await settleUnfinished(session).catch((error) => {
2314
+ warn(`could not settle ${sessionId}'s unfinished turn before the next message: ${error instanceof Error ? error.message : String(error)}`);
2315
+ });
2316
+ // Settling can leave it unfinished — a shutdown that began after this
2317
+ // message was accepted skips recovery — and the message must not run
2318
+ // over it then, or the old key reads finished with its turn never run.
2319
+ const after = await store.get(sessionId);
2320
+ if (after !== undefined && workItemState(after, key) === 'unfinished') {
2321
+ throw stopping
2322
+ ? refusal()
2323
+ : new Error(`Session ${sessionId} has an unfinished turn that could not be settled, so this message was not added behind it. Send it again.`);
2324
+ }
2325
+ }
2326
+ };
1699
2327
  /**
1700
2328
  * Runs `work` as the session's next turn: queued behind whatever that
1701
2329
  * session is already doing, and tracked so shutdown drains it.
@@ -1758,11 +2386,125 @@ export const createGateway = (options = {}) => {
1758
2386
  if (reserved !== undefined) {
1759
2387
  throw new Error(`Session metadata key "${reserved}" is reserved for the daemon's own records and cannot be supplied by a dispatch. Reserved keys: ${RESERVED_SESSION_METADATA_KEYS.join(', ')}.`);
1760
2388
  }
1761
- const { turnId } = input;
1762
- if (turnId === undefined) {
1763
- return onSessionChain(input.sessionId, () => dispatchInternal(input));
2389
+ const { turnId, idempotencyKey } = input;
2390
+ if (idempotencyKey !== undefined && (idempotencyKey.length === 0 || idempotencyKey.length > MAX_IDEMPOTENCY_KEY_LENGTH)) {
2391
+ throw new Error(`An idempotency key is 1 to ${MAX_IDEMPOTENCY_KEY_LENGTH} characters; this one is ${idempotencyKey.length}.`);
2392
+ }
2393
+ // A repeat of a dispatch still queued or running is that dispatch: it
2394
+ // resolves with it rather than queueing behind it, which would let
2395
+ // another caller's turn land in between and then find a finished item.
2396
+ // Attached only when it names no other agent than the live dispatch
2397
+ // did; otherwise it queues, and `settleRepeat` refuses it there.
2398
+ const live = idempotencyKey !== undefined ? liveWorkItems.get(input.sessionId)?.get(idempotencyKey) : undefined;
2399
+ // A claim naming an agent other than this delivery does, that the chain
2400
+ // has not yet confirmed — this delivery naming nobody, or someone else.
2401
+ // The claim may be refused for who it named, and then the item is still
2402
+ // unowned: inheriting the refusal would drop a valid message, and
2403
+ // queueing behind it would leave this delivery to find the turn that a
2404
+ // later valid delivery runs `finished`, and answer it twice. So it
2405
+ // waits for the claim to settle and decides then: a claim that stood
2406
+ // is repeated, by a delivery naming its owner or nobody, and refuses
2407
+ // anyone else; a claim that left the item unowned is dispatched again,
2408
+ // where the first delivery to get there runs it and the rest attach.
2409
+ if (live !== undefined && !live.confirmed && live.agentId !== undefined && input.agentId !== live.agentId) {
2410
+ const claim = live.turn;
2411
+ const claimedBy = input.agentId;
2412
+ const repeatOf = (owner) => {
2413
+ if (claimedBy !== undefined && claimedBy !== owner) {
2414
+ throw crossIdentityError(input.sessionId, owner, claimedBy);
2415
+ }
2416
+ input.onRepeat?.('live');
2417
+ };
2418
+ return claim.then((session) => {
2419
+ repeatOf(session.agent.id);
2420
+ return session;
2421
+ }, async (error) => {
2422
+ // Refused for who it named, or failed before its keyed message was
2423
+ // ever written (an agent gone, a provider it could not build): the
2424
+ // item is still unowned, and there is no turn to have repeated.
2425
+ const stored = error instanceof CrossIdentityError || idempotencyKey === undefined ? undefined : await store.get(input.sessionId);
2426
+ const owned = stored !== undefined && idempotencyKey !== undefined && workItemState(stored, idempotencyKey) !== undefined;
2427
+ if (owned) {
2428
+ repeatOf(stored.agent.id);
2429
+ throw error;
2430
+ }
2431
+ // Its own cleanup runs a tick later; the dispatch below must not
2432
+ // find the dead claim still standing and wait on it again.
2433
+ const items = liveWorkItems.get(input.sessionId);
2434
+ if (idempotencyKey !== undefined && items?.get(idempotencyKey)?.turn === claim) {
2435
+ items.delete(idempotencyKey);
2436
+ if (items.size === 0) {
2437
+ liveWorkItems.delete(input.sessionId);
2438
+ }
2439
+ }
2440
+ return dispatch(input);
2441
+ });
1764
2442
  }
1765
- return onSessionChain(input.sessionId, async () => {
2443
+ if (live !== undefined && (input.agentId === undefined || input.agentId === live.agentId)) {
2444
+ input.onRepeat?.('live');
2445
+ return live.turn;
2446
+ }
2447
+ // The live dispatch named no agent, so whose session it is is known
2448
+ // only once its turn resolves. Queueing instead would make this a
2449
+ // `finished` repeat of a turn the live caller is still rendering — two
2450
+ // answers — so it attaches, and is held to the agent it named then:
2451
+ // told it repeated only if that agent owns the session, refused if not.
2452
+ const claimed = input.agentId;
2453
+ if (live !== undefined && live.agentId === undefined && claimed !== undefined) {
2454
+ const shared = live.turn;
2455
+ // Whichever way the turn ends: a failed one is still that agent's,
2456
+ // its owner read from the store, and a repeat of it is still told so
2457
+ // — or refused, rather than handed another agent's error.
2458
+ const attach = async () => {
2459
+ let outcome;
2460
+ try {
2461
+ outcome = { session: await shared };
2462
+ }
2463
+ catch (error) {
2464
+ outcome = { error };
2465
+ }
2466
+ const owner = 'session' in outcome ? outcome.session.agent.id : (await store.get(input.sessionId))?.agent.id;
2467
+ if (owner !== undefined && owner !== claimed) {
2468
+ throw crossIdentityError(input.sessionId, owner, claimed);
2469
+ }
2470
+ // No owner means the dispatch failed before its session was ever
2471
+ // written: there is no turn to have repeated, only its error.
2472
+ if (owner !== undefined) {
2473
+ input.onRepeat?.('live');
2474
+ }
2475
+ if ('error' in outcome) {
2476
+ throw outcome.error;
2477
+ }
2478
+ return outcome.session;
2479
+ };
2480
+ return attach();
2481
+ }
2482
+ // Reads `turn` only once it is assigned: the registration below runs
2483
+ // after it, and the chain's work runs in a later tick (`.then`).
2484
+ const claimLiveItem = (key, confirmed) => {
2485
+ const items = liveWorkItems.get(input.sessionId) ?? new Map();
2486
+ items.set(key, { turn, agentId: input.agentId, confirmed, addressed: input.addressed !== false });
2487
+ liveWorkItems.set(input.sessionId, items);
2488
+ };
2489
+ const turn = onSessionChain(input.sessionId, async () => {
2490
+ // Before `activeTurns` is touched: a repeat that runs nothing must
2491
+ // not claim the session's turn id, even for the instant it takes.
2492
+ const repeated = idempotencyKey !== undefined ? await settleRepeat(input.sessionId, idempotencyKey, input.agentId, turnId, input.onRepeat) : undefined;
2493
+ if (repeated !== undefined) {
2494
+ return repeated;
2495
+ }
2496
+ // Past the identity check with a key the session has not seen, so this
2497
+ // dispatch owns the item: it becomes the live entry even if a claimant
2498
+ // that was then refused registered first, or a repeat arriving while
2499
+ // this turn runs would find nothing to attach to, queue, and read the
2500
+ // turn as finished — a second answer beside this caller's.
2501
+ if (idempotencyKey !== undefined) {
2502
+ claimLiveItem(idempotencyKey, true);
2503
+ }
2504
+ await settleOrphanedItem(input.sessionId);
2505
+ if (turnId === undefined) {
2506
+ return dispatchInternal(input);
2507
+ }
1766
2508
  activeTurns.set(input.sessionId, turnId);
1767
2509
  try {
1768
2510
  return await dispatchInternal(input);
@@ -1774,6 +2516,25 @@ export const createGateway = (options = {}) => {
1774
2516
  activeTurns.delete(input.sessionId);
1775
2517
  }
1776
2518
  });
2519
+ if (idempotencyKey !== undefined) {
2520
+ // A repeat that queued instead of attaching — it named another agent,
2521
+ // and is about to be refused — must not displace the turn it repeats.
2522
+ // The first registration is provisional; the chain confirms it
2523
+ // (`claimLiveItem` above) once the dispatch is known to own the item.
2524
+ if (liveWorkItems.get(input.sessionId)?.has(idempotencyKey) !== true) {
2525
+ claimLiveItem(idempotencyKey, false);
2526
+ }
2527
+ void turn.catch(() => { }).finally(() => {
2528
+ const current = liveWorkItems.get(input.sessionId);
2529
+ if (current?.get(idempotencyKey)?.turn === turn) {
2530
+ current.delete(idempotencyKey);
2531
+ if (current.size === 0) {
2532
+ liveWorkItems.delete(input.sessionId);
2533
+ }
2534
+ }
2535
+ });
2536
+ }
2537
+ return turn;
1777
2538
  };
1778
2539
  const observe = async (input) => {
1779
2540
  if (stopping) {
@@ -1802,7 +2563,7 @@ export const createGateway = (options = {}) => {
1802
2563
  return undefined;
1803
2564
  }
1804
2565
  if (input.agentId !== undefined && existing.agent.id !== input.agentId) {
1805
- throw new Error(`Session ${input.sessionId} belongs to agent ${existing.agent.id}, not ${input.agentId} — sessions never cross agent identities.`);
2566
+ throw crossIdentityError(input.sessionId, existing.agent.id, input.agentId);
1806
2567
  }
1807
2568
  const continuedAs = existing.metadata?.[ROLLED_OVER_TO_METADATA_KEY];
1808
2569
  if (typeof continuedAs === 'string') {
@@ -1852,7 +2613,8 @@ export const createGateway = (options = {}) => {
1852
2613
  // *calling* agent's — its allowlist checked, its own entry before the
1853
2614
  // fleet's shared one.
1854
2615
  credentials: createFileCredentialResolver(env),
1855
- workspaces: createAgentWorkspaces(env),
2616
+ workspaces: agentWorkspaces,
2617
+ stateDirectories: createPluginStateDirectories(env),
1856
2618
  // The structured log, so a plugin's lifecycle lines — an MCP server
1857
2619
  // that dropped, a reconnect that failed — are in `stratus logs` and
1858
2620
  // not only on a stderr the service manager owns.
@@ -1920,6 +2682,7 @@ export const createGateway = (options = {}) => {
1920
2682
  * qualified `<package>:<skill>` form.
1921
2683
  */
1922
2684
  const startSkills = async () => {
2685
+ await registerBuiltinSkills(skillCatalog);
1923
2686
  operatorSkills = await loadOperatorSkills(env, skillCatalog, warn);
1924
2687
  for (const skill of operatorSkills) {
1925
2688
  log(`skill ${skill.id} loaded from ${skill.path}`);
@@ -1991,6 +2754,7 @@ export const createGateway = (options = {}) => {
1991
2754
  // agent.delegate call is only answerable while nothing can resume that
1992
2755
  // parent. See listOrphanedDelegations.
1993
2756
  const orphaned = await listOrphanedDelegations();
2757
+ orphanedAtStart = orphaned;
1994
2758
  // Channels come up after the roster so their first inbound message
1995
2759
  // already has agents to dispatch to. One failing adapter must not
1996
2760
  // keep the rest (or the gateway) down. The host's adapters first, then
@@ -2262,6 +3026,24 @@ export const createGateway = (options = {}) => {
2262
3026
  },
2263
3027
  dispatch,
2264
3028
  observe,
3029
+ async holdsMessage(sessionId, idempotencyKey) {
3030
+ // A dispatch still queued behind another turn holds the message too:
3031
+ // its key reaches the transcript only when the chain gets to it. By
3032
+ // the same rule as the transcript below — a judged turn still waiting
3033
+ // to run did not take the message either.
3034
+ if (liveWorkItems.get(sessionId)?.get(idempotencyKey)?.addressed === true) {
3035
+ return true;
3036
+ }
3037
+ const session = await store.get(sessionId);
3038
+ if (session === undefined) {
3039
+ return false;
3040
+ }
3041
+ const transcript = workItemState(session, idempotencyKey) !== undefined ? session : await archivedRepeat(session, idempotencyKey);
3042
+ // Accepted as addressed: a turn nobody asked for (`addressed: false`,
3043
+ // stored overheard) was this agent hearing the message, not taking it.
3044
+ const keyed = transcript?.messages.findLast((message) => message.role === 'user' && message.idempotencyKey === idempotencyKey);
3045
+ return keyed !== undefined && keyed.overheard !== true;
3046
+ },
2265
3047
  async sessionRouting(sessionId) {
2266
3048
  const session = await store.get(sessionId);
2267
3049
  if (!session) {
@@ -2392,7 +3174,9 @@ export const createGateway = (options = {}) => {
2392
3174
  return registry.list();
2393
3175
  },
2394
3176
  async reloadRoster() {
2395
- await reloadsInOrder(loadRoster);
3177
+ // Under the soul lock, so a repoint cannot land between a credential
3178
+ // grant's check of the served path and its write (`grantSoulCredential`).
3179
+ await reloadsInOrder(() => withSoulFileLock(loadRoster));
2396
3180
  const roster = registry.list();
2397
3181
  log(`roster reloaded — ${roster.length} agent(s): ${roster.map((agent) => agent.name).join(', ')}`);
2398
3182
  return roster;
@@ -2459,6 +3243,9 @@ export const createGateway = (options = {}) => {
2459
3243
  });
2460
3244
  return status;
2461
3245
  },
3246
+ async checkRestart() {
3247
+ await options.restartPreflight?.();
3248
+ },
2462
3249
  activeTurnId(sessionId) {
2463
3250
  return activeTurns.get(sessionId);
2464
3251
  },
@@ -2493,7 +3280,7 @@ export const createGateway = (options = {}) => {
2493
3280
  // From the registry, not from the load records: the registry is the
2494
3281
  // live answer for which bare aliases still resolve — a plugin loaded
2495
3282
  // later can have retired one a record still remembers.
2496
- const provenance = new Map();
3283
+ const provenance = new Map([[STRATUS_SKILL_ID, { path: STRATUS_SKILL_PATH }]]);
2497
3284
  for (const info of operatorSkills) {
2498
3285
  provenance.set(info.id, { path: info.path });
2499
3286
  }
@@ -2512,6 +3299,7 @@ export const createGateway = (options = {}) => {
2512
3299
  ...(alias !== undefined ? { alias } : {}),
2513
3300
  ...(from?.package !== undefined ? { package: from.package } : {}),
2514
3301
  ...(from !== undefined ? { path: from.path } : {}),
3302
+ ...(skill.builtin === true ? { builtin: true } : {}),
2515
3303
  };
2516
3304
  });
2517
3305
  },
@@ -2583,6 +3371,7 @@ export const createGateway = (options = {}) => {
2583
3371
  })).then((entries) => entries.filter((entry) => entry !== undefined));
2584
3372
  },
2585
3373
  resolveApproval,
3374
+ provideCredential,
2586
3375
  };
2587
3376
  return gateway;
2588
3377
  };