@stratusagent/gateway 0.14.7 → 0.14.9

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.
Files changed (53) hide show
  1. package/dist/agent-source.d.ts +10 -0
  2. package/dist/agent-source.d.ts.map +1 -0
  3. package/dist/agent-source.js +7 -0
  4. package/dist/agent-source.js.map +1 -0
  5. package/dist/approvals.d.ts +38 -0
  6. package/dist/approvals.d.ts.map +1 -0
  7. package/dist/approvals.js +330 -0
  8. package/dist/approvals.js.map +1 -0
  9. package/dist/credential-requests.d.ts +24 -0
  10. package/dist/credential-requests.d.ts.map +1 -0
  11. package/dist/credential-requests.js +452 -0
  12. package/dist/credential-requests.js.map +1 -0
  13. package/dist/dispatch.d.ts +34 -0
  14. package/dist/dispatch.d.ts.map +1 -0
  15. package/dist/dispatch.js +461 -0
  16. package/dist/dispatch.js.map +1 -0
  17. package/dist/index.d.ts +4 -1290
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +202 -2546
  20. package/dist/index.js.map +1 -1
  21. package/dist/lifecycle.d.ts +130 -0
  22. package/dist/lifecycle.d.ts.map +1 -0
  23. package/dist/lifecycle.js +147 -0
  24. package/dist/lifecycle.js.map +1 -0
  25. package/dist/merge-batch.d.ts +60 -0
  26. package/dist/merge-batch.d.ts.map +1 -0
  27. package/dist/merge-batch.js +59 -0
  28. package/dist/merge-batch.js.map +1 -0
  29. package/dist/recovery.d.ts +49 -0
  30. package/dist/recovery.d.ts.map +1 -0
  31. package/dist/recovery.js +487 -0
  32. package/dist/recovery.js.map +1 -0
  33. package/dist/session-keys.d.ts +101 -0
  34. package/dist/session-keys.d.ts.map +1 -0
  35. package/dist/session-keys.js +148 -0
  36. package/dist/session-keys.js.map +1 -0
  37. package/dist/steering.d.ts +24 -0
  38. package/dist/steering.d.ts.map +1 -0
  39. package/dist/steering.js +108 -0
  40. package/dist/steering.js.map +1 -0
  41. package/dist/sub-agents.d.ts +20 -0
  42. package/dist/sub-agents.d.ts.map +1 -0
  43. package/dist/sub-agents.js +263 -0
  44. package/dist/sub-agents.js.map +1 -0
  45. package/dist/turn-dispatch.d.ts +34 -0
  46. package/dist/turn-dispatch.d.ts.map +1 -0
  47. package/dist/turn-dispatch.js +179 -0
  48. package/dist/turn-dispatch.js.map +1 -0
  49. package/dist/types.d.ts +1103 -0
  50. package/dist/types.d.ts.map +1 -0
  51. package/dist/types.js +2 -0
  52. package/dist/types.js.map +1 -0
  53. package/package.json +7 -7
package/dist/index.js CHANGED
@@ -6,18 +6,25 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
6
6
  }
7
7
  return path;
8
8
  };
9
- import { createHash, randomBytes, randomUUID } from 'node:crypto';
10
- import { mkdir, readFile, rename, rm } from 'node:fs/promises';
11
- import path from 'node:path';
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, InMemorySessionStore, MEMORY_PASS_MAX_MODEL_CALLS, MEMORY_PASS_RULES, 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, resolveToolRisk, } from '@stratusagent/core';
13
- import { CREDENTIAL_REQUEST_TOOL_NAME, createCredentialRequestTool, createDelegateTool, DELEGATE_TOOL_NAME, createForgetTool, createPinTool, createMessageSendTool, createMessageSayTool, 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';
9
+ import { createHash, randomUUID } from 'node:crypto';
10
+ import { filePathsOf, isUnaddressedTurn, carriesKey, SENDER_TRUST_METADATA_KEY, workItemState, turnMessageOf, turnKeyOf, leftForNextStart, sessionWriteTrust, AgentRegistry, AgentRunner, InMemorySessionStore, MEMORY_PASS_MAX_MODEL_CALLS, MEMORY_PASS_RULES, ChannelRegistry, ContributionRegistry, createRoutedMemoryStore, EventBus, RunAbortedError, SkillRegistry, ToolRegistry, createSkillReadTool, missingSkillRequirements, describeToolAllowlistFinding, unmatchedToolAllowlist, SESSION_TRUST_METADATA_KEY, latestTurnReply, conversationContextFrom, STRATUS_SKILL_ID, } from '@stratusagent/core';
11
+ import { CREDENTIAL_REQUEST_TOOL_NAME, createForgetTool, createPinTool, createMessageSendTool, createMessageSayTool, createMessageReadTool, createChannelArchiveTool, createChannelCreateTool, createChannelInviteTool, createChannelJoinTool, directMessageReadRefusal, createRecallTool, createRememberTool, createScheduleTools, DELEGATED_SESSION_ID_MARKER, loadStratusSkill, STRATUS_SKILL_PATH, } from '@stratusagent/agents';
12
+ import { createApprovalBroker } from "./approvals.js";
13
+ import { createCredentialRequests } from "./credential-requests.js";
14
+ import { createDispatch } from "./dispatch.js";
14
15
  import { createFleetReader } from "./fleet.js";
15
16
  import { createMemoryPassHost } from "./memory-passes.js";
17
+ import { createRecovery } from "./recovery.js";
16
18
  import { callShownWhole, readReviewReply, REVIEW_RULES, reviewCaseText } from "./review.js";
17
19
  import { createEventIntake, summarizeEvents, } from "./event-intake.js";
18
20
  import { SqliteEventStore } from "./events.js";
19
21
  import { createSchedulerRuntime, isScheduleSessionId, SqliteScheduleStore, SCHEDULE_SESSION_ID_PREFIX, } from "./schedules.js";
20
22
  import { ShardedSessionStore } from "./sessions.js";
23
+ import { createSubAgents } from "./sub-agents.js";
24
+ import { createSteering } from "./steering.js";
25
+ import { createTurnDispatch } from "./turn-dispatch.js";
26
+ import { DEFAULT_CHANNEL_RELOAD_TIMEOUT_MS, DEFAULT_RESTART_DRAIN_TIMEOUT_MS, DEFAULT_STOP_DRAIN_TIMEOUT_MS, GatewayStoppingError, MAX_PAUSED_MESSAGE_LENGTH, PauseMessageError, readIntakeState, RESTARTING_TURN_ERROR, RestartUnsupportedError, STOPPING_TURN_ERROR, writeIntakeState, } from "./lifecycle.js";
27
+ import { crossIdentityError, isSubAgentSession, RESERVED_SESSION_METADATA_KEYS, reservedSessionMetadataKey, ROLLED_OVER_FROM_METADATA_KEY, ROLLED_OVER_SESSION_ID_MARKER, ROLLED_OVER_TO_METADATA_KEY, SUB_AGENT_SESSION_ID_MARKER, } from "./session-keys.js";
21
28
  export { claimHome, homeLockPath, HomeClaimedError, } from "./lock.js";
22
29
  export { SqliteSessionStore, ShardedSessionStore, FleetSessionIndex, SessionIdTakenError, tightenSqliteFile, } from "./sessions.js";
23
30
  export { SqliteScheduleStore, createSchedulerRuntime, isScheduleSessionId, SCHEDULE_SESSION_ID_PREFIX, } from "./schedules.js";
@@ -29,7 +36,7 @@ export { REVIEW_RULES, readReviewReply, reviewCaseText } from "./review.js";
29
36
  export { createEventIntake, mapGithubDelivery, parseStandardWebhookEvent, verifyGithubSignature, verifyStandardWebhookSignature, DEFAULT_EVENT_MAX_BODY_BYTES, EVENT_SOURCE_REQUESTS_PER_MINUTE, } from "./event-intake.js";
30
37
  import { createLocalCommandExecutor } from '@stratusagent/executor-local';
31
38
  import { loadPlugins, } from '@stratusagent/plugins';
32
- import { BUILTIN_EXECUTOR_NAME, BUILTIN_MEMORY_STORE_NAME, BUILTIN_PROVIDER_NAMES, createDemoTool, createFileCredentialResolver, credentialValuesFor, resolveDaemonCredential, createHomeMemoryStore, drainSharedMemory, hasBracketedLegacyStateIn, createRuntimeProvider, describeServingModel, DEFAULT_STRATUS_AGENT, isRegisteredProviderName, loadChannelTransportSecrets, registeredProviderNameOf, loadOperatorSkills, loadRosterSouls, FALLBACK_ACTIVE_METADATA_KEY, loadSoulFile, grantSoulCredential, withSoulFileLock, addNamedCredential, appendCredentialAudit, replaceNamedCredential, NamedCredentialMissingError, namedCredentialSource, NamedCredentialExistsError, CREDENTIAL_NAME_PATTERN, quoteShellArg, migrateLegacyMemory, PROVIDER_STATE_METADATA_KEYS, ConfigFileError, loadConfigFile, resolveConfigLocation, resolveConfiguredSoul, resolveRuntimeConfig, runsOnHarness, applySoulPins, assertStateCompatible, unlistedToolsFor, stratusHomePath, intakeStatePath, fleetDbIn, createAgentWorkspaces, createPluginStateDirectories, createHostProtectedPaths, foldedAgentId, syncDirectory, writeFileSynced, } from '@stratusagent/state';
39
+ import { BUILTIN_EXECUTOR_NAME, BUILTIN_MEMORY_STORE_NAME, BUILTIN_PROVIDER_NAMES, createDemoTool, createFileCredentialResolver, credentialValuesFor, resolveDaemonCredential, createHomeMemoryStore, drainSharedMemory, hasBracketedLegacyStateIn, createRuntimeProvider, describeServingModel, DEFAULT_STRATUS_AGENT, isRegisteredProviderName, loadChannelTransportSecrets, registeredProviderNameOf, loadOperatorSkills, loadRosterSouls, FALLBACK_ACTIVE_METADATA_KEY, loadSoulFile, withSoulFileLock, migrateLegacyMemory, PROVIDER_STATE_METADATA_KEYS, ConfigFileError, loadConfigFile, resolveConfigLocation, resolveConfiguredSoul, resolveRuntimeConfig, runsOnHarness, applySoulPins, assertStateCompatible, unlistedToolsFor, stratusHomePath, fleetDbIn, createAgentWorkspaces, createPluginStateDirectories, createHostProtectedPaths, foldedAgentId, } from '@stratusagent/state';
33
40
  const DEFAULT_IDLE_TIMEOUT_MS = 120_000;
34
41
  /**
35
42
  * How long a parked call waits for a person. Long enough to survive a
@@ -53,348 +60,6 @@ const MAX_TIMER_DELAY_MS = 2_147_483_647;
53
60
  * question even when the rule answering it is not gateway code.
54
61
  */
55
62
  export { applySoulPins } from '@stratusagent/state';
56
- /** What a turn cut short by an announced restart records as its failure. */
57
- export const RESTARTING_TURN_ERROR = 'Run aborted: stratusd is restarting';
58
- /** The error a turn still running when a stop's drain window closes is aborted with. */
59
- export const STOPPING_TURN_ERROR = 'Run aborted: stratusd is stopping';
60
- /**
61
- * How long an announced restart lets in-flight turns finish before aborting
62
- * them. The same as a stop's window (`DEFAULT_STOP_DRAIN_TIMEOUT_MS`), so the
63
- * two windows an operator can hit agree.
64
- */
65
- export const DEFAULT_RESTART_DRAIN_TIMEOUT_MS = 30_000;
66
- /** How long a roster reload waits on one channel's `reload()`. */
67
- export const DEFAULT_CHANNEL_RELOAD_TIMEOUT_MS = 30_000;
68
- /**
69
- * How long a stop lets in-flight turns finish before aborting them. The
70
- * daemon's documented drain ("up to 30 seconds"), and shorter than the
71
- * service units' stop timeouts, so the abort happens in this process
72
- * rather than as the manager's SIGKILL. That matters beyond the turn's
73
- * record: a shell command runs in its own process group, which launchd's
74
- * kill of the job does not reach, so only the turn's abort stops it.
75
- */
76
- export const DEFAULT_STOP_DRAIN_TIMEOUT_MS = 30_000;
77
- /** What a paused daemon says when nobody gave it a sentence of its own. */
78
- export const DEFAULT_PAUSED_MESSAGE = "I'm paused right now and not taking new work. Please try again later.";
79
- /** What a held gateway answers a dispatch with, before it is promoted. */
80
- export const HELD_MESSAGE = "I'm starting up and not taking work yet. Please try again in a moment.";
81
- /** The longest pause message, so a reply to a refused message stays one. */
82
- export const MAX_PAUSED_MESSAGE_LENGTH = 500;
83
- /** A pause message the gateway will not take: empty, or too long. */
84
- export class PauseMessageError extends Error {
85
- constructor(message) {
86
- super(message);
87
- this.name = 'PauseMessageError';
88
- }
89
- }
90
- /**
91
- * A dispatch refused because intake is paused. Its message is the sentence
92
- * to show whoever sent the work, as it is: a channel posts it as the reply,
93
- * not as a failure.
94
- */
95
- export class IntakePausedError extends Error {
96
- constructor(message) {
97
- super(message);
98
- this.name = 'IntakePausedError';
99
- }
100
- }
101
- /**
102
- * A dispatch refused because the daemon is restarting or stopping. Channels
103
- * stay connected while a restart drains, so they hear people during it, and
104
- * this is what tells them the message reached a daemon on its way down: a
105
- * channel can say so and keep the message for the next one. Matched by name
106
- * there, as channel packages do not import the gateway.
107
- */
108
- export class GatewayStoppingError extends Error {
109
- /** True for a restart, which comes back on its own; false for a stop. */
110
- restarting;
111
- constructor(message, restarting) {
112
- super(message);
113
- this.name = 'GatewayStoppingError';
114
- this.restarting = restarting;
115
- }
116
- }
117
- /**
118
- * The intake state a previous process left, or open when there is none.
119
- *
120
- * Fails closed: a file that exists but cannot be read or parsed starts the
121
- * daemon paused, with a warning naming it. A pause exists to stop work (a
122
- * spending cap, a maintenance window), and a file nobody can read is no
123
- * evidence that it was lifted.
124
- */
125
- const readIntakeState = async (env, warn) => {
126
- const file = intakeStatePath(env);
127
- let raw;
128
- try {
129
- raw = await readFile(file, 'utf8');
130
- }
131
- catch (error) {
132
- if (error.code === 'ENOENT') {
133
- return { paused: false };
134
- }
135
- warn(`intake: ${file} could not be read (${error.message}); starting paused. Resume intake to clear it.`);
136
- return { paused: true, since: new Date().toISOString(), unreadable: true };
137
- }
138
- try {
139
- const parsed = JSON.parse(raw);
140
- // Only a pause is ever written here (resuming removes the file), so
141
- // anything else in it is not a record this build wrote, and the same
142
- // rule as an unreadable file applies.
143
- if (typeof parsed !== 'object' || parsed === null || parsed.paused !== true) {
144
- warn(`intake: ${file} does not hold a pause record; starting paused. Resume intake to clear it.`);
145
- return { paused: true, since: new Date().toISOString(), unreadable: true };
146
- }
147
- return {
148
- paused: true,
149
- ...(typeof parsed.message === 'string' && parsed.message.trim().length > 0 ? { message: parsed.message } : {}),
150
- since: typeof parsed.since === 'string' ? parsed.since : new Date().toISOString(),
151
- };
152
- }
153
- catch {
154
- warn(`intake: ${file} is not valid JSON; starting paused. Resume intake to clear it.`);
155
- return { paused: true, since: new Date().toISOString(), unreadable: true };
156
- }
157
- };
158
- /**
159
- * Record a pause durably (0600, replaced in one atomic step), or remove the
160
- * record when intake reopens: open is the default, so nothing on disk
161
- * means open.
162
- */
163
- const writeIntakeState = async (env, state) => {
164
- const file = intakeStatePath(env);
165
- if (!state.paused) {
166
- await rm(file, { force: true });
167
- return;
168
- }
169
- await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
170
- const staging = `${file}.${randomBytes(8).toString('hex')}`;
171
- try {
172
- await writeFileSynced(staging, `${JSON.stringify(state)}\n`, { flag: 'wx', mode: 0o600 });
173
- await rename(staging, file);
174
- await syncDirectory(path.dirname(file));
175
- }
176
- finally {
177
- await rm(staging, { force: true });
178
- }
179
- };
180
- /**
181
- * `restart()` on a gateway whose host gave it no way back. Exported so the
182
- * control API can answer it as "not supported here" rather than as a
183
- * failure of the daemon.
184
- */
185
- export class RestartUnsupportedError extends Error {
186
- constructor() {
187
- super('This daemon cannot restart itself: the host that started it did not say how. '
188
- + 'Restart it from the service manager (stratus service stop, then start) or from the terminal it runs in.');
189
- this.name = 'RestartUnsupportedError';
190
- }
191
- }
192
- /**
193
- * The most text one merged user message may hold (card 423). The runner's
194
- * overflow recovery can drop earlier transcript but never part of the
195
- * newest message, so a batch must not grow that message past what one
196
- * message could be on its own: this is Slack's own message-length limit.
197
- * A single message longer than this still runs, alone, as it would have
198
- * without merging.
199
- */
200
- const MERGED_TEXT_BUDGET_CHARS = 40_000;
201
- /** Separator between merged messages; counted against the text budget. */
202
- const MERGED_SEPARATOR = '\n\n';
203
- /**
204
- * Whether `images` all fit the replay budget together. A merged turn's
205
- * images are one message's, and the runner empties whatever falls outside
206
- * the budget before the provider sees it, so a batch that would overflow
207
- * must not take the message that overflows it (card 423).
208
- */
209
- const fitsReplayBudget = (images) => images.length === 0 || imagesWithinReplayBudget([{ id: '', createdAt: '', role: 'user', content: '', images: [...images] }]).size === images.length;
210
- /** A signal that aborts once every one of `signals` has. */
211
- const allAborted = (signals) => joinableSignal(signals).signal;
212
- /**
213
- * `allAborted` for callers that keep joining: a turn that takes in messages
214
- * at its tool steps (card 948) is cancelled only once every caller in it
215
- * is, the ones who joined late included. A caller joining with no signal
216
- * can never cancel, so from then on neither can the turn.
217
- */
218
- const joinableSignal = (initial) => {
219
- const controller = new AbortController();
220
- const signals = [...initial];
221
- let uncancellable = false;
222
- const check = () => {
223
- if (!uncancellable && !controller.signal.aborted && signals.every((signal) => signal.aborted)) {
224
- controller.abort(signals[signals.length - 1]?.reason);
225
- }
226
- };
227
- for (const signal of signals) {
228
- signal.addEventListener('abort', check, { once: true });
229
- }
230
- check();
231
- return {
232
- signal: controller.signal,
233
- join: (signal) => {
234
- if (signal === undefined) {
235
- uncancellable = true;
236
- return;
237
- }
238
- signals.push(signal);
239
- signal.addEventListener('abort', check, { once: true });
240
- },
241
- };
242
- };
243
- /**
244
- * A value from a form, without the line breaks around it.
245
- *
246
- * Forms take multi-line values now, so a one-line key copied with the line
247
- * break after it would otherwise be stored with it, and fail later inside
248
- * whatever header or command it lands in. Only line breaks: a value's own
249
- * spaces are its own, as on the CLI.
250
- */
251
- const formCredentialValue = (value) => value.replace(/^[\r\n]+|[\r\n]+$/g, '');
252
- /**
253
- * A PEM block (a private key, a certificate) with no line breaks left, which
254
- * is what a one-line field makes of a pasted key file. PEM readers need the
255
- * line after the `-----BEGIN …-----` header, so storing it would only move
256
- * the failure to the first call that reads the key. Only a value that *is*
257
- * one: a PEM inside JSON (a service account file) escapes its line breaks,
258
- * so it has none of its own and is fine as sent.
259
- */
260
- const isFlattenedPem = (value) => /^\s*-----BEGIN [A-Z0-9 ]+-----/.test(value) && !value.includes('\n');
261
- /**
262
- * Recorded on a turn the previous process was still running when it died.
263
- *
264
- * Exported because "distinguishable" is the whole point: a surface, a
265
- * test, or an operator reading `lastError` has to be able to tell a turn
266
- * nobody finished apart from one a provider or a tool actually failed, and
267
- * matching on prose that lives in one place beats each caller inventing
268
- * its own guess at the wording.
269
- */
270
- export const ABANDONED_TURN_ERROR = 'stratusd stopped while this turn was still running; it was not resumed. Send the message again.';
271
- /**
272
- * The longest `DispatchInput.idempotencyKey` accepted. A platform message id
273
- * is far shorter; the bound is so a key cannot be used to grow every row it
274
- * is stored on.
275
- */
276
- /**
277
- * Recorded on a delegated sub-session that was parked on a human when the
278
- * previous process died.
279
- *
280
- * Its parent — the turn whose `agent.delegate` call was awaiting the reply
281
- * — was running, so the restart settles it as abandoned (above). Recovering
282
- * the child anyway would re-ask a person to approve a call whose result no
283
- * turn will ever read, and then run it: a command executed, tokens spent,
284
- * and a reply delivered to nobody. Distinguishable from the parent's error
285
- * because the remedy differs — there is no message to send again here;
286
- * the parent's is the one to repeat.
287
- */
288
- /**
289
- * Session metadata keys the daemon writes for itself, which no external
290
- * dispatch may supply.
291
- *
292
- * Metadata is one bag: the keys a channel attaches to say where a turn is
293
- * happening sit beside the ones the kernel, the scheduler, the fallback
294
- * wrapper, and `agent.delegate` write to remember what a session *is* —
295
- * and the control API forwards a caller's `metadata` into a new session as
296
- * given. A caller who can write `delegatedBy` can have the restart sweep
297
- * fail its own parked turn as an orphan; one who can write `fallbackActive`
298
- * bills its conversation to the fallback model from the first turn. The
299
- * scheduler already refused to trust its own keys for exactly this reason
300
- * (see `activeFirings`); the durable ones cannot be re-derived in memory,
301
- * so they are refused at the door instead.
302
- */
303
- /**
304
- * A rolled-over session: the fresh row records where its predecessor's
305
- * transcript went, and the archived row records which live id continues
306
- * the conversation. See `Gateway.rolloverSession`.
307
- */
308
- export const ROLLED_OVER_FROM_METADATA_KEY = 'rolledOverFrom';
309
- export const ROLLED_OVER_TO_METADATA_KEY = 'rolledOverTo';
310
- /** The segment a rollover mints into the archived transcript's id. */
311
- export const ROLLED_OVER_SESSION_ID_MARKER = ':rolledover:';
312
- /**
313
- * How long after a rollover a repeated key is still looked for in the
314
- * archived transcript. A rollover moves every keyed message out of the
315
- * live row, so a redelivery arriving after one would otherwise read as a
316
- * new message and run again. Platforms redeliver within minutes — Slack
317
- * gives up after about five — and past the window the archive is not
318
- * loaded at all, since every new message to the fresh row would pay for a
319
- * read that can no longer find anything.
320
- */
321
- const ROLLOVER_REPEAT_WINDOW_MS = 60 * 60 * 1000;
322
- /**
323
- * The one refusal for a dispatch naming an agent other than its session's.
324
- * Its own type so a repeat attached to a claimant can tell the claimant was
325
- * refused for who it named — the item then still has no owner — from the
326
- * claimant's turn failing. Internal: callers read the message.
327
- */
328
- class CrossIdentityError extends Error {
329
- constructor(sessionId, owner, claimed) {
330
- super(`Session ${sessionId} belongs to agent ${owner}, not ${claimed} — sessions never cross agent identities.`);
331
- this.name = 'CrossIdentityError';
332
- }
333
- }
334
- const crossIdentityError = (sessionId, owner, claimed) => new CrossIdentityError(sessionId, owner, claimed);
335
- /** Where the session's tool calls ran: `local-command`, or a contributed executor's registered name. */
336
- export const EXECUTOR_METADATA_KEY = 'executor';
337
- /**
338
- * Marks a sub-agent's session (roadmap 24). Reserved: it is what makes the
339
- * daemon deny the session's gated calls without asking anyone, so a caller
340
- * that could set it could also clear it.
341
- */
342
- export const SUB_AGENT_METADATA_KEY = 'subAgent';
343
- export const SUB_AGENT_SESSION_ID_MARKER = ':subagent:';
344
- /** How many sub-agents one parent turn runs at once unless the host says otherwise. */
345
- export const DEFAULT_SUB_AGENTS_PER_TURN = 4;
346
- export const isSubAgentSession = (session) => session.metadata?.[SUB_AGENT_METADATA_KEY] === true && session.id.includes(SUB_AGENT_SESSION_ID_MARKER);
347
- /**
348
- * Tools a sub-agent never gets, whatever its parent holds. `agent.spawn`
349
- * would fan out inside a fan-out; `agent.delegate` runs a roster agent
350
- * under that agent's own allowlist, which is the way out of a narrowed
351
- * slice; `memory.*` would let it persist what nobody reviewed; and
352
- * `credential.request` asks a person, which a sub-agent never does.
353
- */
354
- const subAgentNeverGets = (name) => name === 'agent.spawn'
355
- || name === DELEGATE_TOOL_NAME
356
- || name.startsWith('memory.')
357
- || name === CREDENTIAL_REQUEST_TOOL_NAME;
358
- export const RESERVED_SESSION_METADATA_KEYS = [
359
- PENDING_APPROVAL_METADATA_KEY,
360
- FALLBACK_ACTIVE_METADATA_KEY,
361
- DELEGATED_BY_METADATA_KEY,
362
- ROOT_SESSION_ID_METADATA_KEY,
363
- DELEGATION_DEPTH_METADATA_KEY,
364
- SCHEDULED_TURN_METADATA_KEY,
365
- SCHEDULE_ID_METADATA_KEY,
366
- // The trust label is the runner's to write and only ever goes down; a
367
- // caller who could seed it would be seeding it upward.
368
- SESSION_TRUST_METADATA_KEY,
369
- SESSION_TAINTED_BY_METADATA_KEY,
370
- ROLLED_OVER_FROM_METADATA_KEY,
371
- ROLLED_OVER_TO_METADATA_KEY,
372
- // Which executor ran the turn's tool calls is the daemon's record of
373
- // where a command ran, and the one audit question a caller must not be
374
- // able to answer for it.
375
- EXECUTOR_METADATA_KEY,
376
- // The context floor is an index into a specific transcript, so it means
377
- // nothing next to a different one. Reserved for both halves of that: a
378
- // caller cannot seed it, and a rollover drops it with the transcript it
379
- // indexed — otherwise the fresh session inherits an absolute floor over
380
- // no messages and, once it grows past that index, silently stops sending
381
- // its own earliest turns, with no overflow and no trim event to say so.
382
- CONTEXT_FLOOR_METADATA_KEY,
383
- // The summary that stands in below that floor, for both of the same
384
- // reasons, and one more: the model reads it as something it wrote itself,
385
- // so a caller who could seed it could put words in the agent's mouth.
386
- CONTEXT_SUMMARY_METADATA_KEY,
387
- SUB_AGENT_METADATA_KEY,
388
- ];
389
- /**
390
- * The first reserved key `metadata` carries, or undefined when it carries
391
- * none. Exported so the control API can refuse the request where the
392
- * caller can see it, with the same rule the gateway applies at dispatch.
393
- */
394
- export const reservedSessionMetadataKey = (metadata) => metadata === undefined
395
- ? undefined
396
- : RESERVED_SESSION_METADATA_KEYS.find((key) => Object.prototype.hasOwnProperty.call(metadata, key));
397
- export const ORPHANED_DELEGATION_ERROR = 'stratusd stopped while the turn that delegated this one was still running; the delegation was not resumed. Send the original message again.';
398
63
  /**
399
64
  * The always-on Stratus process. One gateway holds the roster, the durable
400
65
  * session store, the shared event bus, and a pool of runners keyed by
@@ -482,6 +147,7 @@ export const createGateway = (options = {}) => {
482
147
  if (Object.keys(eventSources).length > 0) {
483
148
  openEvents();
484
149
  }
150
+ const startedChannels = [];
485
151
  /**
486
152
  * The started adapter of one kind that speaks for an agent and has the
487
153
  * capability asked for. `carriesOthers` says there were capable adapters
@@ -589,307 +255,15 @@ export const createGateway = (options = {}) => {
589
255
  if (approvalTimeoutMs > MAX_TIMER_DELAY_MS) {
590
256
  warn(`approval timeout ${approvalTimeoutMs}ms is above Node's maximum timer delay; using ${MAX_TIMER_DELAY_MS}ms (~24.8 days)`);
591
257
  }
592
- const pendingApprovals = new Map();
593
- /**
594
- * In-flight `tool.approval-resolved` emissions. `EventBus.emit` awaits its
595
- * subscribers in order, so one async handler ahead of a channel adapter
596
- * suspends the emission before the adapter ever sees it. Nothing today is
597
- * async in front of one — which is exactly the problem: the shutdown
598
- * guarantee below would rest on that staying true, and the first async
599
- * subscriber anyone adds would silently leave live-looking buttons in a
600
- * workspace the daemon has left.
601
- */
602
- const approvalEmissions = new Set();
603
- /**
604
- * Publishes one parked call and settles when somebody answers it, the
605
- * request expires, or the turn is cancelled.
606
- *
607
- * All three endings run through the same `settle`, which removes the
608
- * request from the registry BEFORE resolving. That ordering is the whole
609
- * point: a request that is no longer pending cannot be resolved twice,
610
- * so a click racing the timeout — or arriving after an abort — is refused
611
- * rather than executing a tool for a turn that has already moved on.
612
- */
613
- /**
614
- * The conversation a delegated sub-session's work was asked from, read
615
- * from the store rather than from anything the caller wrote: the root
616
- * session `agent.delegate` recorded, which is where the person waiting
617
- * on the answer is. Undefined for an ordinary session, or a root that no
618
- * longer exists — the request is then asked the way it always was.
619
- */
620
- const delegationOriginOf = async (session) => {
621
- if (!isDelegatedSession(session)) {
622
- return undefined;
623
- }
624
- const rootId = session.metadata?.[ROOT_SESSION_ID_METADATA_KEY];
625
- if (typeof rootId !== 'string' || rootId === session.id) {
626
- return undefined;
627
- }
628
- let root;
629
- try {
630
- root = await store.get(rootId);
631
- }
632
- catch (error) {
633
- warn(`${session.id}: could not read the delegating session ${rootId} to route an approval (${error instanceof Error ? error.message : String(error)})`);
634
- return undefined;
635
- }
636
- if (!root) {
637
- return undefined;
638
- }
639
- return { agentId: root.agent.id, sessionId: root.id, ...(root.metadata ? { metadata: root.metadata } : {}) };
640
- };
641
- /**
642
- * Which sessions are sub-agents, as the host knows it rather than as the
643
- * session says (roadmap 24). Every tool in a slice is handed the same
644
- * mutable Session, so the metadata marker and the id are claims a tool
645
- * can rewrite. The ids are the ones `subAgents.run` minted, held while
646
- * the sub-agent runs. A session object's id is read once, by the
647
- * executor on its first tool call, before any tool has been handed it,
648
- * so a session a tool renamed is still known by the id it ran under.
649
- */
650
- const liveSubAgentIds = new Set();
651
- const idAtFirstCall = new WeakMap();
652
- const hostIdOf = (session) => idAtFirstCall.get(session) ?? session.id;
653
- const isHostSubAgent = (session) => liveSubAgentIds.has(hostIdOf(session)) || isSubAgentSession(session);
654
- const requestApproval = async (request) => {
655
- // A sub-agent asks no one (roadmap 24): its parent turn is the one a
656
- // person is accountable for, and that turn gets the refusal back as a
657
- // result and decides what to do. Denied as undeliverable, since nobody
658
- // was asked, and announced only as a resolution, so no channel renders
659
- // a question nobody may answer.
660
- if (isHostSubAgent(request.session)) {
661
- void bus.emit({
662
- type: 'tool.approval-resolved',
663
- sessionId: request.session.id,
664
- requestId: randomUUID(),
665
- answer: 'deny',
666
- reason: 'undeliverable',
667
- });
668
- return { answer: 'deny' };
669
- }
670
- const delegatedFrom = await delegationOriginOf(request.session);
671
- return requestApprovalFrom(request, delegatedFrom);
672
- };
673
- const requestApprovalFrom = (request, delegatedFrom) => new Promise((resolve) => {
674
- // A shutdown denies what is parked once, at the top of stop(). Turns
675
- // already running keep going through the drain, though, and one of them
676
- // can reach a gated tool AFTER that snapshot — finishing a provider
677
- // call, or moving to the next call in the same response. Parking it
678
- // would deadlock the drain against a question nobody is left to answer:
679
- // stop() waits for the turn, the turn waits for the approval, and the
680
- // approval waits out its timeout — forever when there is none.
681
- if (stopping) {
682
- resolve({ answer: 'deny' });
683
- void bus.emit({
684
- type: 'tool.approval-resolved',
685
- sessionId: request.session.id,
686
- requestId: randomUUID(),
687
- answer: 'deny',
688
- reason: 'cancelled',
689
- });
690
- return;
691
- }
692
- const requestId = randomUUID();
693
- let timer;
694
- const onAbort = () => settle('deny', 'cancelled');
695
- /**
696
- * Settles once the request has finished being announced.
697
- *
698
- * Both emissions go through the same subscriber list, in order, and
699
- * `EventBus.emit` awaits each one — so an async subscriber ahead of a
700
- * channel can hold the announcement while a timeout or abort fires
701
- * behind it, and the channel would hear that a request it has never
702
- * heard of was resolved. It drops that, then renders the announcement
703
- * when it finally arrives, leaving buttons for a settled request that
704
- * nothing will ever retract.
705
- *
706
- * Chaining is the fix rather than remembering orphaned resolutions in
707
- * the channel: a resolution that arrives before its request is not
708
- * something a consumer can act on, so it should not be able to happen.
709
- * Starts resolved, because a turn already aborted never announces at
710
- * all and its denial should not wait on an emission that never runs.
711
- */
712
- let announced = Promise.resolve();
713
- // Read when the request is made, from the scheduler's own records: a
714
- // live firing of a schedule approved with a destination, nothing else.
715
- // Delegated work is not included yet; its sub-session is not a firing.
716
- const scheduleDestination = scheduler.approvalDestinationFor(request.session);
717
- // Work one agent does for another is allowed one call at a time. The
718
- // people asked are the delegating conversation's, and a standing grant
719
- // would widen what the other agent may do on its own work for good —
720
- // so nothing offers **Always allow** here, and an `always` that
721
- // arrives anyway (the control API takes all three answers) runs the
722
- // call once, recorded as exactly that.
723
- const forAnotherAgent = delegatedFrom !== undefined && delegatedFrom.agentId !== request.session.agent.id;
724
- const oneShot = request.oneShot === true || forAnotherAgent;
725
- const always = forAnotherAgent ? undefined : request.always;
726
- const settle = (given, reason, actor) => {
727
- const answer = forAnotherAgent && given === 'always' ? 'once' : given;
728
- // Identity, not presence: a request that already settled is gone from
729
- // the registry, and comparing against this exact function is what
730
- // makes every ending — click, timeout, abort, shutdown — idempotent.
731
- if (pendingApprovals.get(requestId)?.settle !== settle) {
732
- return;
733
- }
734
- pendingApprovals.delete(requestId);
735
- if (timer) {
736
- clearTimeout(timer);
737
- }
738
- request.signal?.removeEventListener('abort', onAbort);
739
- resolve({ answer, ...(actor ? { actor } : {}) });
740
- const emitted = announced.then(() => bus.emit({
741
- type: 'tool.approval-resolved',
742
- sessionId: request.session.id,
743
- requestId,
744
- answer,
745
- reason,
746
- ...(actor ? { actor } : {}),
747
- }));
748
- approvalEmissions.add(emitted);
749
- void emitted.finally(() => approvalEmissions.delete(emitted));
750
- };
751
- // What is left of the window, not a fresh one: a re-asked request
752
- // carries when it originally parked, and the elapsed time counts.
753
- // Computed before registration because the summary below quotes the
754
- // deadline, and a listing that disagreed with the announcing event about
755
- // when a request expires would be worse than one that said nothing.
756
- const alreadyWaitedMs = request.parkedAt ? Date.now() - Date.parse(request.parkedAt) : 0;
757
- const remainingTimeoutMs = effectiveApprovalTimeoutMs > 0
758
- ? effectiveApprovalTimeoutMs - (Number.isFinite(alreadyWaitedMs) ? Math.max(0, alreadyWaitedMs) : 0)
759
- : 0;
760
- const expiresAt = remainingTimeoutMs > 0
761
- ? new Date(Date.now() + remainingTimeoutMs).toISOString()
762
- : undefined;
763
- pendingApprovals.set(requestId, {
764
- settle,
765
- pending: {
766
- requestId,
767
- sessionId: request.session.id,
768
- agentId: request.session.agent.id,
769
- call: request.call,
770
- risk: request.risk,
771
- ...(request.origin !== undefined ? { origin: request.origin } : {}),
772
- ...(oneShot ? { oneShot: true } : {}),
773
- ...(always !== undefined ? { always } : {}),
774
- ...(request.reviewNote !== undefined ? { reviewNote: request.reviewNote } : {}),
775
- parkedAt: request.parkedAt ?? new Date().toISOString(),
776
- ...(expiresAt ? { expiresAt } : {}),
777
- ...(request.session.metadata ? { metadata: request.session.metadata } : {}),
778
- ...(delegatedFrom ? { delegatedFrom } : {}),
779
- ...(scheduleDestination ? { scheduleDestination } : {}),
780
- },
781
- });
782
- if (effectiveApprovalTimeoutMs > 0 && remainingTimeoutMs <= 0) {
783
- // Nothing left to wait with. Settled here rather than armed with a
784
- // zero timer, which `setTimeout` would still run a tick later — long
785
- // enough to announce a request that is already over.
786
- settle('deny', 'timeout');
787
- return;
788
- }
789
- if (remainingTimeoutMs > 0) {
790
- // Deliberately NOT unref'd. A parked turn is real outstanding work,
791
- // and its timer is the only thing that will ever finish it — letting
792
- // the process exit out from under one would abandon the turn instead
793
- // of denying it. Shutdown is handled where it belongs, in stop(),
794
- // which settles every outstanding request before draining.
795
- timer = setTimeout(() => settle('deny', 'timeout'), remainingTimeoutMs);
796
- }
797
- // The abort listener is attached before the request is announced, so a
798
- // turn cancelled while the event is still being delivered still ends
799
- // the request rather than leaving it pending forever.
800
- if (request.signal) {
801
- if (request.signal.aborted) {
802
- settle('deny', 'cancelled');
803
- return;
804
- }
805
- request.signal.addEventListener('abort', onAbort, { once: true });
806
- }
807
- // `announced` is assigned BEFORE the emission starts, because emit runs
808
- // its first subscribers synchronously — one of them settling the
809
- // request inline would otherwise chain onto the already-resolved
810
- // placeholder and race the very announcement it is answering.
811
- let markAnnounced = () => { };
812
- announced = new Promise((resolveAnnounced) => {
813
- markAnnounced = resolveAnnounced;
814
- });
815
- const announcing = bus.emit({
816
- type: 'tool.approval-requested',
817
- sessionId: request.session.id,
818
- agentId: request.session.agent.id,
819
- requestId,
820
- call: request.call,
821
- risk: request.risk,
822
- ...(request.origin !== undefined ? { origin: request.origin } : {}),
823
- ...(oneShot ? { oneShot: true } : {}),
824
- ...(always !== undefined ? { always } : {}),
825
- ...(request.reviewNote !== undefined ? { reviewNote: request.reviewNote } : {}),
826
- ...(request.session.metadata ? { metadata: request.session.metadata } : {}),
827
- ...(delegatedFrom ? { delegatedFrom } : {}),
828
- ...(scheduleDestination ? { scheduleDestination } : {}),
829
- ...(expiresAt ? { expiresAt } : {}),
830
- });
831
- // Drained at shutdown alongside the resolutions: a channel that has not
832
- // yet been told a request exists cannot be told it was denied either.
833
- approvalEmissions.add(announcing);
834
- void announcing.finally(() => {
835
- approvalEmissions.delete(announcing);
836
- markAnnounced();
837
- });
258
+ // `stopping` is read late: it is declared further down, with shutdown.
259
+ const { pendingApprovals, approvalEmissions, liveSubAgentIds, idAtFirstCall, hostIdOf, isHostSubAgent, requestApproval, resolveApproval, suspendWatchdog, approvalWaits, holdApprovalWait, } = createApprovalBroker({
260
+ warn,
261
+ bus,
262
+ store,
263
+ scheduler,
264
+ effectiveApprovalTimeoutMs,
265
+ isStopping: () => stopping,
838
266
  });
839
- const resolveApproval = (input) => {
840
- const parked = pendingApprovals.get(input.requestId);
841
- if (!parked) {
842
- return false;
843
- }
844
- parked.settle(input.answer, input.reason ?? 'decided', input.actor);
845
- return true;
846
- };
847
- /**
848
- * How a turn's watchdog stops its own clock, registered while it runs.
849
- *
850
- * Recording that a tool phase is open is not enough on its own: the
851
- * timer is only re-evaluated when an event arrives, so a phase opened by
852
- * something that emits nothing — an approval policy handed straight to
853
- * the gateway — would leave an already-armed timer running to fire
854
- * mid-wait.
855
- */
856
- const suspendWatchdog = new Map();
857
- /**
858
- * Approval waits in progress, per session.
859
- *
860
- * Deliberately a balanced counter and not a set of call ids. An earlier
861
- * shape had the approval wrapper *open* a phase that the watchdog's
862
- * event observer was expected to close, and that handoff was wrong in
863
- * every way it could be: the observer does not exist when no watchdog is
864
- * armed, so nothing closed it; a policy that threw closed nothing; and a
865
- * stale entry did not merely leak, it suppressed the watchdog for the
866
- * next turn on that session. Four separate exits, each patched in turn.
867
- *
868
- * A counter incremented and decremented in one `finally` cannot have any
869
- * of those bugs, because the thing that opens the phase is the thing
870
- * that closes it, in the same scope, on every path out.
871
- */
872
- const approvalWaits = new Map();
873
- const holdApprovalWait = (sessionId) => {
874
- approvalWaits.set(sessionId, (approvalWaits.get(sessionId) ?? 0) + 1);
875
- // Stop any clock already running: this wait produces no events, so
876
- // nothing else would.
877
- suspendWatchdog.get(sessionId)?.();
878
- let released = false;
879
- return () => {
880
- if (released) {
881
- return;
882
- }
883
- released = true;
884
- const remaining = (approvalWaits.get(sessionId) ?? 1) - 1;
885
- if (remaining > 0) {
886
- approvalWaits.set(sessionId, remaining);
887
- }
888
- else {
889
- approvalWaits.delete(sessionId);
890
- }
891
- };
892
- };
893
267
  const configuredApprovals = typeof options.approvals === 'function'
894
268
  ? options.approvals({
895
269
  request: requestApproval,
@@ -1669,750 +1043,112 @@ export const createGateway = (options = {}) => {
1669
1043
  return own !== undefined && own === source.to ? undefined : directMessageReadRefusal(`${source.channel}:${source.to}`);
1670
1044
  },
1671
1045
  }));
1046
+ const { provideCredential } = createCredentialRequests({
1047
+ env,
1048
+ warn,
1049
+ bus,
1050
+ startedChannels,
1051
+ channelCarrying,
1052
+ registry,
1053
+ sources,
1054
+ unlistedTools,
1055
+ tools,
1056
+ });
1057
+ // dispatchInternal and onSessionChain are declared further down: late-bound.
1058
+ const { bindToolCalls, subAgents } = createSubAgents({
1059
+ options,
1060
+ store,
1061
+ registry,
1062
+ unlistedTools,
1063
+ tools,
1064
+ dispatchInternal: (...args) => dispatchInternal(...args),
1065
+ onSessionChain: (...args) => onSessionChain(...args),
1066
+ liveSubAgentIds,
1067
+ idAtFirstCall,
1068
+ hostIdOf,
1069
+ isHostSubAgent,
1070
+ });
1071
+ const runners = new Map();
1072
+ // Every provider-construction input participates in the key — model,
1073
+ // endpoint, credentials, system prompt, fallback config. A rotated
1074
+ // credential therefore resolves to a NEW runner on the next dispatch
1075
+ // instead of silently reusing a provider built with the old secret.
1076
+ // (Hashed so raw secrets never sit in a map key; the soul is excluded
1077
+ // because provider construction ignores it.)
1078
+ const runnerKeyFor = (config) => {
1079
+ const { soul: _soul, soulPath: _soulPath, fetch: _fetch, ...providerInputs } = config;
1080
+ return createHash('sha256').update(JSON.stringify(providerInputs)).digest('hex');
1081
+ };
1082
+ // A sub-agent's runner has no memory store, so no turn of it injects or
1083
+ // reads the agent's memory; no skill catalog, so its prompt is its
1084
+ // instruction and its tools; and grants nothing to every agent, since
1085
+ // the credential request asks a person.
1086
+ const runnerFor = (config, agentId, subAgent = false) => {
1087
+ // The budget is a provider-construction input too (the harness
1088
+ // runtimes take it as their inner limit), so it is part of the key:
1089
+ // two agents on one model with different budgets get their own runners.
1090
+ const maxTurns = options.maxTurnsFor?.(agentId) ?? options.maxTurns;
1091
+ const autoContinue = options.autoContinueFor?.(agentId);
1092
+ const key = `${runnerKeyFor(config)}:${maxTurns ?? 'default'}:${autoContinue ?? 'off'}${subAgent ? ':sub' : ''}`;
1093
+ const existing = runners.get(key);
1094
+ if (existing) {
1095
+ return existing;
1096
+ }
1097
+ // The Claude Code runtime executes kernel tools by calling back into
1098
+ // the runner built just below — late-bound because the runner needs
1099
+ // the provider first.
1100
+ let hostedRunner;
1101
+ const provider = createRuntimeProvider(config, (error) => warn(`default model failed (${error instanceof Error ? error.message : String(error)}); using the fallback model`), async (session, call, context) => {
1102
+ if (!hostedRunner) {
1103
+ throw new Error('The Stratus runtime is not ready to execute tools yet.');
1104
+ }
1105
+ return hostedRunner.executeHostedToolCall(session, call, context);
1106
+ }, maxTurns,
1107
+ // The sticky-fallback switch is durable the moment it happens, not
1108
+ // when the turn's next save lands — a daemon killed mid-fallback
1109
+ // must not retry the primary on restart.
1110
+ (session) => store.save(session), providerContributions);
1111
+ const runner = new AgentRunner({
1112
+ provider,
1113
+ tools,
1114
+ // Runners are built on dispatch, after the plugins loaded and the
1115
+ // selection below ran, so a contributed executor is in place for
1116
+ // the first turn.
1117
+ executor: bindToolCalls(selectedExecutor ?? createLocalCommandExecutor()),
1118
+ ...(approvals ? { approvals } : {}),
1119
+ store,
1120
+ bus,
1121
+ agents: registry,
1122
+ // Asking a person for a key is harmless to grant: they decide
1123
+ // everything after the question, so no soul has to list it.
1124
+ grantedToEveryAgent: subAgent ? [] : [CREDENTIAL_REQUEST_TOOL_NAME],
1125
+ unlistedTools: unlistedTools(),
1126
+ ...(subAgent ? {} : { skills: skillCatalog, memory }),
1127
+ streaming: true,
1128
+ ...(maxTurns !== undefined ? { maxTurns } : {}),
1129
+ ...(autoContinue !== undefined
1130
+ ? { autoContinue, waitingInput: (sessionId) => (waitingDispatches.get(sessionId) ?? 0) > 0 }
1131
+ : {}),
1132
+ providerCallGuards,
1133
+ });
1134
+ hostedRunner = runner;
1135
+ runners.set(key, runner);
1136
+ return runner;
1137
+ };
1672
1138
  /**
1673
- * Credential requests waiting on a person, by request id.
1139
+ * The runner that hears and never speaks. `observe` runs no turn, so it
1140
+ * needs no provider — and resolving one anyway would refuse an overhear
1141
+ * whenever the agent's runtime cannot currently be built: credentials
1142
+ * missing after a restart, a sign-in that lapsed. That is precisely the
1143
+ * window in which the agent cannot answer and the thread carries on
1144
+ * without it, so it is the window in which hearing matters most, and
1145
+ * what it heard is there once the configuration is repaired.
1674
1146
  *
1675
- * In memory on purpose: a request is a question somebody is being asked
1676
- * now, and a restart that forgot it only means the agent asks again. What
1677
- * matters is that the entry carries everything the answer needs, so a
1678
- * channel quoting the id back decides nothing about where the key goes.
1679
- */
1680
- const credentialRequests = new Map();
1681
- tools.register(createCredentialRequestTool(async (request, session) => {
1682
- const agentId = session.agent.id;
1683
- const soulPath = sources.get(agentId)?.soulPath;
1684
- if (soulPath === undefined) {
1685
- throw new Error(`${session.agent.name} is the built-in agent and has no soul file to grant a credential in. `
1686
- + `Your operator can store one on the machine with \`stratus credential set ${request.name}\`.`);
1687
- }
1688
- if (!CREDENTIAL_NAME_PATTERN.test(request.name)) {
1689
- throw new Error(`${JSON.stringify(request.name)} is not a credential name. Use letters, digits, dots, dashes, or underscores, `
1690
- + 'starting with a letter, the way the tool that needs it spells it: search.apiKey, github.token.');
1691
- }
1692
- // The stricter rule, for a name the model picked: checked before anyone
1693
- // is asked, so an approver never sees a form whose binding the store
1694
- // would refuse, and so a variable that changes what a judged command
1695
- // does (NODE_OPTIONS) is never offered at all.
1696
- if (request.env !== undefined) {
1697
- const problem = credentialVariableProblem(request.env, { requested: true });
1698
- if (problem !== undefined) {
1699
- throw new Error(`${problem} Nothing is pending.`);
1700
- }
1701
- }
1702
- // A key already stored is not something to ask a person for again: the
1703
- // form only adds, so it could only fail. Say what is actually missing.
1704
- const source = await namedCredentialSource(env, agentId, request.name);
1705
- const stored = source !== undefined;
1706
- const granted = session.agent.credentials?.includes(request.name) === true;
1707
- // Binding a key the agent already holds asks for no value, and every
1708
- // form today has a value field, so phase 1 leaves it to the machine.
1709
- const bindOnMachine = request.env !== undefined
1710
- ? ` To have your shell read it as ${request.env}, ask your operator to run \`stratus credential env ${request.name} ${request.env} --agent ${quoteShellArg(agentId)}\` on the machine.`
1711
- : '';
1712
- if (request.replace === true) {
1713
- // Checked here, before anyone is asked, so an approver is never shown
1714
- // a form whose answer could only be refused.
1715
- if (!stored || !granted) {
1716
- throw new Error(`You don't hold ${request.name}, so there is nothing to replace. Ask for it without replace.`);
1717
- }
1718
- if (source === 'environment') {
1719
- throw new Error(`${request.name} comes from the daemon's environment, which a form can't change. `
1720
- + 'Ask your operator to change it where that environment is set.');
1721
- }
1722
- if (source === 'shared' && request.scope !== 'shared') {
1723
- throw new Error(`${request.name} is a shared credential, so replacing it changes the key for every agent granted it. `
1724
- + 'Ask again with scope "shared" if that is what you mean.');
1725
- }
1726
- if (source === 'agent' && request.scope === 'shared') {
1727
- throw new Error(`${request.name} is your own credential, not a shared one. Ask again with scope "agent" to replace it.`);
1728
- }
1729
- if (request.via === 'link') {
1730
- throw new Error('A replacement is asked for in a form an approver answers, never a link. Nothing is pending.');
1731
- }
1732
- }
1733
- else if (stored && granted) {
1734
- throw new Error(`You already hold ${request.name}; the tools that need it use it for you. To rotate it for a new value, ask again with replace: true.${bindOnMachine}`
1735
- + (request.env !== undefined ? ' Nothing is pending.' : ''));
1736
- }
1737
- else if (stored) {
1738
- throw new Error(`${request.name} is already ${source === 'environment' ? "supplied by the daemon's environment" : 'stored'} but not granted to you. `
1739
- + `Ask your operator to add it to the credentials list in your soul; a form would only refuse to store it again.${bindOnMachine}`);
1740
- }
1741
- const onMachine = request.env === undefined
1742
- ? `\`stratus credential set ${request.name}${request.scope === 'agent' ? ` --agent ${quoteShellArg(agentId)}` : ''}\``
1743
- : request.scope === 'agent'
1744
- ? `\`stratus credential set ${request.name} --agent ${quoteShellArg(agentId)} --env ${request.env}\``
1745
- : `\`stratus credential set ${request.name}\`, then \`stratus credential env ${request.name} ${request.env} --agent ${quoteShellArg(agentId)}\``;
1746
- const record = {
1747
- agentId,
1748
- sessionId: session.id,
1749
- name: request.name,
1750
- scope: request.scope,
1751
- soulPath,
1752
- ...(request.replace === true ? { replace: true } : {}),
1753
- ...(request.env !== undefined ? { env: request.env } : {}),
1754
- };
1755
- const announce = async (requestId, via) => {
1756
- await bus.emit({
1757
- type: 'credential.requested',
1758
- sessionId: session.id,
1759
- agentId,
1760
- requestId,
1761
- name: request.name,
1762
- scope: request.scope,
1763
- ...(request.reason !== undefined ? { reason: request.reason } : {}),
1764
- via,
1765
- ...(request.replace === true ? { replace: true } : {}),
1766
- ...(request.env !== undefined ? { env: request.env } : {}),
1767
- ...(session.metadata ? { metadata: session.metadata } : {}),
1768
- });
1769
- };
1770
- // A link only for an agent whose soul lists the tool. Every agent may
1771
- // ask through a form, because a form goes to an approver; a link is a
1772
- // bearer credential the model is handed, and whoever it reaches can
1773
- // store a key and amend the soul. That is the operator's to allow.
1774
- const allowlist = session.agent.tools ?? registry.get(agentId)?.tools;
1775
- // An omitted list grants the safe tools, this one among them, but a
1776
- // link is the operator's to allow by naming it, so only a list says yes.
1777
- // On a home an older build set up an omitted list is every tool, and
1778
- // has always allowed a link there.
1779
- const linkAllowed = allowlist === undefined
1780
- ? unlistedTools() === 'every'
1781
- : matchesToolAllowlist(CREDENTIAL_REQUEST_TOOL_NAME, allowlist);
1782
- // Why no form went up, said to the agent beside the link that replaces
1783
- // it, so it can tell the person why they are getting a link instead.
1784
- let formUnavailable;
1785
- if (request.via !== 'link') {
1786
- // Only a conversation a channel started has anywhere to show a form,
1787
- // and only a channel that renders one can show it: a scheduled or
1788
- // HTTP turn, or a channel with no form, would record a request nobody
1789
- // sees while the agent tells its user the operator was asked.
1790
- const kind = session.metadata?.channel;
1791
- const { adapter } = typeof kind === 'string'
1792
- ? channelCarrying(kind, agentId, (candidate) => candidate.requestCredential !== undefined
1793
- && (request.replace !== true || candidate.credentialReplaceForms === true)
1794
- // A form that can't say the key will be shell-readable must not
1795
- // be the approval for it: a link says it instead.
1796
- && (request.env === undefined || candidate.credentialEnvForms === true))
1797
- : { adapter: undefined };
1798
- if (!adapter?.requestCredential || !session.metadata) {
1799
- formUnavailable = typeof kind === 'string'
1800
- ? `This conversation's channel (${kind}) cannot show your operator a ${request.replace === true ? 'replacement' : 'credential'} form here${request.env !== undefined ? ' that says which variable your shell will read it from' : ''}.`
1801
- : 'This conversation is not in a channel that can show your operator a form.';
1802
- }
1803
- else {
1804
- const requestId = randomUUID();
1805
- credentialRequests.set(requestId, { ...record, via: 'form' });
1806
- // Delivered before it is announced, and dropped if delivery fails: a
1807
- // request whose post never landed has no button to answer it, so
1808
- // keeping it would leave it pending for good behind a tool result
1809
- // saying the operator was asked.
1810
- try {
1811
- await adapter.requestCredential({
1812
- sessionId: session.id,
1813
- agentId,
1814
- requestId,
1815
- name: request.name,
1816
- scope: request.scope,
1817
- ...(request.reason !== undefined ? { reason: request.reason } : {}),
1818
- ...(request.replace === true ? { replace: true } : {}),
1819
- ...(request.env !== undefined ? { env: request.env } : {}),
1820
- metadata: session.metadata,
1821
- });
1822
- await announce(requestId, 'form');
1823
- return { requestId, via: 'form' };
1824
- }
1825
- catch (error) {
1826
- credentialRequests.delete(requestId);
1827
- formUnavailable = `The form could not be shown here: ${error instanceof Error ? error.message : String(error)}`;
1828
- }
1829
- }
1830
- if (request.replace === true) {
1831
- // Never a link: whoever holds one could change a key in use.
1832
- throw new Error(`${formUnavailable} Nothing is pending. A replacement needs a conversation that can show an approver a form.`);
1833
- }
1834
- if (request.via === 'form') {
1835
- throw new Error(`${formUnavailable} Nothing is pending. ${linkAllowed ? 'Ask for a link instead (via: "link"), or ask' : 'Ask'} your operator to store ${request.name} on the machine with ${onMachine} and grant it to you.`);
1836
- }
1837
- }
1838
- if (!linkAllowed) {
1839
- throw new Error(`${formUnavailable !== undefined ? `${formUnavailable} ` : ''}A one-time link needs ${CREDENTIAL_REQUEST_TOOL_NAME} in your soul's tools: list, and it is not there. Nothing is pending. `
1840
- + `Ask in a conversation that can show your operator a form, or ask your operator to store ${request.name} on the machine with ${onMachine} and grant it to you.`);
1841
- }
1842
- // A link where no form can go, from whichever adapter issues them: the
1843
- // control API, which is not tied to any one agent's transport.
1844
- const linker = startedChannels.find((candidate) => candidate.requestCredentialLink !== undefined);
1845
- if (!linker?.requestCredentialLink) {
1846
- throw new Error(`${formUnavailable !== undefined ? `${formUnavailable} ` : ''}No control API is running to issue a link either. `
1847
- + `Ask your operator to store ${request.name} on the machine with ${onMachine} and grant it to you.`);
1848
- }
1849
- const requestId = randomUUID();
1850
- credentialRequests.set(requestId, { ...record, via: 'link' });
1851
- let link;
1852
- try {
1853
- link = await linker.requestCredentialLink({
1854
- sessionId: session.id,
1855
- agentId,
1856
- agentName: session.agent.name,
1857
- requestId,
1858
- name: request.name,
1859
- scope: request.scope,
1860
- ...(request.reason !== undefined ? { reason: request.reason } : {}),
1861
- ...(request.env !== undefined ? { env: request.env } : {}),
1862
- });
1863
- }
1864
- catch (error) {
1865
- credentialRequests.delete(requestId);
1866
- throw new Error(`A link for ${request.name} could not be issued: ${error instanceof Error ? error.message : String(error)} `
1867
- + `Nothing is pending. Ask your operator to store it on the machine with ${onMachine}.`);
1868
- }
1869
- await announce(requestId, 'link');
1870
- return {
1871
- requestId,
1872
- via: 'link',
1873
- url: link.url,
1874
- expiresAt: link.expiresAt,
1875
- ...(link.localOnly === true ? { localOnly: true } : {}),
1876
- ...(formUnavailable !== undefined ? { formUnavailable } : {}),
1877
- };
1878
- }));
1879
- /**
1880
- * Requests an answer is being stored for now. Claimed before the first
1881
- * await: two approvers submitting one form at once would otherwise both
1882
- * start storing, and the second's add-only conflict would retire a
1883
- * request the first was about to settle as added.
1884
- */
1885
- const answering = new Set();
1886
- const provideCredential = async (input) => {
1887
- const request = credentialRequests.get(input.requestId);
1888
- if (!request) {
1889
- return { ok: false, retired: true, message: 'That credential request is no longer pending. Ask the agent to request it again.' };
1890
- }
1891
- if (answering.has(input.requestId)) {
1892
- return { ok: false, message: 'Someone else\'s answer to this request is being stored right now. Wait a moment before trying again.' };
1893
- }
1894
- answering.add(input.requestId);
1895
- try {
1896
- return await answerCredentialRequest(input, request);
1897
- }
1898
- finally {
1899
- answering.delete(input.requestId);
1900
- }
1901
- };
1902
- /**
1903
- * The audit line for a key changed from a chat or a link. After the
1904
- * store, since it records what happened; a line that can't be written is
1905
- * warned about rather than undoing a change that already landed.
1906
- */
1907
- const auditCredentialChange = async (action, request, actor) => {
1908
- try {
1909
- await appendCredentialAudit(env, {
1910
- action,
1911
- name: request.name,
1912
- ...(request.scope === 'agent' ? { agentId: request.agentId } : {}),
1913
- ...(actor !== undefined ? { actor } : {}),
1914
- via: request.via ?? 'form',
1915
- requestedBy: request.agentId,
1916
- ...(request.env !== undefined ? { env: request.env, bindingFor: request.agentId } : {}),
1917
- });
1918
- }
1919
- catch (error) {
1920
- warn(`credentials: ${request.name} was ${action === 'add' ? 'added' : 'replaced'}, but the audit line could not be written (${error instanceof Error ? error.message : String(error)}).`);
1921
- }
1922
- };
1923
- const answerCredentialRequest = async (input, request) => {
1924
- // The agent must still be served from the file the request captured: a
1925
- // repointed default soul (same id, another file) replaces the source,
1926
- // and a grant landing in the old file would report a key the agent now
1927
- // running cannot use.
1928
- const served = sources.get(request.agentId)?.soulPath;
1929
- if (served !== request.soulPath) {
1930
- credentialRequests.delete(input.requestId);
1931
- return {
1932
- ok: false,
1933
- retired: true,
1934
- message: `${request.agentId} is no longer served from ${request.soulPath}, so nothing was stored. Ask ${request.agentId} to request it again.`,
1935
- };
1936
- }
1937
- // The soul the grant will land in must still be the requester's, and is
1938
- // checked before anything is stored: a file reassigned to another agent
1939
- // while the request waited would otherwise receive a key nobody meant
1940
- // to give it. `grantSoulCredential` checks again as it writes.
1941
- let owner;
1942
- try {
1943
- owner = (await loadSoulFile(request.soulPath)).agent.id;
1944
- }
1945
- catch (error) {
1946
- return {
1947
- ok: false,
1948
- message: `${request.agentId}'s soul could not be read (${error instanceof Error ? error.message : String(error)}), so nothing was stored.`,
1949
- };
1950
- }
1951
- if (owner !== request.agentId) {
1952
- credentialRequests.delete(input.requestId);
1953
- return {
1954
- ok: false,
1955
- retired: true,
1956
- message: `${request.soulPath} now belongs to ${owner}, not ${request.agentId}, so nothing was stored. Ask ${request.agentId} to request it again.`,
1957
- };
1958
- }
1959
- const value = formCredentialValue(input.value);
1960
- if (isFlattenedPem(value)) {
1961
- // Refused, not retired: the same form can still take the key whole.
1962
- const command = request.scope === 'agent'
1963
- ? `stratus credential set ${request.name} --agent ${quoteShellArg(request.agentId)} < key.pem`
1964
- : `stratus credential set ${request.name} < key.pem`;
1965
- return {
1966
- ok: false,
1967
- message: 'That looks like a PEM key whose line breaks were lost, so nothing was stored: whatever reads it would fail. '
1968
- + `Paste it again from the original file with its line breaks, or store it on the machine: ${command}.`,
1969
- };
1970
- }
1971
- const store = {
1972
- ...(request.scope === 'agent' ? { agentId: request.agentId } : {}),
1973
- // The binding the form promised, written with the value, always for
1974
- // the agent that asked: consent is per agent even for a shared key.
1975
- ...(request.env !== undefined ? { bind: { agentId: request.agentId, variable: request.env } } : {}),
1976
- };
1977
- if (request.replace === true) {
1978
- // Re-read under the soul lock, so a grant removed while the form
1979
- // waited (a shared key most of all) is never rotated on its behalf.
1980
- let stillGranted;
1981
- try {
1982
- stillGranted = await withSoulFileLock(async () => {
1983
- const soul = await loadSoulFile(request.soulPath);
1984
- if (soul.agent.id !== request.agentId || soul.agent.credentials?.includes(request.name) !== true) {
1985
- return false;
1986
- }
1987
- await replaceNamedCredential(env, { name: request.name, value, ...store });
1988
- return true;
1989
- });
1990
- }
1991
- catch (error) {
1992
- // Removed since the request was made: no value can land now.
1993
- if (error instanceof NamedCredentialMissingError) {
1994
- credentialRequests.delete(input.requestId);
1995
- return { ok: false, retired: true, message: error.message };
1996
- }
1997
- return { ok: false, message: error instanceof Error ? error.message : String(error) };
1998
- }
1999
- if (!stillGranted) {
2000
- credentialRequests.delete(input.requestId);
2001
- return {
2002
- ok: false,
2003
- retired: true,
2004
- message: `${request.agentId} is no longer granted ${request.name} in ${request.soulPath}, so the stored key was kept. Ask ${request.agentId} to request it again if it still needs it.`,
2005
- };
2006
- }
2007
- credentialRequests.delete(input.requestId);
2008
- await auditCredentialChange('replace', request, input.actor);
2009
- await bus.emit({
2010
- type: 'credential.provided',
2011
- sessionId: request.sessionId,
2012
- agentId: request.agentId,
2013
- requestId: input.requestId,
2014
- name: request.name,
2015
- scope: request.scope,
2016
- replace: true,
2017
- ...(request.env !== undefined ? { env: request.env } : {}),
2018
- ...(input.actor !== undefined ? { actor: input.actor } : {}),
2019
- });
2020
- // The soul already lists it: a replace is only asked for a key held.
2021
- return { ok: true, name: request.name, scope: request.scope, agentId: request.agentId, granted: false };
2022
- }
2023
- try {
2024
- await addNamedCredential(env, {
2025
- name: request.name,
2026
- value,
2027
- ...store,
2028
- });
2029
- }
2030
- catch (error) {
2031
- // A name stored since the request was made (another form for the
2032
- // same shared key, or the machine) is final under add-only: no value
2033
- // typed into this form can ever land, so the request is retired
2034
- // rather than left answering every submission with the same refusal.
2035
- if (error instanceof NamedCredentialExistsError) {
2036
- credentialRequests.delete(input.requestId);
2037
- return { ok: false, retired: true, message: error.message };
2038
- }
2039
- // Otherwise refused, not failed: the request stays pending, so a
2040
- // person who typed an empty value can try again from the same form.
2041
- return { ok: false, message: error instanceof Error ? error.message : String(error) };
2042
- }
2043
- credentialRequests.delete(input.requestId);
2044
- await auditCredentialChange('add', request, input.actor);
2045
- // Stored first, granted second: a key nobody may use is recoverable by
2046
- // editing the soul, while a soul naming a key that was never stored
2047
- // fails every call that reaches for it. Announced either way, because
2048
- // the store already changed and a shared key is already usable.
2049
- //
2050
- // The served path is checked again under the soul lock, which roster
2051
- // reloads take too: a default repointed while the key was being
2052
- // written, or while the grant queued behind another soul edit, would
2053
- // otherwise be granted in a file the running agent no longer reads.
2054
- let granted = false;
2055
- let grantError;
2056
- try {
2057
- granted = await grantSoulCredential(request.soulPath, request.name, request.agentId, {
2058
- check: () => {
2059
- const servedNow = sources.get(request.agentId)?.soulPath;
2060
- if (servedNow !== request.soulPath) {
2061
- throw new Error(`${request.agentId} is now served from ${servedNow ?? 'no soul file'}, not ${request.soulPath}`);
2062
- }
2063
- },
2064
- });
2065
- }
2066
- catch (error) {
2067
- grantError = error instanceof Error ? error.message : String(error);
2068
- }
2069
- await bus.emit({
2070
- type: 'credential.provided',
2071
- sessionId: request.sessionId,
2072
- agentId: request.agentId,
2073
- requestId: input.requestId,
2074
- name: request.name,
2075
- scope: request.scope,
2076
- ...(request.env !== undefined ? { env: request.env } : {}),
2077
- ...(input.actor !== undefined ? { actor: input.actor } : {}),
2078
- ...(grantError !== undefined ? { grantError } : {}),
2079
- });
2080
- if (grantError !== undefined) {
2081
- return {
2082
- ok: false,
2083
- retired: true,
2084
- message: `${request.name} was stored, but could not be added to ${request.soulPath} `
2085
- + `(${grantError}). Add it to the soul's credentials list by hand.`,
2086
- };
2087
- }
2088
- return { ok: true, name: request.name, scope: request.scope, agentId: request.agentId, granted };
2089
- };
2090
- tools.register(createDelegateTool({
2091
- registry,
2092
- dispatch: (input) => dispatchInternal({
2093
- sessionId: input.sessionId,
2094
- agentId: input.agent.id,
2095
- userMessage: input.userMessage,
2096
- metadata: input.metadata,
2097
- // A cancelled parent turn cancels its delegated runs too.
2098
- ...(input.signal ? { signal: input.signal } : {}),
2099
- }),
2100
- }));
2101
- // ---- sub-agents (roadmap 24) -------------------------------------------
2102
- /**
2103
- * Running sub-agents per parent session, and who waits for a slot. A
2104
- * parent session runs one turn at a time, so this is per turn.
2105
- */
2106
- const maxSubAgentsPerTurn = options.subAgents?.maxConcurrentPerTurn ?? DEFAULT_SUB_AGENTS_PER_TURN;
2107
- // NaN, 0, a fraction or Infinity would queue every sub-agent forever or
2108
- // defeat the cap, so refuse it here instead of failing later in a turn.
2109
- if (!Number.isInteger(maxSubAgentsPerTurn) || maxSubAgentsPerTurn < 1) {
2110
- throw new Error(`subAgents.maxConcurrentPerTurn must be a whole number of at least 1 (got ${String(maxSubAgentsPerTurn)}).`);
2111
- }
2112
- const subAgentSlots = new Map();
2113
- let subAgentCount = 0;
2114
- /**
2115
- * The session object each in-flight tool call was handed, with that
2116
- * call's turn signal. `subAgents.run` accepts only a parent found here:
2117
- * the session a running tool was given, by identity, never an id, so a
2118
- * plugin cannot start sub-agents of another turn (another agent's
2119
- * included) just because that turn is running. The signal is how the
2120
- * parent's cancel reaches its sub-agents whether or not the plugin
2121
- * passes one.
2122
- */
2123
- const toolCallsInFlight = new Map();
2124
- const bindToolCalls = (inner) => ({
2125
- async execute(call, tool, session, context) {
2126
- if (!idAtFirstCall.has(session)) {
2127
- idAtFirstCall.set(session, session.id);
2128
- }
2129
- const entry = toolCallsInFlight.get(session) ?? [];
2130
- toolCallsInFlight.set(session, entry);
2131
- const signal = context?.signal;
2132
- entry.push(signal);
2133
- try {
2134
- return await inner.execute(call, tool, session, context);
2135
- }
2136
- finally {
2137
- entry.splice(entry.indexOf(signal), 1);
2138
- if (entry.length === 0) {
2139
- toolCallsInFlight.delete(session);
2140
- }
2141
- }
2142
- },
2143
- });
2144
- const subAgentSlot = (parentId, signal) => new Promise((resolve, reject) => {
2145
- const entry = subAgentSlots.get(parentId) ?? { running: 0, waiting: [] };
2146
- subAgentSlots.set(parentId, entry);
2147
- const release = () => {
2148
- entry.running -= 1;
2149
- const next = entry.waiting.shift();
2150
- if (next) {
2151
- next();
2152
- }
2153
- else if (entry.running === 0) {
2154
- subAgentSlots.delete(parentId);
2155
- }
2156
- };
2157
- const take = () => {
2158
- entry.running += 1;
2159
- resolve(release);
2160
- };
2161
- if (entry.running < maxSubAgentsPerTurn) {
2162
- take();
2163
- return;
2164
- }
2165
- const onAbort = () => {
2166
- const at = entry.waiting.indexOf(waiter);
2167
- if (at >= 0) {
2168
- entry.waiting.splice(at, 1);
2169
- }
2170
- reject(abortErrorFor(signal));
2171
- };
2172
- const waiter = () => {
2173
- signal?.removeEventListener('abort', onAbort);
2174
- take();
2175
- };
2176
- entry.waiting.push(waiter);
2177
- signal?.addEventListener('abort', onAbort, { once: true });
2178
- });
2179
- /** Why this slice cannot be a sub-agent's, or undefined when it can. */
2180
- const subAgentSliceProblem = (parent, requested) => {
2181
- if (!Array.isArray(requested) || requested.some((name) => typeof name !== 'string' || name.length === 0)) {
2182
- return 'A sub-agent\'s tools are a list of tool names.';
2183
- }
2184
- // Read the way the runner reads it for the parent's own calls: the
2185
- // session's copy, else the registered agent's.
2186
- const allowlist = parent.agent.tools ?? registry.get(parent.agent.id)?.tools;
2187
- for (const name of requested) {
2188
- if (/[*?]/.test(name)) {
2189
- return `A sub-agent's tools are exact names, and ${name} is a pattern.`;
2190
- }
2191
- if (subAgentNeverGets(name)) {
2192
- return `${name} is never given to a sub-agent, even by an agent that holds it.`;
2193
- }
2194
- const tool = tools.get(name);
2195
- if (!tool) {
2196
- return `There is no tool named ${name}.`;
2197
- }
2198
- // The parent's allowlist as its running turn holds it: a slice can
2199
- // only narrow what that turn may already do. With no list, that is
2200
- // the tools registered as safe (every tool on a home an older build
2201
- // set up), and the slice is held to the same rule: naming a gated
2202
- // tool must not hand it to the sub-agent.
2203
- if (allowlist === undefined) {
2204
- if (unlistedTools() !== 'every' && resolveToolRisk(tool) !== 'safe') {
2205
- return `${name} is not a safe tool, and ${parent.agent.id}'s soul has no tools: list, so it gets only safe tools and a sub-agent of it cannot have ${name}.`;
2206
- }
2207
- }
2208
- else if (!matchesToolAllowlist(name, allowlist)) {
2209
- return `${name} is not one of ${parent.agent.id}'s tools, so a sub-agent of it cannot have it.`;
2210
- }
2211
- }
2212
- return undefined;
2213
- };
2214
- const subAgents = {
2215
- async run(request) {
2216
- // Copied before the first await, and only the copy is read from here
2217
- // on: the plugin keeps its own objects and can change them while this
2218
- // request is checked or waits for a slot, and a slice changed after
2219
- // it was checked must not be the slice the sub-agent runs with.
2220
- const handed = request?.parent;
2221
- const callSignals = handed ? toolCallsInFlight.get(handed) : undefined;
2222
- if (!callSignals) {
2223
- throw new Error('subAgents.run starts sub-agents only for the session your tool was handed, while that call is running. Pass that session object as "parent".');
2224
- }
2225
- // The parent turn's own stop, and the caller's as an extra one: a
2226
- // plugin that passes no signal still has its sub-agents cancelled
2227
- // with the turn.
2228
- const stops = [...callSignals, request.signal].filter((signal) => signal !== undefined);
2229
- const input = {
2230
- parent: { id: hostIdOf(handed) },
2231
- instruction: request?.instruction,
2232
- tools: Array.isArray(request?.tools) ? [...request.tools] : request?.tools,
2233
- model: request?.model,
2234
- signal: stops.length === 0 ? undefined : stops.length === 1 ? stops[0] : AbortSignal.any(stops),
2235
- };
2236
- const parentId = typeof input.parent.id === 'string' ? input.parent.id : '';
2237
- if (!parentId) {
2238
- throw new Error('subAgents.run needs the session of the turn asking.');
2239
- }
2240
- const instruction = typeof input.instruction === 'string' ? input.instruction.trim() : '';
2241
- if (!instruction) {
2242
- throw new Error('subAgents.run needs an instruction.');
2243
- }
2244
- if (input.model !== undefined) {
2245
- throw new Error('A sub-agent\'s model cannot be chosen yet: it runs on its parent\'s. Leave "model" out.');
2246
- }
2247
- const parent = await store.get(parentId);
2248
- if (!parent) {
2249
- throw new Error(`There is no session ${parentId} to start a sub-agent from.`);
2250
- }
2251
- if (isHostSubAgent(handed) || isSubAgentSession(parent)) {
2252
- throw new Error('A sub-agent cannot start sub-agents.');
2253
- }
2254
- if (parent.status !== 'running') {
2255
- throw new Error(`Session ${parentId} is not running a turn, and sub-agents start only from one.`);
2256
- }
2257
- const depth = typeof parent.metadata?.[DELEGATION_DEPTH_METADATA_KEY] === 'number'
2258
- ? parent.metadata[DELEGATION_DEPTH_METADATA_KEY]
2259
- : 0;
2260
- if (depth >= DEFAULT_MAX_DELEGATION_DEPTH) {
2261
- throw new Error(`Delegation depth limit reached (${DEFAULT_MAX_DELEGATION_DEPTH}).`);
2262
- }
2263
- const problem = subAgentSliceProblem(parent, input.tools);
2264
- if (problem) {
2265
- throw new Error(problem);
2266
- }
2267
- if (input.signal?.aborted) {
2268
- throw abortErrorFor(input.signal);
2269
- }
2270
- const release = await subAgentSlot(parent.id, input.signal);
2271
- subAgentCount += 1;
2272
- const sessionId = `${parent.id}${SUB_AGENT_SESSION_ID_MARKER}${depth + 1}:${subAgentCount}-${randomUUID().slice(0, 8)}`;
2273
- // The parent agent, narrowed: its id, provider and credentials, the
2274
- // slice for tools, and nothing else — no instructions (the persona),
2275
- // no skills, no delegates.
2276
- const definition = {
2277
- id: parent.agent.id,
2278
- name: parent.agent.name,
2279
- tools: [...new Set(input.tools)],
2280
- skills: [],
2281
- delegates: [],
2282
- ...(parent.agent.credentials !== undefined ? { credentials: [...parent.agent.credentials] } : {}),
2283
- };
2284
- let session;
2285
- liveSubAgentIds.add(sessionId);
2286
- try {
2287
- session = await onSessionChain(sessionId, () => dispatchInternal({
2288
- sessionId,
2289
- agentId: parent.agent.id,
2290
- userMessage: instruction,
2291
- metadata: {
2292
- [SUB_AGENT_METADATA_KEY]: true,
2293
- [DELEGATION_DEPTH_METADATA_KEY]: depth + 1,
2294
- [DELEGATED_BY_METADATA_KEY]: parent.agent.id,
2295
- [ROOT_SESSION_ID_METADATA_KEY]: typeof parent.metadata?.[ROOT_SESSION_ID_METADATA_KEY] === 'string'
2296
- ? parent.metadata[ROOT_SESSION_ID_METADATA_KEY]
2297
- : parent.id,
2298
- // The instruction is the parent's text, at the parent's label:
2299
- // a tainted parent must not hand a stranger's words to a
2300
- // session that has seen nothing.
2301
- [SENDER_TRUST_METADATA_KEY]: sessionWriteTrust(parent),
2302
- // On the runtime serving the parent's turn: a parent that has
2303
- // switched to its sticky fallback must not have its helpers
2304
- // retry the primary it left.
2305
- ...(parent.metadata?.[FALLBACK_ACTIVE_METADATA_KEY] === true ? { [FALLBACK_ACTIVE_METADATA_KEY]: true } : {}),
2306
- },
2307
- ...(input.signal ? { signal: input.signal } : {}),
2308
- }, [], definition));
2309
- }
2310
- catch (error) {
2311
- if (input.signal?.aborted) {
2312
- throw error;
2313
- }
2314
- // The error text may be the provider quoting what it refused,
2315
- // which is this sub-agent's context: labelled as nobody can vouch.
2316
- return { status: 'error', error: error instanceof Error ? error.message : String(error), sessionId, trust: 'unknown' };
2317
- }
2318
- finally {
2319
- liveSubAgentIds.delete(sessionId);
2320
- release();
2321
- }
2322
- const trust = sessionTrustOf(session);
2323
- if (session.status === 'failed') {
2324
- return { status: 'error', error: session.lastError ?? 'The sub-agent failed.', sessionId, trust: 'unknown' };
2325
- }
2326
- const reply = [...session.messages]
2327
- .reverse()
2328
- .find((message) => message.role === 'assistant' && message.content.trim().length > 0);
2329
- const outcome = reply
2330
- ? { status: 'result', text: reply.content, sessionId, trust }
2331
- : { status: 'empty', sessionId, trust };
2332
- return outcome;
2333
- },
2334
- };
2335
- const runners = new Map();
2336
- // Every provider-construction input participates in the key — model,
2337
- // endpoint, credentials, system prompt, fallback config. A rotated
2338
- // credential therefore resolves to a NEW runner on the next dispatch
2339
- // instead of silently reusing a provider built with the old secret.
2340
- // (Hashed so raw secrets never sit in a map key; the soul is excluded
2341
- // because provider construction ignores it.)
2342
- const runnerKeyFor = (config) => {
2343
- const { soul: _soul, soulPath: _soulPath, fetch: _fetch, ...providerInputs } = config;
2344
- return createHash('sha256').update(JSON.stringify(providerInputs)).digest('hex');
2345
- };
2346
- // A sub-agent's runner has no memory store, so no turn of it injects or
2347
- // reads the agent's memory; no skill catalog, so its prompt is its
2348
- // instruction and its tools; and grants nothing to every agent, since
2349
- // the credential request asks a person.
2350
- const runnerFor = (config, agentId, subAgent = false) => {
2351
- // The budget is a provider-construction input too (the harness
2352
- // runtimes take it as their inner limit), so it is part of the key:
2353
- // two agents on one model with different budgets get their own runners.
2354
- const maxTurns = options.maxTurnsFor?.(agentId) ?? options.maxTurns;
2355
- const autoContinue = options.autoContinueFor?.(agentId);
2356
- const key = `${runnerKeyFor(config)}:${maxTurns ?? 'default'}:${autoContinue ?? 'off'}${subAgent ? ':sub' : ''}`;
2357
- const existing = runners.get(key);
2358
- if (existing) {
2359
- return existing;
2360
- }
2361
- // The Claude Code runtime executes kernel tools by calling back into
2362
- // the runner built just below — late-bound because the runner needs
2363
- // the provider first.
2364
- let hostedRunner;
2365
- const provider = createRuntimeProvider(config, (error) => warn(`default model failed (${error instanceof Error ? error.message : String(error)}); using the fallback model`), async (session, call, context) => {
2366
- if (!hostedRunner) {
2367
- throw new Error('The Stratus runtime is not ready to execute tools yet.');
2368
- }
2369
- return hostedRunner.executeHostedToolCall(session, call, context);
2370
- }, maxTurns,
2371
- // The sticky-fallback switch is durable the moment it happens, not
2372
- // when the turn's next save lands — a daemon killed mid-fallback
2373
- // must not retry the primary on restart.
2374
- (session) => store.save(session), providerContributions);
2375
- const runner = new AgentRunner({
2376
- provider,
2377
- tools,
2378
- // Runners are built on dispatch, after the plugins loaded and the
2379
- // selection below ran, so a contributed executor is in place for
2380
- // the first turn.
2381
- executor: bindToolCalls(selectedExecutor ?? createLocalCommandExecutor()),
2382
- ...(approvals ? { approvals } : {}),
2383
- store,
2384
- bus,
2385
- agents: registry,
2386
- // Asking a person for a key is harmless to grant: they decide
2387
- // everything after the question, so no soul has to list it.
2388
- grantedToEveryAgent: subAgent ? [] : [CREDENTIAL_REQUEST_TOOL_NAME],
2389
- unlistedTools: unlistedTools(),
2390
- ...(subAgent ? {} : { skills: skillCatalog, memory }),
2391
- streaming: true,
2392
- ...(maxTurns !== undefined ? { maxTurns } : {}),
2393
- ...(autoContinue !== undefined
2394
- ? { autoContinue, waitingInput: (sessionId) => (waitingDispatches.get(sessionId) ?? 0) > 0 }
2395
- : {}),
2396
- providerCallGuards,
2397
- });
2398
- hostedRunner = runner;
2399
- runners.set(key, runner);
2400
- return runner;
2401
- };
2402
- /**
2403
- * The runner that hears and never speaks. `observe` runs no turn, so it
2404
- * needs no provider — and resolving one anyway would refuse an overhear
2405
- * whenever the agent's runtime cannot currently be built: credentials
2406
- * missing after a restart, a sign-in that lapsed. That is precisely the
2407
- * window in which the agent cannot answer and the thread carries on
2408
- * without it, so it is the window in which hearing matters most, and
2409
- * what it heard is there once the configuration is repaired.
2410
- *
2411
- * A runner rather than a store write, because the label a speaker
2412
- * lowers the session to is the runner's rule and there is one
2413
- * implementation of it. Same store and bus as every speaking runner, so
2414
- * a message it appends is the message the next turn reads, and its
2415
- * event goes where every other event goes.
1147
+ * A runner rather than a store write, because the label a speaker
1148
+ * lowers the session to is the runner's rule and there is one
1149
+ * implementation of it. Same store and bus as every speaking runner, so
1150
+ * a message it appends is the message the next turn reads, and its
1151
+ * event goes where every other event goes.
2416
1152
  */
2417
1153
  const observer = new AgentRunner({
2418
1154
  provider: {
@@ -2942,720 +1678,60 @@ export const createGateway = (options = {}) => {
2942
1678
  throw error;
2943
1679
  }
2944
1680
  };
2945
- /**
2946
- * `subAgent` is the narrowed definition `subAgents.run` built: the turn
2947
- * runs as that, on the parent's resolved provider, with no memory and no
2948
- * soul. Only that path passes it.
2949
- */
2950
- const dispatchInternal = async (input, absorbedKeys = [], subAgent,
2951
- /**
2952
- * See `RunInput.steer`; passed by `dispatch` for an opted-in caller.
2953
- * Handed the turn's own signal — the one the runner was given, which
2954
- * the watchdog, `abortTurns` and a restart's window abort as well as
2955
- * the callers — so it takes nothing into a turn that is already
2956
- * stopping (card 959).
2957
- */
2958
- steer) => {
2959
- // Re-read on every turn, not once at start: a newer build that stamps
2960
- // the home while this daemon is serving has formats this build does not
2961
- // understand, and a daemon that kept writing into them would be the
2962
- // downgrade the stamp exists to refuse — from the inside. Inside the
2963
- // session chain, so a turn already accepted is counted by the drain
2964
- // before anything else runs. One small file read per turn.
2965
- assertStateCompatible(env);
2966
- // A dispatch whose signal fired while it queued behind another turn
2967
- // must not touch durable state: without this check, the runner would
2968
- // load the session, append the cancelled user message, and save it as
2969
- // failed — polluting future model history with input never processed.
2970
- if (input.signal?.aborted) {
2971
- throw abortErrorFor(input.signal);
2972
- }
2973
- // A session pins its agent: when the caller names none, an existing
2974
- // conversation keeps the agent it was created with even if the
2975
- // configured default soul has changed since — only a brand-new session
2976
- // takes the current default. (Loaded here, before the watchdog, also
2977
- // because whether this turn streams depends on durable session state.)
2978
- const existing = await store.get(input.sessionId);
2979
- const agentId = input.agentId ?? existing?.agent.id ?? await defaultAgentId();
2980
- const source = await refreshAgent(agentId);
2981
- const agent = subAgent ?? source.definition;
2982
- if (subAgent !== undefined && existing) {
2983
- throw new Error(`Session ${input.sessionId} already exists; a sub-agent's session is new every time.`);
2984
- }
2985
- // An archived transcript is the record of a conversation that has moved
2986
- // on. Continuing it would revive a session the operator deliberately
2987
- // left behind, under an id nothing routes to any more.
2988
- const continuedAs = existing?.metadata?.[ROLLED_OVER_TO_METADATA_KEY];
2989
- if (typeof continuedAs === 'string') {
2990
- throw new Error(`Session ${input.sessionId} was rolled over; it is an archived transcript and the conversation continues as ${continuedAs}.`);
2991
- }
2992
- const config = await runtimeForAgent(source);
2993
- const runner = runnerFor(config, source.definition.id, subAgent !== undefined);
2994
- // Which executor the turn's tool calls run through — the built-in
2995
- // under the name `stratus run` has always recorded, a contributed one
2996
- // under its registered name — so a transcript says where a command
2997
- // ran, not only which model asked for it.
2998
- const executorRecord = selectedExecutor && options.executor !== undefined ? options.executor : 'local-command';
2999
- const metadata = {
3000
- provider: config.provider,
3001
- // A contributed provider may leave the model to its own default, in
3002
- // which case there is none to record.
3003
- ...(config.provider !== 'demo' && config.model !== undefined ? { model: config.model } : {}),
3004
- ...input.metadata,
3005
- // After the caller's metadata, and reserved besides: a dispatch that
3006
- // could write it could make a transcript claim a sandbox ran what
3007
- // the host ran.
3008
- [EXECUTOR_METADATA_KEY]: executorRecord,
3009
- };
3010
- if (existing && existing.agent.id !== agent.id) {
3011
- throw crossIdentityError(input.sessionId, existing.agent.id, agent.id);
3012
- }
3013
- // A sub-agent's session is new, but may start on its parent's fallback.
3014
- const { switchedToFallback, effectiveStreams, fallbackStreams } = watchdogStreamsFor(existing ?? (subAgent !== undefined ? { metadata } : undefined), config);
3015
- // Recorded with a keyed message so a restart judges the turn by what it
3016
- // could have run on, not by whatever the config resolves to by then.
3017
- // The fallback counts: a turn can switch to it mid-run. Once a session
3018
- // has switched for good, the primary is never called again, and only
3019
- // the fallback counts.
3020
- const hostedLoop = input.idempotencyKey !== undefined && reachesHarness(config, switchedToFallback);
3021
- return settleLeftForNextStart(input, onTurnRuntime(input.sessionId, { source, config }, () => withWatchdog(input.sessionId, input.signal, effectiveStreams, fallbackStreams, async (signal) => {
3022
- // The preflight above (agent refresh, config resolution, session
3023
- // load) awaited filesystem work; the entry check has long passed.
3024
- // Recheck before the runner touches durable state, or a dispatch
3025
- // cancelled mid-preflight would still persist its user message.
3026
- if (signal.aborted) {
3027
- throw abortErrorFor(signal);
3028
- }
3029
- if (existing) {
3030
- // Refresh the stored definition before resuming, so the turn runs
3031
- // with the current instructions, tools, and credentials.
3032
- existing.agent = agent;
3033
- // A sub-session reached here is being continued from outside — a
3034
- // delegation never resumes its own child; its ids are minted once.
3035
- // The delegating turn is over, so the markers come off: what is
3036
- // now an ordinary conversation must not be closed as an orphan if
3037
- // a later turn of it parks and the daemon dies.
3038
- if (isDelegatedSession(existing) && existing.metadata) {
3039
- existing.metadata = withoutDelegation(existing.metadata);
3040
- }
3041
- // The same for a sub-agent's session, continued as the agent itself:
3042
- // the marker that denies its approvals unasked comes off with the
3043
- // delegation markers it carries.
3044
- if (isSubAgentSession(existing) && existing.metadata) {
3045
- const { [SUB_AGENT_METADATA_KEY]: _subAgent, ...rest } = withoutDelegation(existing.metadata);
3046
- existing.metadata = rest;
3047
- }
3048
- // The executor record follows the daemon, not the session's first
3049
- // turn: a restart that selected another executor runs this turn's
3050
- // commands there, and a session from before the record existed
3051
- // gets one now rather than never.
3052
- if (existing.metadata?.[EXECUTOR_METADATA_KEY] !== executorRecord) {
3053
- existing.metadata = { ...existing.metadata, [EXECUTOR_METADATA_KEY]: executorRecord };
3054
- }
3055
- // The room rides on the turn, but two readers have only the
3056
- // session's: a turn parked on approval and recovered after a
3057
- // restart, and a turn that brings no room of its own (a schedule
3058
- // firing into the thread). Both get the latest room this way, not
3059
- // the first turn's, which a channel made public since would make
3060
- // read as more private than it is. A turn from a channel that
3061
- // could not tell the room (its lookup failed) clears it instead:
3062
- // no room is the generic line, and an old one is a claim.
3063
- if (typeof input.metadata?.channel === 'string') {
3064
- const room = input.metadata[CONVERSATION_METADATA_KEY];
3065
- const { [CONVERSATION_METADATA_KEY]: _stale, ...rest } = existing.metadata ?? {};
3066
- existing.metadata = room !== undefined ? { ...rest, [CONVERSATION_METADATA_KEY]: room } : rest;
3067
- }
3068
- await store.save(existing);
3069
- // The turn's metadata rides along for the sender's trust: without
3070
- // it a resumed turn has no way to say who sent it, and a stranger
3071
- // mentioning the agent in a thread the operator opened would
3072
- // inherit the operator's label.
3073
- return runner.resume({
3074
- sessionId: input.sessionId,
3075
- userMessage: input.userMessage,
3076
- ...(input.images !== undefined ? { images: input.images } : {}),
3077
- ...(input.addressed !== undefined ? { addressed: input.addressed } : {}),
3078
- ...(input.replyOptional === true ? { replyOptional: true } : {}),
3079
- ...(input.idempotencyKey !== undefined ? { idempotencyKey: input.idempotencyKey } : {}),
3080
- ...(absorbedKeys.length > 0 ? { absorbedKeys: [...absorbedKeys] } : {}),
3081
- ...(hostedLoop ? { hostedLoop } : {}),
3082
- ...(input.metadata ? { metadata: input.metadata } : {}),
3083
- runtime: runtimeContextFor(source, config, switchedToFallback, input.metadata),
3084
- ...(steer !== undefined ? { steer: () => steer(signal) } : {}),
3085
- signal,
3086
- });
3087
- }
3088
- return runner.run({
3089
- sessionId: input.sessionId,
3090
- agent,
3091
- userMessage: input.userMessage,
3092
- ...(input.images !== undefined ? { images: input.images } : {}),
3093
- ...(input.addressed !== undefined ? { addressed: input.addressed } : {}),
3094
- ...(input.replyOptional === true ? { replyOptional: true } : {}),
3095
- ...(input.idempotencyKey !== undefined ? { idempotencyKey: input.idempotencyKey } : {}),
3096
- ...(absorbedKeys.length > 0 ? { absorbedKeys: [...absorbedKeys] } : {}),
3097
- ...(hostedLoop ? { hostedLoop } : {}),
3098
- ...(input.earlier !== undefined && input.earlier.length > 0 ? { earlier: input.earlier } : {}),
3099
- metadata,
3100
- runtime: runtimeContextFor(source, config, switchedToFallback, input.metadata, subAgent !== undefined),
3101
- ...(steer !== undefined ? { steer: () => steer(signal) } : {}),
3102
- signal,
3103
- });
3104
- })));
3105
- };
3106
- /**
3107
- * Finishes the turns that were parked on a human when this daemon last
3108
- * went down.
3109
- *
3110
- * Run after the channels are up, deliberately: recovery re-asks, and a
3111
- * request emitted into a gateway with no live adapter would be declined
3112
- * for want of anywhere to put it — turning "your approval survived the
3113
- * restart" into "your approval was denied by the restart".
3114
- *
3115
- * Each session is recovered independently and failures are per-session:
3116
- * one unrecoverable turn must not stop the daemon from serving, or from
3117
- * recovering the others.
3118
- */
3119
- const recoverParkedTurns = async (orphaned) => {
3120
- if (!store.listIdsByStatus) {
3121
- return;
3122
- }
3123
- const parked = await store.listIdsByStatus('pending_approval');
3124
- if (parked.length === 0) {
3125
- return;
3126
- }
3127
- log(`recovering ${parked.length} turn(s) parked on approval`);
3128
- // Started together, not one after another. Each recovery re-asks and
3129
- // then waits on a human, so awaiting them in sequence would mean the
3130
- // second parked turn is not even *announced* until the first is
3131
- // answered — fifteen minutes later by default, and never at all with a
3132
- // zero timeout. They are independent turns; only same-session work is
3133
- // ordered, and onSessionChain already guarantees that.
3134
- await Promise.allSettled(parked.map((sessionId) => recoverOne(sessionId, orphaned.has(sessionId))));
3135
- };
3136
- /**
3137
- * The parked sub-sessions whose delegating turn the last process was
3138
- * still inside — read BEFORE the channels start, for the same reason the
3139
- * abandoned list is.
3140
- *
3141
- * The proof is the parent's transcript: only the runner writes an
3142
- * assistant `agent.delegate` call, and only a delegation in flight leaves
3143
- * one with no result behind it. That evidence does not survive contact
3144
- * with a channel, though — a message for the parent arriving once the
3145
- * adapters are up resumes it, and the resume closes the dangling call as
3146
- * interrupted. Read live from the sweep, the parent would then look as if
3147
- * it awaited nothing and the child would be re-asked after all, which is
3148
- * the bug this exists to stop. So it is read while nothing can dispatch,
3149
- * and the sweep judges from the snapshot; a child whose parent is missing
3150
- * or not inside such a call is left to ordinary recovery.
3151
- *
3152
- * Matched call by call, not parent by parent: the call that awaits a
3153
- * child names it (see `outstandingDelegationFor`), and a call can await
3154
- * only one. Two parked children claiming the same call — one of them a
3155
- * sub-session continued from outside under a version that kept its
3156
- * markers, parked again on something of its own — is an ambiguity this
3157
- * cannot settle, and both are left to ordinary recovery rather than one
3158
- * of them being failed for a delegation that was never its.
3159
- */
3160
- const listOrphanedDelegations = async () => {
3161
- const claimants = new Map();
3162
- if (!store.listIdsByStatus) {
3163
- return new Set();
3164
- }
3165
- for (const sessionId of await store.listIdsByStatus('pending_approval')) {
3166
- const parentId = delegatingSessionIdOf(sessionId);
3167
- if (parentId === undefined) {
3168
- continue;
3169
- }
3170
- const session = await store.get(sessionId);
3171
- if (!session || !isDelegatedSession(session)) {
3172
- continue;
3173
- }
3174
- const parent = await store.get(parentId);
3175
- const callId = parent ? outstandingDelegationFor(parent, session) : undefined;
3176
- if (callId !== undefined) {
3177
- const key = `${parentId}\u0000${callId}`;
3178
- claimants.set(key, [...(claimants.get(key) ?? []), sessionId]);
3179
- }
3180
- }
3181
- return new Set([...claimants.values()].filter((ids) => ids.length === 1).map(([id]) => id));
3182
- };
3183
- const recoverOne = (sessionId, orphaned) => onSessionChain(sessionId, () => recoverParked(sessionId, orphaned));
3184
- /**
3185
- * `recoverOne`'s work, for a caller already on the session's chain: a
3186
- * repeated idempotency key that reached the chain before the parked sweep
3187
- * did, and would otherwise wait on a turn nothing is running yet.
3188
- */
3189
- const recoverParked = async (sessionId, orphaned) => {
3190
- if (stopping) {
3191
- return;
3192
- }
3193
- try {
3194
- const session = await store.get(sessionId);
3195
- if (!session) {
3196
- return;
3197
- }
3198
- const record = readPendingApproval(session);
3199
- // A delegated sub-session is only ever awaited by its parent's
3200
- // `agent.delegate` call, and that parent was running when the last
3201
- // process died — it is in the abandoned sweep, not in this one.
3202
- // Nothing will read the reply, so the parked call is not re-asked:
3203
- // a person would be approving a command that runs for no one.
3204
- //
3205
- // Judged on the re-read, on the chain: the channels are up before
3206
- // this sweep starts, so a message for this id can have taken the
3207
- // chain first and moved the session on — and a turn that finished
3208
- // normally must not be rewritten as failed. The delegation metadata
3209
- // is durable, so it proves what the session is, not what state it
3210
- // is in; the status and the checkpoint say that.
3211
- //
3212
- // Whether it IS an orphan was decided before the channels started
3213
- // (`listOrphanedDelegations`), from the parent's transcript, which
3214
- // a message arriving since may already have rewritten.
3215
- // A sub-agent never parks (nobody is asked for it), and if one ever
3216
- // did, its parent's turn died with the last process: nothing would
3217
- // read its answer, and recovering it would run it as the full agent.
3218
- if (isSubAgentSession(session) && session.status === 'pending_approval') {
3219
- log(`${sessionId}: a sub-agent parked when the last stratusd stopped; failing it`);
3220
- await failParked(session, ABANDONED_TURN_ERROR);
3221
- return;
3222
- }
3223
- if (orphaned && isDelegatedSession(session) && session.status === 'pending_approval' && record) {
3224
- log(`${sessionId}: parked on ${record.call.toolName} for a delegating turn the last stratusd was still running; failing it`);
3225
- await failOrphanedDelegation(session);
3226
- return;
3227
- }
3228
- // A wait that outlived its window while the daemon was down is
3229
- // honoured, not restarted: the request really did go unanswered for
3230
- // the whole time it was configured to wait, and downtime is not a
3231
- // reason to extend a security decision. Measured from when the turn
3232
- // parked against this daemon's timeout — the transport's original
3233
- // deadline was chosen after the checkpoint was written and is gone
3234
- // with the process that chose it. Denying goes through the same
3235
- // recovery path, so the queue behind it still drains.
3236
- const parkedAt = record ? Date.parse(record.parkedAt) : Number.NaN;
3237
- const expired = effectiveApprovalTimeoutMs > 0
3238
- && Number.isFinite(parkedAt)
3239
- && Date.now() - parkedAt >= effectiveApprovalTimeoutMs;
3240
- if (expired) {
3241
- log(`${sessionId}: the approval for ${record?.call.toolName} outlived its window while the daemon was down; denying it`);
3242
- }
3243
- // Resolved the way a dispatch for this agent would resolve it, so a
3244
- // recovered turn finishes on the same provider, model, and
3245
- // credentials it was parked on.
3246
- const source = await refreshAgent(session.agent.id);
3247
- // And on the CURRENT definition, saved before recovery reads the
3248
- // session back. An allowlist is a permission boundary: a soul that
3249
- // dropped a tool while the daemon was down must not have that tool
3250
- // executed by the turn that outlived the change, and
3251
- // `allowedToolsFor` reads the definition frozen into the session
3252
- // ahead of the registry. Dispatch refreshes it the same way before
3253
- // resuming.
3254
- session.agent = source.definition;
3255
- await store.save(session);
3256
- const recoveredConfig = await runtimeForAgent(source);
3257
- const runner = runnerFor(recoveredConfig, source.definition.id);
3258
- // Tracked like a dispatched turn's controller: a recovered turn that
3259
- // runs on past its approval is a turn like any other, so a restart's
3260
- // window has to be able to cut it short with the same reason (or it
3261
- // runs unseen to the second window and the restart reports
3262
- // undrained), and a stop for the session has to find it. Only once
3263
- // the checkpoint has been read on this chain, right before the runner
3264
- // takes it: a stop counted then always ends in the turn's own
3265
- // outcome. A stop sent during the reads above finds nothing running
3266
- // and reaches the session as an ordinary message, queued behind it.
3267
- const controller = new AbortController();
3268
- if (record) {
3269
- trackTurn(sessionId, controller);
3270
- if (abortingWith !== undefined) {
3271
- controller.abort(shutdownAbort());
3272
- }
3273
- }
3274
- try {
3275
- await onTurnRuntime(sessionId, { source, config: recoveredConfig }, () => runner.recoverPendingApproval(sessionId, {
3276
- denyPending: expired,
3277
- runtime: runtimeContextFor(source, recoveredConfig, session.metadata?.[FALLBACK_ACTIVE_METADATA_KEY] === true, session.metadata),
3278
- signal: controller.signal,
3279
- }));
3280
- }
3281
- finally {
3282
- untrackTurn(sessionId, controller);
3283
- // A recovered firing's row outlived the process that would have
3284
- // retired it, and the firing is over either way — a recovery that
3285
- // failed has durably failed its session, which is as finished as
3286
- // completing. The scheduler decides whether there is a row to
3287
- // retire, and only this firing's own.
3288
- const scheduleId = session.metadata?.[SCHEDULE_ID_METADATA_KEY];
3289
- if (typeof scheduleId === 'string') {
3290
- scheduler.retireSpentOneShot(scheduleId, sessionId);
3291
- }
3292
- }
3293
- }
3294
- catch (error) {
3295
- warn(`could not recover parked session ${sessionId}: ${error instanceof Error ? error.message : String(error)}`);
3296
- // A keyed item is a promise to its caller, and one whose recovery
3297
- // cannot even start (its agent gone, its provider unresolvable) would
3298
- // otherwise stay parked for every later start and redelivery to try
3299
- // again. An unkeyed one keeps waiting for a sweep that can, as before.
3300
- const after = await store.get(sessionId).catch(() => undefined);
3301
- const key = after === undefined ? undefined : turnKeyOf(turnMessageOf(after));
3302
- if (after !== undefined && key !== undefined && after.status === 'pending_approval' && workItemState(after, key) === 'unfinished') {
3303
- await failParked(after, ABANDONED_TURN_ERROR).catch(() => { });
3304
- }
3305
- }
3306
- };
3307
- /**
3308
- * Closes an orphaned sub-session the way the abandoned sweep closes its
3309
- * parent: durably failed, with the checkpoint retired so no later sweep
3310
- * can find a call to re-enter, and announced where surfaces can hear it.
3311
- */
3312
- const failOrphanedDelegation = (session) => failParked(session, ORPHANED_DELEGATION_ERROR);
3313
- const failParked = async (session, error) => {
3314
- const metadata = { ...(session.metadata ?? {}) };
3315
- delete metadata[PENDING_APPROVAL_METADATA_KEY];
3316
- session.metadata = metadata;
3317
- recordTurnFailure(session, error);
3318
- await store.save(session);
3319
- await bus.emit({ type: 'session.updated', sessionId: session.id, status: 'failed' });
3320
- await bus.emit({ type: 'session.failed', sessionId: session.id, error });
3321
- };
3322
- /**
3323
- * Turns the last process was still running when it stopped being able to
3324
- * finish them.
3325
- *
3326
- * `pending_approval` is the one window a turn can be picked up from, and
3327
- * it has its own sweep above. Everything else a turn does is saved as
3328
- * `running`: the provider call, the executor, and — on a provider that
3329
- * hosts its own loop — the approval wait too, since a hosted call is
3330
- * deliberately not checkpointed (`executeHostedToolCall` passes
3331
- * `recoverable: false`, because the SDK's inner loop is not something a
3332
- * restart can rebuild). None of those can be re-entered where they
3333
- * stopped, and nothing else sweeps them, so the record would go on
3334
- * claiming the turn is running for as long as the session exists. A turn
3335
- * whose caller gave it an idempotency key is continued from its
3336
- * transcript instead, with its dangling calls closed as interrupted
3337
- * (`settleAbandonedTurn`); the rest are failed.
3338
- *
3339
- * Note what this is *not* for. A graceful stop denies everything parked
3340
- * and drains the turns those denials release, so an operator restarting
3341
- * the daemon normally has nothing here. These are the turns a crash, a
3342
- * SIGKILL, or a lost machine left behind.
3343
- */
3344
- const listAbandonedTurns = async () => {
3345
- if (!store.listIdsByStatus) {
3346
- return [];
3347
- }
3348
- return store.listIdsByStatus('running');
3349
- };
3350
- /**
3351
- * Settles each of them — continued if keyed, otherwise marked failed with
3352
- * a reason that says what happened.
3353
- *
3354
- * Read BEFORE the channels start and applied after, which is the only
3355
- * ordering that is both honest and safe. Reading first means the list is
3356
- * exactly what the last process left, with no chance of a turn this
3357
- * process started being in it. Applying after means the failure is
3358
- * emitted into a gateway whose surfaces are listening, rather than into
3359
- * one where nothing is.
3360
- *
3361
- * The gap between the two is real, though: an inbound message for one of
3362
- * these sessions can arrive as soon as the channels are up. What makes
3363
- * that safe is single-flight — every writer of a session goes through
3364
- * `onSessionChain`, so this runs either wholly before that turn (it
3365
- * fails, and `resume()` then clears `lastError` and starts fresh) or
3366
- * wholly after it (the status has moved off `running`, and the re-read
3367
- * below leaves it alone). There is no interleaving in which this
3368
- * overwrites a live turn.
3369
- *
3370
- * What that invariant does NOT cover is a second daemon on the same
3371
- * database, whose turns are running with no chain of ours in front of
3372
- * them. Two processes sharing a session store is already unsupported —
3373
- * the parked sweep above would re-ask that daemon's approvals for the
3374
- * same reason — and no check here would make it supported.
3375
- */
3376
- const failAbandonedTurns = async (abandoned) => {
3377
- if (abandoned.length === 0) {
3378
- return;
3379
- }
3380
- log(`settling ${abandoned.length} turn(s) left running by the last stratusd`);
3381
- await Promise.allSettled(abandoned.map((id) => failAbandonedTurn(id)));
3382
- };
3383
- const failAbandonedTurn = (id) => onSessionChain(id, async () => {
3384
- if (stopping) {
3385
- return;
3386
- }
3387
- try {
3388
- const session = await store.get(id);
3389
- // Re-read on the chain, because the snapshot is from before the
3390
- // channels came up: a message that arrived since has already taken
3391
- // this session somewhere else, and that turn owns it now.
3392
- if (!session || session.status !== 'running') {
3393
- return;
3394
- }
3395
- await settleAbandonedTurn(session);
3396
- }
3397
- catch (error) {
3398
- warn(`could not settle abandoned turn ${id}: ${error instanceof Error ? error.message : String(error)}`);
3399
- }
1681
+ // `selectedExecutor` is read late: plugins set it when they start.
1682
+ const { dispatchInternal } = createTurnDispatch({
1683
+ options,
1684
+ env,
1685
+ store,
1686
+ getSelectedExecutor: () => selectedExecutor,
1687
+ defaultAgentId,
1688
+ refreshAgent,
1689
+ runtimeContextFor,
1690
+ runnerFor,
1691
+ withWatchdog,
1692
+ onTurnRuntime,
1693
+ runtimeForAgent,
1694
+ watchdogStreamsFor,
1695
+ reachesHarness,
1696
+ settleLeftForNextStart,
1697
+ });
1698
+ // `stopping`, `abortingWith` and `orphanedAtStart` are read late, and
1699
+ // `onSessionChain` is declared further down.
1700
+ const { recoverParkedTurns, listOrphanedDelegations, listAbandonedTurns, failAbandonedTurns, settleRepeat, archivedRepeat, settleUnfinished, } = createRecovery({
1701
+ log,
1702
+ warn,
1703
+ bus,
1704
+ store,
1705
+ scheduler,
1706
+ effectiveApprovalTimeoutMs,
1707
+ refreshAgent,
1708
+ runtimeContextFor,
1709
+ runnerFor,
1710
+ withWatchdog,
1711
+ isStopping: () => stopping,
1712
+ trackTurn,
1713
+ untrackTurn,
1714
+ getAbortingWith: () => abortingWith,
1715
+ shutdownAbort,
1716
+ activeTurns,
1717
+ onTurnRuntime,
1718
+ getOrphanedAtStart: () => orphanedAtStart,
1719
+ runtimeForAgent,
1720
+ watchdogStreamsFor,
1721
+ reachesHarness,
1722
+ onSessionChain: (...args) => onSessionChain(...args),
1723
+ });
1724
+ // `stopping` is read late: it is set at shutdown.
1725
+ const { takeSteered } = createSteering({
1726
+ log,
1727
+ store,
1728
+ releaseQueued,
1729
+ waitingDispatches,
1730
+ isStopping: () => stopping,
1731
+ liveWorkItems,
1732
+ openBatches,
1733
+ archivedRepeat,
3400
1734
  });
3401
- /**
3402
- * A turn left `running` with nothing running it, read on its chain.
3403
- *
3404
- * One whose message carries an idempotency key is a work item its caller
3405
- * was promised would finish, so it is continued from the transcript
3406
- * (`AgentRunner.continueTurn`) — once. A turn that already was, and is
3407
- * here again, took the last process down with it, and continuing it again
3408
- * would do the same on every start; it is failed like an unkeyed one,
3409
- * whose caller was never promised anything and is told to send again.
3410
- */
3411
- const settleAbandonedTurn = async (session) => {
3412
- const message = turnMessageOf(session);
3413
- if (message === undefined || turnKeyOf(message) === undefined) {
3414
- return failAbandoned(session);
3415
- }
3416
- // An answer already in the transcript is completed whatever came before
3417
- // — a second crash, a harness, an agent or provider gone since —
3418
- // because completing it needs none of them and runs nothing again;
3419
- // failing it would throw away a finished reply.
3420
- if (lastResponseIsAnswer(session)) {
3421
- log(`${session.id}: a keyed turn the last stratusd was running had saved its answer; completing it`);
3422
- return completeAnsweredTurn(session, store, bus);
3423
- }
3424
- if (message.continuedAfterCrash === true) {
3425
- return failAbandoned(session);
3426
- }
3427
- if (message.hostedLoop === true) {
3428
- log(`${session.id}: a keyed turn the last stratusd was running could reach a harness, which cannot be continued; failing it`);
3429
- return failAbandoned(session);
3430
- }
3431
- return continueAbandonedTurn(session);
3432
- };
3433
- const failAbandoned = async (session) => {
3434
- recordTurnFailure(session, ABANDONED_TURN_ERROR);
3435
- await store.save(session);
3436
- await bus.emit({ type: 'session.updated', sessionId: session.id, status: 'failed' });
3437
- await bus.emit({ type: 'session.failed', sessionId: session.id, error: ABANDONED_TURN_ERROR });
3438
- return session;
3439
- };
3440
- /**
3441
- * Continues an abandoned turn on the configuration a dispatch for its
3442
- * agent would resolve now, and under the same watchdog — it is a provider
3443
- * call like any turn's. The definition is refreshed and saved first, as
3444
- * `recoverOne` does and for the same reason: an allowlist is a permission
3445
- * boundary, and a tool dropped while the daemon was down must not run.
3446
- *
3447
- * Not on a harness (`runsOnHarness`) — neither one the turn could have
3448
- * run on (`Message.hostedLoop`, recorded when it was accepted) nor one it
3449
- * would continue on. A harness runs its own tool loop inside a single
3450
- * provider call and keeps its own conversation, so the prompt the dead
3451
- * turn sent may already have run tools there that the transcript never
3452
- * saw, and continuing would send it again. That turn is failed as an
3453
- * unkeyed one is — the same answer as before keys existed. (A turn whose
3454
- * answer is already saved never gets here: `settleAbandonedTurn`
3455
- * completes it first.)
3456
- */
3457
- const continueAbandonedTurn = async (session) => {
3458
- // Its parent's turn died with the last process, and continuing it here
3459
- // would run it as the full agent. (Sub-agent turns carry no key today,
3460
- // so this is a guard, not a path.)
3461
- if (isSubAgentSession(session)) {
3462
- return failAbandoned(session);
3463
- }
3464
- // Settled either way: a turn whose agent left the roster, or whose
3465
- // provider no longer resolves, must not stay `running` for every later
3466
- // start and every redelivery to find unfinished and trip over again.
3467
- let source;
3468
- let config;
3469
- try {
3470
- source = await refreshAgent(session.agent.id);
3471
- config = await runtimeForAgent(source);
3472
- }
3473
- catch (error) {
3474
- log(`${session.id}: a keyed turn the last stratusd was running cannot be continued (${error instanceof Error ? error.message : String(error)}); failing it`);
3475
- return failAbandoned(session);
3476
- }
3477
- const { switchedToFallback, effectiveStreams, fallbackStreams } = watchdogStreamsFor(session, config);
3478
- // The message's own record covers the runtimes the turn could have run
3479
- // on; this covers the ones it would continue on — a fallback included —
3480
- // which a changed config may make a harness.
3481
- if (reachesHarness(config, switchedToFallback)) {
3482
- log(`${session.id}: a keyed turn the last stratusd was running would continue on a harness; failing it`);
3483
- return failAbandoned(session);
3484
- }
3485
- log(`${session.id}: continuing a keyed turn the last stratusd was still running`);
3486
- session.agent = source.definition;
3487
- await store.save(session);
3488
- const runner = runnerFor(config, source.definition.id);
3489
- return onTurnRuntime(session.id, { source, config }, () => withWatchdog(session.id, undefined, effectiveStreams, fallbackStreams, async (signal) => await runner.continueTurn(session.id, {
3490
- runtime: runtimeContextFor(source, config, switchedToFallback, session.metadata),
3491
- signal,
3492
- }) ?? session));
3493
- };
3494
- /**
3495
- * A repeated idempotency key that reached the session's chain, so nothing
3496
- * else is running on the session. `undefined` means the key is new here
3497
- * and the dispatch runs as any other; otherwise this is the session to
3498
- * resolve with, and no new turn starts.
3499
- *
3500
- * An unfinished item here has no live turn — the live map would have
3501
- * answered first — so it is one the last process left, reached before
3502
- * the start-up sweep that would have settled it: settled now, the way
3503
- * that sweep would, which then finds it no longer needs anything.
3504
- */
3505
- const settleRepeat = async (sessionId, idempotencyKey, agentId, turnId, onRepeat) => {
3506
- const session = await store.get(sessionId);
3507
- if (session === undefined) {
3508
- return undefined;
3509
- }
3510
- // Before anything is returned: the turn a dispatch would run is refused
3511
- // for naming another agent, and the one it would repeat is no less that
3512
- // agent's — a repeat is not a way to read its transcript.
3513
- if (agentId !== undefined && session.agent.id !== agentId) {
3514
- throw crossIdentityError(sessionId, session.agent.id, agentId);
3515
- }
3516
- const state = workItemState(session, idempotencyKey);
3517
- if (state === undefined) {
3518
- const archived = await archivedRepeat(session, idempotencyKey);
3519
- if (archived !== undefined) {
3520
- onRepeat?.('finished');
3521
- }
3522
- return archived;
3523
- }
3524
- if (state === 'finished') {
3525
- onRepeat?.('finished');
3526
- return session;
3527
- }
3528
- // Run now, on this caller's behalf, so its events are this caller's
3529
- // turn — the adapter waiting on them has no other way to claim them.
3530
- if (turnId !== undefined) {
3531
- activeTurns.set(sessionId, turnId);
3532
- }
3533
- try {
3534
- return await settleUnfinished(session);
3535
- }
3536
- finally {
3537
- if (turnId !== undefined) {
3538
- activeTurns.delete(sessionId);
3539
- }
3540
- }
3541
- };
3542
- /**
3543
- * The archived transcript holding a key the live row does not, when the
3544
- * row is a rollover's fresh one made inside `ROLLOVER_REPEAT_WINDOW_MS`.
3545
- * Only the one rollover back: a second inside the window is a redelivery
3546
- * outliving two deliberate resets, and the chain is not walked for it.
3547
- * Always finished — a rollover refuses a session with a turn in flight.
3548
- */
3549
- const archivedRepeat = async (session, idempotencyKey) => {
3550
- const archivedAs = session.metadata?.[ROLLED_OVER_FROM_METADATA_KEY];
3551
- if (typeof archivedAs !== 'string' || Date.now() - Date.parse(session.createdAt) > ROLLOVER_REPEAT_WINDOW_MS) {
3552
- return undefined;
3553
- }
3554
- const archived = await store.get(archivedAs);
3555
- return archived !== undefined && workItemState(archived, idempotencyKey) !== undefined ? archived : undefined;
3556
- };
3557
- /** An unfinished item with no live turn, settled the way the start-up sweeps would. */
3558
- const settleUnfinished = async (session) => {
3559
- if (session.status === 'pending_approval') {
3560
- await recoverParked(session.id, orphanedAtStart.has(session.id));
3561
- return (await store.get(session.id)) ?? session;
3562
- }
3563
- return settleAbandonedTurn(session);
3564
- };
3565
- /**
3566
- * What a running turn takes in at its next tool step (card 948): the
3567
- * merged-turn batch waiting behind it, whole — leader and absorbed alike
3568
- * — when it is for the same agent. The dispatches it takes join `into`,
3569
- * which the running turn settles with its own outcome (`runSteerable`).
3570
- *
3571
- * A part cancelled while it waited is refused, as `runMerged` refuses it.
3572
- * A part whose key this session (or the archive it rolled over from)
3573
- * already holds is a repeat, and judging a repeat can mean settling a
3574
- * turn — never something to do from inside one — so such a batch is not
3575
- * taken: it waits and runs next, merged, where that is judged. Nor while
3576
- * the gateway is stopping, when the batch is what the drain refuses.
3577
- */
3578
- const takeSteered = async (sessionId, agentId, into,
3579
- /**
3580
- * Asked last, with nothing awaited after it: whether the running turn
3581
- * still takes these callers, joining them to its cancellation if so. A
3582
- * turn cancelled while the store was read takes nothing, and the batch
3583
- * waits and runs next, as it would have.
3584
- */
3585
- admit) => {
3586
- const batch = openBatches.get(sessionId);
3587
- if (stopping || batch === undefined || !batch.firstInLine || batch.agentId !== agentId) {
3588
- return undefined;
3589
- }
3590
- // Until every key in it is checked with nothing awaited since: a message
3591
- // can join while the store is read, and is checked on the next pass; a
3592
- // batch closed meanwhile is no longer this turn's to take.
3593
- const checked = new Set();
3594
- let session;
3595
- for (;;) {
3596
- if (stopping || openBatches.get(sessionId) !== batch) {
3597
- return undefined;
3598
- }
3599
- const unchecked = [batch.leader, ...batch.absorbed].flatMap((part) => (part.input.idempotencyKey !== undefined && !checked.has(part.input.idempotencyKey) ? [part.input.idempotencyKey] : []));
3600
- if (unchecked.length === 0) {
3601
- break;
3602
- }
3603
- session ??= await store.get(sessionId);
3604
- for (const key of unchecked) {
3605
- checked.add(key);
3606
- if (session !== undefined && (workItemState(session, key) !== undefined || await archivedRepeat(session, key) !== undefined)) {
3607
- return undefined;
3608
- }
3609
- }
3610
- }
3611
- const all = [batch.leader, ...batch.absorbed];
3612
- if (!admit(all.filter((part) => part.input.signal?.aborted !== true))) {
3613
- return undefined;
3614
- }
3615
- openBatches.delete(sessionId);
3616
- batch.steered = true;
3617
- // Its place on the chain runs nothing now, so it is not ahead of a batch
3618
- // opening behind it, which can then be steered at a later step.
3619
- releaseQueued(sessionId);
3620
- // Not waiting any more; the leader's place on the chain does not count it again.
3621
- const left = (waitingDispatches.get(sessionId) ?? all.length) - all.length;
3622
- if (left > 0) {
3623
- waitingDispatches.set(sessionId, left);
3624
- }
3625
- else {
3626
- waitingDispatches.delete(sessionId);
3627
- }
3628
- const runnable = [];
3629
- for (const part of all) {
3630
- if (part.input.signal?.aborted) {
3631
- part.reject(abortErrorFor(part.input.signal));
3632
- continue;
3633
- }
3634
- runnable.push(part);
3635
- }
3636
- if (runnable.length === 0) {
3637
- return undefined;
3638
- }
3639
- const keys = runnable.flatMap((part) => (part.input.idempotencyKey !== undefined ? [part.input.idempotencyKey] : []));
3640
- const items = liveWorkItems.get(sessionId);
3641
- for (const key of keys) {
3642
- const item = items?.get(key);
3643
- if (item !== undefined) {
3644
- item.confirmed = true;
3645
- }
3646
- }
3647
- into.push(...runnable);
3648
- const labelled = runnable.some((part) => part.input.metadata !== undefined && SENDER_TRUST_METADATA_KEY in part.input.metadata);
3649
- const lowest = leastTrusted(...runnable.map((part) => senderTrustOf(part.input.metadata)));
3650
- const images = runnable.flatMap((part) => part.input.images ?? []);
3651
- log(`${sessionId}: ${runnable.length} message(s) sent during the running turn reached it at its next step`);
3652
- return {
3653
- userMessage: runnable.map((part) => part.input.userMessage).join(MERGED_SEPARATOR),
3654
- ...(images.length > 0 ? { images } : {}),
3655
- ...(labelled ? { metadata: { [SENDER_TRUST_METADATA_KEY]: lowest } } : {}),
3656
- ...(keys.length > 0 ? { keys } : {}),
3657
- };
3658
- };
3659
1735
  /**
3660
1736
  * Settles a keyed item the last process left unfinished before any other
3661
1737
  * message joins its session. Channels are up before the sweeps reach it,
@@ -3934,448 +2010,25 @@ export const createGateway = (options = {}) => {
3934
2010
  return dispatchInternal(input);
3935
2011
  });
3936
2012
  };
3937
- const dispatch = async (input) => {
3938
- if (stopping) {
3939
- throw refusal();
3940
- }
3941
- // The reserved namespace is the scheduler's alone. A caller that could
3942
- // name a firing's session id — derivable from `GET /schedules` — would
3943
- // otherwise queue a turn of its own behind the firing (single-flight)
3944
- // and run inside the grant, using `message.send` to the schedule's
3945
- // approved destination without its own approval. Refused here, at the
3946
- // one door every external dispatch comes through.
3947
- if (isScheduleSessionId(input.sessionId)) {
3948
- throw new Error(`Session ids beginning with "${SCHEDULE_SESSION_ID_PREFIX}" are reserved for scheduled firings and cannot be dispatched externally.`);
3949
- }
3950
- // The daemon's own bookkeeping keys, by the same logic as the reserved
3951
- // id namespace above: what the runner, the scheduler, and delegation
3952
- // record about a session must be something only they can have written.
3953
- const reserved = reservedSessionMetadataKey(input.metadata);
3954
- if (reserved !== undefined) {
3955
- 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(', ')}.`);
3956
- }
3957
- const { turnId, idempotencyKey } = input;
3958
- if (idempotencyKey !== undefined && (idempotencyKey.length === 0 || idempotencyKey.length > MAX_IDEMPOTENCY_KEY_LENGTH)) {
3959
- throw new Error(`An idempotency key is 1 to ${MAX_IDEMPOTENCY_KEY_LENGTH} characters; this one is ${idempotencyKey.length}.`);
3960
- }
3961
- // A repeat of a dispatch still queued or running is that dispatch: it
3962
- // resolves with it rather than queueing behind it, which would let
3963
- // another caller's turn land in between and then find a finished item.
3964
- // Attached only when it names no other agent than the live dispatch
3965
- // did; otherwise it queues, and `settleRepeat` refuses it there.
3966
- const live = idempotencyKey !== undefined ? liveWorkItems.get(input.sessionId)?.get(idempotencyKey) : undefined;
3967
- // A claim naming an agent other than this delivery does, that the chain
3968
- // has not yet confirmed — this delivery naming nobody, or someone else.
3969
- // The claim may be refused for who it named, and then the item is still
3970
- // unowned: inheriting the refusal would drop a valid message, and
3971
- // queueing behind it would leave this delivery to find the turn that a
3972
- // later valid delivery runs `finished`, and answer it twice. So it
3973
- // waits for the claim to settle and decides then: a claim that stood
3974
- // is repeated, by a delivery naming its owner or nobody, and refuses
3975
- // anyone else; a claim that left the item unowned is dispatched again,
3976
- // where the first delivery to get there runs it and the rest attach.
3977
- if (live !== undefined && !live.confirmed && live.agentId !== undefined && input.agentId !== live.agentId) {
3978
- const claim = live.turn;
3979
- const claimedBy = input.agentId;
3980
- const repeatOf = (owner) => {
3981
- if (claimedBy !== undefined && claimedBy !== owner) {
3982
- throw crossIdentityError(input.sessionId, owner, claimedBy);
3983
- }
3984
- input.onRepeat?.('live');
3985
- };
3986
- return claim.then((session) => {
3987
- repeatOf(session.agent.id);
3988
- return session;
3989
- }, async (error) => {
3990
- // Refused for who it named, or failed before its keyed message was
3991
- // ever written (an agent gone, a provider it could not build): the
3992
- // item is still unowned, and there is no turn to have repeated.
3993
- const stored = error instanceof CrossIdentityError || idempotencyKey === undefined ? undefined : await store.get(input.sessionId);
3994
- const owned = stored !== undefined && idempotencyKey !== undefined && workItemState(stored, idempotencyKey) !== undefined;
3995
- if (owned) {
3996
- repeatOf(stored.agent.id);
3997
- throw error;
3998
- }
3999
- // Its own cleanup runs a tick later; the dispatch below must not
4000
- // find the dead claim still standing and wait on it again.
4001
- const items = liveWorkItems.get(input.sessionId);
4002
- if (idempotencyKey !== undefined && items?.get(idempotencyKey)?.turn === claim) {
4003
- items.delete(idempotencyKey);
4004
- if (items.size === 0) {
4005
- liveWorkItems.delete(input.sessionId);
4006
- }
4007
- }
4008
- return dispatch(input);
4009
- });
4010
- }
4011
- if (live !== undefined && (input.agentId === undefined || input.agentId === live.agentId)) {
4012
- input.onRepeat?.('live');
4013
- return live.turn;
4014
- }
4015
- // The one door every external dispatch comes through, so a pause holds
4016
- // for every channel at once. Work already accepted is not behind this
4017
- // check: a turn running, a redelivery of a message whose turn is live
4018
- // (answered above), a message queued behind it, a delegation or
4019
- // an approval resuming a parked turn all go on.
4020
- if (intakeState.paused) {
4021
- throw new IntakePausedError(intakeState.message ?? DEFAULT_PAUSED_MESSAGE);
4022
- }
4023
- if (heldState) {
4024
- throw new IntakePausedError(HELD_MESSAGE);
4025
- }
4026
- // The live dispatch named no agent, so whose session it is is known
4027
- // only once its turn resolves. Queueing instead would make this a
4028
- // `finished` repeat of a turn the live caller is still rendering — two
4029
- // answers — so it attaches, and is held to the agent it named then:
4030
- // told it repeated only if that agent owns the session, refused if not.
4031
- const claimed = input.agentId;
4032
- if (live !== undefined && live.agentId === undefined && claimed !== undefined) {
4033
- const shared = live.turn;
4034
- // Whichever way the turn ends: a failed one is still that agent's,
4035
- // its owner read from the store, and a repeat of it is still told so
4036
- // — or refused, rather than handed another agent's error.
4037
- const attach = async () => {
4038
- let outcome;
4039
- try {
4040
- outcome = { session: await shared };
4041
- }
4042
- catch (error) {
4043
- outcome = { error };
4044
- }
4045
- const owner = 'session' in outcome ? outcome.session.agent.id : (await store.get(input.sessionId))?.agent.id;
4046
- if (owner !== undefined && owner !== claimed) {
4047
- throw crossIdentityError(input.sessionId, owner, claimed);
4048
- }
4049
- // No owner means the dispatch failed before its session was ever
4050
- // written: there is no turn to have repeated, only its error.
4051
- if (owner !== undefined) {
4052
- input.onRepeat?.('live');
4053
- }
4054
- if ('error' in outcome) {
4055
- throw outcome.error;
4056
- }
4057
- return outcome.session;
4058
- };
4059
- return attach();
4060
- }
4061
- // Merged turns (card 423): an opted-in message behind a running turn
4062
- // joins the batch already waiting, if there is one for the same agent.
4063
- const mergeable = input.onAbsorbed !== undefined && input.addressed !== false;
4064
- const open = openBatches.get(input.sessionId);
4065
- if (mergeable &&
4066
- open !== undefined &&
4067
- open.agentId === input.agentId &&
4068
- fitsReplayBudget([...open.images, ...(input.images ?? [])]) &&
4069
- open.chars + MERGED_SEPARATOR.length + input.userMessage.length <= MERGED_TEXT_BUDGET_CHARS) {
4070
- open.images.push(...(input.images ?? []));
4071
- open.chars += MERGED_SEPARATOR.length + input.userMessage.length;
4072
- waitingDispatches.set(input.sessionId, (waitingDispatches.get(input.sessionId) ?? 0) + 1);
4073
- const joined = new Promise((resolve, reject) => {
4074
- open.absorbed.push({ input, resolve, reject });
4075
- });
4076
- if (idempotencyKey !== undefined) {
4077
- // Provisional, like a queued dispatch's: a redelivery while it waits
4078
- // attaches here; the merged turn confirms it once it owns the key.
4079
- const items = liveWorkItems.get(input.sessionId) ?? new Map();
4080
- if (!items.has(idempotencyKey)) {
4081
- items.set(idempotencyKey, { turn: joined, agentId: input.agentId, confirmed: false, addressed: true });
4082
- liveWorkItems.set(input.sessionId, items);
4083
- }
4084
- void joined.catch(() => { }).finally(() => {
4085
- const current = liveWorkItems.get(input.sessionId);
4086
- if (current?.get(idempotencyKey)?.turn === joined) {
4087
- current.delete(idempotencyKey);
4088
- if (current.size === 0) {
4089
- liveWorkItems.delete(input.sessionId);
4090
- }
4091
- }
4092
- });
4093
- }
4094
- return joined;
4095
- }
4096
- // It cannot join (or its images or text would overflow the batch's
4097
- // budget), so the open batch takes no more: a message arriving
4098
- // after this one must not be answered ahead of it.
4099
- if (open !== undefined) {
4100
- openBatches.delete(input.sessionId);
4101
- }
4102
- // Otherwise it leads a new batch, if it is waiting behind a turn at all:
4103
- // a message to an idle session runs at once, alone.
4104
- const batch = mergeable && sessionChains.has(input.sessionId)
4105
- ? {
4106
- agentId: input.agentId,
4107
- // Settled through `settleLeader`, read when called: it is assigned below.
4108
- leader: { input, resolve: (session) => settleLeader({ session }), reject: (error) => settleLeader({ error }) },
4109
- firstInLine: (queuedWork.get(input.sessionId) ?? 0) === 0,
4110
- absorbed: [],
4111
- steered: false,
4112
- images: [...(input.images ?? [])],
4113
- chars: input.userMessage.length,
4114
- }
4115
- : undefined;
4116
- if (batch !== undefined) {
4117
- openBatches.set(input.sessionId, batch);
4118
- }
4119
- // Reads `turn` only once it is assigned: the registration below runs
4120
- // after it, and the chain's work runs in a later tick (`.then`).
4121
- const claimLiveItem = (key, confirmed) => {
4122
- const items = liveWorkItems.get(input.sessionId) ?? new Map();
4123
- items.set(key, { turn, agentId: input.agentId, confirmed, addressed: input.addressed !== false });
4124
- liveWorkItems.set(input.sessionId, items);
4125
- };
4126
- /**
4127
- * This dispatch's turn with the messages it absorbed (card 423). Each
4128
- * part is judged on its own first, as it would have been alone: one
4129
- * cancelled while it waited is refused, one that repeats a turn the
4130
- * session already holds is that turn. What is left runs as one turn.
4131
- */
4132
- const runMerged = async (absorbed) => {
4133
- const parts = [{ input }, ...absorbed.map((joiner) => ({ input: joiner.input, joiner }))];
4134
- let leader;
4135
- const settle = (part, outcome) => {
4136
- if (part.joiner === undefined) {
4137
- // Settled the moment it is known, not when the chain moves on: a
4138
- // leader cancelled or repeated while it waited must leave its
4139
- // caller's queue before the turn it handed over starts rendering.
4140
- leader = outcome;
4141
- settleLeader(outcome);
4142
- }
4143
- else if ('session' in outcome) {
4144
- part.joiner.resolve(outcome.session);
4145
- }
4146
- else {
4147
- part.joiner.reject(outcome.error);
4148
- }
4149
- };
4150
- const runnable = [];
4151
- for (const part of parts) {
4152
- const own = part.input;
4153
- if (own.signal?.aborted) {
4154
- settle(part, { error: abortErrorFor(own.signal) });
4155
- continue;
4156
- }
4157
- if (own.idempotencyKey !== undefined) {
4158
- try {
4159
- const repeated = await settleRepeat(input.sessionId, own.idempotencyKey, own.agentId, own.turnId, own.onRepeat);
4160
- if (repeated !== undefined) {
4161
- settle(part, { session: repeated });
4162
- continue;
4163
- }
4164
- }
4165
- catch (error) {
4166
- settle(part, { error });
4167
- continue;
4168
- }
4169
- }
4170
- runnable.push(part);
4171
- }
4172
- const head = runnable[0];
4173
- if (head === undefined) {
4174
- return finish(leader);
4175
- }
4176
- // Every runnable key now belongs to this turn.
4177
- const keys = runnable.flatMap((part) => (part.input.idempotencyKey !== undefined ? [part.input.idempotencyKey] : []));
4178
- const items = liveWorkItems.get(input.sessionId);
4179
- for (const key of keys) {
4180
- const item = items?.get(key);
4181
- if (item !== undefined) {
4182
- item.confirmed = true;
4183
- }
4184
- }
4185
- // The turn runs at the least trusted sender in it: everyone's words
4186
- // are in its one message.
4187
- const labelled = runnable.some((part) => part.input.metadata !== undefined && SENDER_TRUST_METADATA_KEY in part.input.metadata);
4188
- const lowest = leastTrusted(...runnable.map((part) => senderTrustOf(part.input.metadata)));
4189
- const newest = runnable[runnable.length - 1].input;
4190
- const metadata = labelled ? { ...newest.metadata, [SENDER_TRUST_METADATA_KEY]: lowest } : newest.metadata;
4191
- const images = runnable.flatMap((part) => part.input.images ?? []);
4192
- const signals = runnable.map((part) => part.input.signal);
4193
- // Cancelled only when every caller in it is: one caller giving up
4194
- // must not take the others' answer with it.
4195
- const signal = signals.every((candidate) => candidate !== undefined)
4196
- ? allAborted(signals)
4197
- : undefined;
4198
- const [primaryKey, ...absorbedKeys] = keys;
4199
- const merged = {
4200
- sessionId: input.sessionId,
4201
- ...(head.input.agentId !== undefined ? { agentId: head.input.agentId } : {}),
4202
- userMessage: runnable.map((part) => part.input.userMessage).join(MERGED_SEPARATOR),
4203
- ...(images.length > 0 ? { images } : {}),
4204
- ...(runnable.every((part) => part.input.replyOptional === true) ? { replyOptional: true } : {}),
4205
- ...(head.input.earlier !== undefined ? { earlier: head.input.earlier } : {}),
4206
- ...(metadata !== undefined ? { metadata } : {}),
4207
- ...(signal !== undefined ? { signal } : {}),
4208
- ...(head.input.turnId !== undefined ? { turnId: head.input.turnId } : {}),
4209
- ...(primaryKey !== undefined ? { idempotencyKey: primaryKey } : {}),
4210
- };
4211
- await settleOrphanedItem(input.sessionId);
4212
- let outcome;
4213
- if (merged.turnId !== undefined) {
4214
- activeTurns.set(input.sessionId, merged.turnId);
4215
- }
4216
- try {
4217
- outcome = { session: await runSteerable(merged, absorbedKeys) };
4218
- }
4219
- catch (error) {
4220
- outcome = { error };
4221
- }
4222
- finally {
4223
- if (merged.turnId !== undefined) {
4224
- activeTurns.delete(input.sessionId);
4225
- }
4226
- }
4227
- for (const part of runnable) {
4228
- if (part !== head) {
4229
- part.input.onAbsorbed?.();
4230
- }
4231
- settle(part, outcome);
4232
- }
4233
- return finish(leader);
4234
- };
4235
- const finish = (outcome) => {
4236
- if (outcome === undefined) {
4237
- throw new Error('A merged turn settled without an outcome for its leader.');
4238
- }
4239
- if ('error' in outcome) {
4240
- throw outcome.error;
4241
- }
4242
- return outcome.session;
4243
- };
4244
- // What this caller is handed. It follows the chain's work, except that a
4245
- // batch leader settles it early (`settle` in `runMerged`).
4246
- let settleLeader = () => { };
4247
- const turn = new Promise((resolve, reject) => {
4248
- settleLeader = (outcome) => ('error' in outcome ? reject(outcome.error) : resolve(outcome.session));
4249
- });
4250
- waitingDispatches.set(input.sessionId, (waitingDispatches.get(input.sessionId) ?? 0) + 1);
4251
- /**
4252
- * Runs this dispatch's turn, handing it whatever queues behind it at
4253
- * each tool step (`takeSteered`), then settles every dispatch it took
4254
- * with the turn's outcome — the outcome they joined. Only for a caller
4255
- * that opted into merged turns: it is the one that renders an answer
4256
- * covering messages other than its own.
4257
- */
4258
- const runSteerable = async (turnInput, absorbedKeys = []) => {
4259
- const steered = [];
4260
- // Cancelled only when every caller in it is, as a merged turn is, the
4261
- // ones steered in later included: one caller giving up must not take
4262
- // the others' answer with it. A turn with a caller who cannot cancel
4263
- // has no signal to join.
4264
- const joint = turnInput.signal !== undefined ? joinableSignal([turnInput.signal]) : undefined;
4265
- // Admitted on the turn's own signal, not only the callers' joint one:
4266
- // the watchdog, `abortTurns` and a restart's window stop the turn
4267
- // without touching its callers, and a batch taken into a turn stopped
4268
- // while the store was read would share its failure instead of running
4269
- // next (card 959). The turn's signal follows the joint one, so this
4270
- // covers every caller giving up too.
4271
- const steer = mergeable
4272
- ? (turnSignal) => takeSteered(input.sessionId, input.agentId, steered, (parts) => {
4273
- if (turnSignal.aborted) {
4274
- return false;
4275
- }
4276
- for (const part of parts) {
4277
- joint?.join(part.input.signal);
4278
- }
4279
- return true;
4280
- })
4281
- : undefined;
4282
- const running = joint !== undefined ? { ...turnInput, signal: joint.signal } : turnInput;
4283
- let outcome;
4284
- try {
4285
- outcome = { session: await dispatchInternal(running, absorbedKeys, undefined, steer) };
4286
- }
4287
- catch (error) {
4288
- outcome = { error };
4289
- }
4290
- for (const part of steered) {
4291
- part.input.onAbsorbed?.();
4292
- if ('session' in outcome) {
4293
- part.resolve(outcome.session);
4294
- }
4295
- else {
4296
- part.reject(outcome.error);
4297
- }
4298
- }
4299
- if ('error' in outcome) {
4300
- throw outcome.error;
4301
- }
4302
- return outcome.session;
4303
- };
4304
- const chained = onSessionChain(input.sessionId, async () => {
4305
- // Steered into the turn that ran ahead of it, which has answered and
4306
- // settled everyone in the batch already — this caller included.
4307
- if (batch?.steered === true) {
4308
- return turn;
4309
- }
4310
- // The batch closes here: anything arriving from now on waits behind
4311
- // this turn and leads the next one.
4312
- if (batch !== undefined && openBatches.get(input.sessionId) === batch) {
4313
- openBatches.delete(input.sessionId);
4314
- }
4315
- const absorbed = batch?.absorbed ?? [];
4316
- // No longer waiting once it reaches the head of the chain — nor is
4317
- // anything it absorbed.
4318
- const left = (waitingDispatches.get(input.sessionId) ?? 1) - 1 - absorbed.length;
4319
- if (left > 0) {
4320
- waitingDispatches.set(input.sessionId, left);
4321
- }
4322
- else {
4323
- waitingDispatches.delete(input.sessionId);
4324
- }
4325
- if (absorbed.length > 0) {
4326
- return runMerged(absorbed);
4327
- }
4328
- // Before `activeTurns` is touched: a repeat that runs nothing must
4329
- // not claim the session's turn id, even for the instant it takes.
4330
- const repeated = idempotencyKey !== undefined ? await settleRepeat(input.sessionId, idempotencyKey, input.agentId, turnId, input.onRepeat) : undefined;
4331
- if (repeated !== undefined) {
4332
- return repeated;
4333
- }
4334
- // Past the identity check with a key the session has not seen, so this
4335
- // dispatch owns the item: it becomes the live entry even if a claimant
4336
- // that was then refused registered first, or a repeat arriving while
4337
- // this turn runs would find nothing to attach to, queue, and read the
4338
- // turn as finished — a second answer beside this caller's.
4339
- if (idempotencyKey !== undefined) {
4340
- claimLiveItem(idempotencyKey, true);
4341
- }
4342
- await settleOrphanedItem(input.sessionId);
4343
- if (turnId === undefined) {
4344
- return runSteerable(input);
4345
- }
4346
- activeTurns.set(input.sessionId, turnId);
4347
- try {
4348
- return await runSteerable(input);
4349
- }
4350
- finally {
4351
- // Unconditional, and safe to be: the chain guarantees no other turn
4352
- // for this session ran between the set above and here, so this can
4353
- // only ever be clearing its own entry.
4354
- activeTurns.delete(input.sessionId);
4355
- }
4356
- }, () => batch?.steered === true);
4357
- // A second settle (a leader already settled early) is a no-op.
4358
- chained.then((session) => settleLeader({ session }), (error) => settleLeader({ error }));
4359
- if (idempotencyKey !== undefined) {
4360
- // A repeat that queued instead of attaching — it named another agent,
4361
- // and is about to be refused — must not displace the turn it repeats.
4362
- // The first registration is provisional; the chain confirms it
4363
- // (`claimLiveItem` above) once the dispatch is known to own the item.
4364
- if (liveWorkItems.get(input.sessionId)?.has(idempotencyKey) !== true) {
4365
- claimLiveItem(idempotencyKey, false);
4366
- }
4367
- void turn.catch(() => { }).finally(() => {
4368
- const current = liveWorkItems.get(input.sessionId);
4369
- if (current?.get(idempotencyKey)?.turn === turn) {
4370
- current.delete(idempotencyKey);
4371
- if (current.size === 0) {
4372
- liveWorkItems.delete(input.sessionId);
4373
- }
4374
- }
4375
- });
4376
- }
4377
- return turn;
4378
- };
2013
+ // `stopping`, `intakeState` and `heldState` are read late.
2014
+ const { dispatch } = createDispatch({
2015
+ store,
2016
+ sessionChains,
2017
+ queuedWork,
2018
+ waitingDispatches,
2019
+ isStopping: () => stopping,
2020
+ getIntakeState: () => intakeState,
2021
+ isHeld: () => heldState,
2022
+ refusal,
2023
+ activeTurns,
2024
+ liveWorkItems,
2025
+ openBatches,
2026
+ settleOrphanedItem,
2027
+ onSessionChain,
2028
+ dispatchInternal,
2029
+ settleRepeat,
2030
+ takeSteered,
2031
+ });
4379
2032
  const observe = async (input) => {
4380
2033
  if (stopping) {
4381
2034
  throw refusal();
@@ -4857,7 +2510,6 @@ export const createGateway = (options = {}) => {
4857
2510
  loadedPlugins = [];
4858
2511
  return released;
4859
2512
  };
4860
- const startedChannels = [];
4861
2513
  /** Set once a shutdown has stopped the started channels; a start that finishes after it stops its own. */
4862
2514
  let channelsClosed = false;
4863
2515
  /** Resolves with `true` when `work` settles before `ms` elapse. */
@@ -5544,4 +3196,8 @@ export const createGateway = (options = {}) => {
5544
3196
  };
5545
3197
  return gateway;
5546
3198
  };
3199
+ // Restart, stop and pause, in their own module (see the top of lifecycle.ts).
3200
+ export { DEFAULT_CHANNEL_RELOAD_TIMEOUT_MS, DEFAULT_PAUSED_MESSAGE, DEFAULT_RESTART_DRAIN_TIMEOUT_MS, DEFAULT_STOP_DRAIN_TIMEOUT_MS, GatewayStoppingError, HELD_MESSAGE, IntakePausedError, MAX_PAUSED_MESSAGE_LENGTH, PauseMessageError, RESTARTING_TURN_ERROR, RestartUnsupportedError, STOPPING_TURN_ERROR, } from "./lifecycle.js";
3201
+ // Reserved session keys and markers, in their own module (see the top of session-keys.ts).
3202
+ export { ABANDONED_TURN_ERROR, DEFAULT_SUB_AGENTS_PER_TURN, EXECUTOR_METADATA_KEY, isSubAgentSession, ORPHANED_DELEGATION_ERROR, RESERVED_SESSION_METADATA_KEYS, reservedSessionMetadataKey, ROLLED_OVER_FROM_METADATA_KEY, ROLLED_OVER_SESSION_ID_MARKER, ROLLED_OVER_TO_METADATA_KEY, SUB_AGENT_METADATA_KEY, SUB_AGENT_SESSION_ID_MARKER, } from "./session-keys.js";
5547
3203
  //# sourceMappingURL=index.js.map