@stratusagent/cli 0.11.2 → 0.11.4

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 (158) hide show
  1. package/README.md +14 -8
  2. package/dist/approvals.d.ts +93 -0
  3. package/dist/approvals.d.ts.map +1 -0
  4. package/dist/approvals.js +368 -0
  5. package/dist/approvals.js.map +1 -0
  6. package/dist/commands/agent-new.d.ts +22 -0
  7. package/dist/commands/agent-new.d.ts.map +1 -0
  8. package/dist/commands/agent-new.js +159 -0
  9. package/dist/commands/agent-new.js.map +1 -0
  10. package/dist/commands/agents.d.ts +4 -0
  11. package/dist/commands/agents.d.ts.map +1 -0
  12. package/dist/commands/agents.js +90 -0
  13. package/dist/commands/agents.js.map +1 -0
  14. package/dist/commands/chat.d.ts +4 -0
  15. package/dist/commands/chat.d.ts.map +1 -0
  16. package/dist/commands/chat.js +227 -0
  17. package/dist/commands/chat.js.map +1 -0
  18. package/dist/commands/credential.d.ts +22 -0
  19. package/dist/commands/credential.d.ts.map +1 -0
  20. package/dist/commands/credential.js +93 -0
  21. package/dist/commands/credential.js.map +1 -0
  22. package/dist/commands/dashboard.d.ts +14 -0
  23. package/dist/commands/dashboard.d.ts.map +1 -0
  24. package/dist/commands/dashboard.js +195 -0
  25. package/dist/commands/dashboard.js.map +1 -0
  26. package/dist/commands/doctor.d.ts +40 -0
  27. package/dist/commands/doctor.d.ts.map +1 -0
  28. package/dist/commands/doctor.js +376 -0
  29. package/dist/commands/doctor.js.map +1 -0
  30. package/dist/commands/grants.d.ts +19 -0
  31. package/dist/commands/grants.d.ts.map +1 -0
  32. package/dist/commands/grants.js +128 -0
  33. package/dist/commands/grants.js.map +1 -0
  34. package/dist/commands/logs.d.ts +9 -0
  35. package/dist/commands/logs.d.ts.map +1 -0
  36. package/dist/commands/logs.js +58 -0
  37. package/dist/commands/logs.js.map +1 -0
  38. package/dist/commands/memory.d.ts +20 -0
  39. package/dist/commands/memory.d.ts.map +1 -0
  40. package/dist/commands/memory.js +110 -0
  41. package/dist/commands/memory.js.map +1 -0
  42. package/dist/commands/plugins.d.ts +103 -0
  43. package/dist/commands/plugins.d.ts.map +1 -0
  44. package/dist/commands/plugins.js +386 -0
  45. package/dist/commands/plugins.js.map +1 -0
  46. package/dist/commands/restart.d.ts +4 -0
  47. package/dist/commands/restart.d.ts.map +1 -0
  48. package/dist/commands/restart.js +25 -0
  49. package/dist/commands/restart.js.map +1 -0
  50. package/dist/commands/schedules.d.ts +11 -0
  51. package/dist/commands/schedules.d.ts.map +1 -0
  52. package/dist/commands/schedules.js +50 -0
  53. package/dist/commands/schedules.js.map +1 -0
  54. package/dist/commands/serve.d.ts +4 -0
  55. package/dist/commands/serve.d.ts.map +1 -0
  56. package/dist/commands/serve.js +633 -0
  57. package/dist/commands/serve.js.map +1 -0
  58. package/dist/commands/service.d.ts +9 -0
  59. package/dist/commands/service.d.ts.map +1 -0
  60. package/dist/commands/service.js +97 -0
  61. package/dist/commands/service.js.map +1 -0
  62. package/dist/commands/session.d.ts +9 -0
  63. package/dist/commands/session.d.ts.map +1 -0
  64. package/dist/commands/session.js +25 -0
  65. package/dist/commands/session.js.map +1 -0
  66. package/dist/commands/setup.d.ts +5 -0
  67. package/dist/commands/setup.d.ts.map +1 -0
  68. package/dist/commands/setup.js +2467 -0
  69. package/dist/commands/setup.js.map +1 -0
  70. package/dist/commands/skills.d.ts +15 -0
  71. package/dist/commands/skills.d.ts.map +1 -0
  72. package/dist/commands/skills.js +272 -0
  73. package/dist/commands/skills.js.map +1 -0
  74. package/dist/commands/template.d.ts +4 -0
  75. package/dist/commands/template.d.ts.map +1 -0
  76. package/dist/commands/template.js +593 -0
  77. package/dist/commands/template.js.map +1 -0
  78. package/dist/commands/update.d.ts +21 -0
  79. package/dist/commands/update.d.ts.map +1 -0
  80. package/dist/commands/update.js +291 -0
  81. package/dist/commands/update.js.map +1 -0
  82. package/dist/daemon.d.ts +74 -0
  83. package/dist/daemon.d.ts.map +1 -0
  84. package/dist/daemon.js +192 -0
  85. package/dist/daemon.js.map +1 -0
  86. package/dist/environment.d.ts +114 -0
  87. package/dist/environment.d.ts.map +1 -0
  88. package/dist/environment.js +2 -0
  89. package/dist/environment.js.map +1 -0
  90. package/dist/events.d.ts +17 -0
  91. package/dist/events.d.ts.map +1 -0
  92. package/dist/events.js +87 -0
  93. package/dist/events.js.map +1 -0
  94. package/dist/help.d.ts +2 -0
  95. package/dist/help.d.ts.map +1 -0
  96. package/dist/help.js +272 -0
  97. package/dist/help.js.map +1 -0
  98. package/dist/index.d.ts +32 -624
  99. package/dist/index.d.ts.map +1 -1
  100. package/dist/index.js +39 -6981
  101. package/dist/index.js.map +1 -1
  102. package/dist/io.d.ts +16 -0
  103. package/dist/io.d.ts.map +1 -0
  104. package/dist/io.js +45 -0
  105. package/dist/io.js.map +1 -0
  106. package/dist/loaders.d.ts +31 -0
  107. package/dist/loaders.d.ts.map +1 -0
  108. package/dist/loaders.js +56 -0
  109. package/dist/loaders.js.map +1 -0
  110. package/dist/main.d.ts +8 -0
  111. package/dist/main.d.ts.map +1 -0
  112. package/dist/main.js +218 -0
  113. package/dist/main.js.map +1 -0
  114. package/dist/npm.d.ts +66 -0
  115. package/dist/npm.d.ts.map +1 -0
  116. package/dist/npm.js +201 -0
  117. package/dist/npm.js.map +1 -0
  118. package/dist/parse.d.ts +245 -0
  119. package/dist/parse.d.ts.map +1 -0
  120. package/dist/parse.js +1098 -0
  121. package/dist/parse.js.map +1 -0
  122. package/dist/plugin-catalog.d.ts +44 -0
  123. package/dist/plugin-catalog.d.ts.map +1 -0
  124. package/dist/plugin-catalog.js +70 -0
  125. package/dist/plugin-catalog.js.map +1 -0
  126. package/dist/preflight.d.ts +60 -0
  127. package/dist/preflight.d.ts.map +1 -0
  128. package/dist/preflight.js +102 -0
  129. package/dist/preflight.js.map +1 -0
  130. package/dist/prompter.d.ts +84 -0
  131. package/dist/prompter.d.ts.map +1 -0
  132. package/dist/prompter.js +347 -0
  133. package/dist/prompter.js.map +1 -0
  134. package/dist/roster.d.ts +57 -0
  135. package/dist/roster.d.ts.map +1 -0
  136. package/dist/roster.js +78 -0
  137. package/dist/roster.js.map +1 -0
  138. package/dist/runtime.d.ts +76 -0
  139. package/dist/runtime.d.ts.map +1 -0
  140. package/dist/runtime.js +372 -0
  141. package/dist/runtime.js.map +1 -0
  142. package/dist/slack.d.ts +26 -0
  143. package/dist/slack.d.ts.map +1 -0
  144. package/dist/slack.js +113 -0
  145. package/dist/slack.js.map +1 -0
  146. package/dist/source.d.ts +24 -0
  147. package/dist/source.d.ts.map +1 -0
  148. package/dist/source.js +80 -0
  149. package/dist/source.js.map +1 -0
  150. package/dist/supervisor.d.ts +69 -0
  151. package/dist/supervisor.d.ts.map +1 -0
  152. package/dist/supervisor.js +218 -0
  153. package/dist/supervisor.js.map +1 -0
  154. package/dist/trusted-config.d.ts +84 -0
  155. package/dist/trusted-config.d.ts.map +1 -0
  156. package/dist/trusted-config.js +159 -0
  157. package/dist/trusted-config.js.map +1 -0
  158. package/package.json +27 -23
@@ -0,0 +1,633 @@
1
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
2
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
3
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
4
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
5
+ });
6
+ }
7
+ return path;
8
+ };
9
+ import { createFileCommandWhitelist, createPermissionPolicy, describeCommandScope, describeOriginScope, describeToolGrant, } from '@stratusagent/permissions';
10
+ import { agentsDirPath, loadChannelCredentials, readProcessEnv, logsDirPath, resolveAgentApprovals, resolveAgentPrincipals, servedRuntimes, discoverIgnoredUntrustedConfig, } from '@stratusagent/state';
11
+ import { createLogWriter, truncateRedirectLogs } from "../logs.js";
12
+ import { describePrincipals, describeApprovers } from "../approvals.js";
13
+ import { HomeHeldError, legacyDaemonServing, describeHeldHome } from "../daemon.js";
14
+ import { formatEvent, eventDetail } from "../events.js";
15
+ import { writeLine } from "../io.js";
16
+ import { loadSlackAdapter, loadControlApi } from "../loaders.js";
17
+ import { warnOnCredentialOverride, warnOnUntrustedConfig, warnOnIgnoredConfig } from "../runtime.js";
18
+ import { RESTART_EXIT_CODE, UNDRAINED_RESTART_EXIT_CODE, SUPERVISED_ENV, BOUND_API_PORT_ENV, defaultSupervisorLink, superviseRestarts, } from "../supervisor.js";
19
+ import { loadServeApprovals, loadServePrincipals, loadServeApi, loadServePlugins, loadServeRuntimeSelection, } from "../trusted-config.js";
20
+ export const runServe = async (command, streams, env = {}) => {
21
+ // First, before anything that can throw. A broken install makes the
22
+ // gateway import below fail, the manager restarts, and the CLI's error
23
+ // and help text append to the redirect file again — the crash loop this
24
+ // bounding exists for. Running it after the import would skip exactly
25
+ // that case.
26
+ if (command.logToFile !== false) {
27
+ await truncateRedirectLogs(logsDirPath(env)).catch(() => undefined);
28
+ }
29
+ // Loaded lazily: the gateway pulls in node:sqlite, which every other CLI
30
+ // command neither needs nor should pay for (Node still prints an
31
+ // experimental warning for it).
32
+ const { createGateway, claimHome, HomeClaimedError } = await import('@stratusagent/gateway');
33
+ // Before anything of this daemon's is written or opened: a second daemon
34
+ // on this home is refused here, with no log line, no store, and no sweep
35
+ // or firing of its own.
36
+ let claim;
37
+ try {
38
+ claim = claimHome(env);
39
+ }
40
+ catch (error) {
41
+ if (error instanceof HomeClaimedError) {
42
+ throw new HomeHeldError(await describeHeldHome(env));
43
+ }
44
+ throw error;
45
+ }
46
+ let outcome;
47
+ try {
48
+ // The one daemon the claim cannot see: one that predates it.
49
+ if (await legacyDaemonServing(env)) {
50
+ throw new HomeHeldError(await describeHeldHome(env));
51
+ }
52
+ outcome = await serveHeldHome(command, streams, env, createGateway);
53
+ }
54
+ finally {
55
+ // After everything: the store closed by stop(), or whatever a throw
56
+ // reached — a preflight that rejected before the gateway existed
57
+ // included, so a host that calls runServe again is not refused for a
58
+ // daemon that never started. And never sooner, so a replacement cannot
59
+ // open the store while this one still writes.
60
+ claim.release();
61
+ }
62
+ // An announced restart, once the home is let go — the replacement claims
63
+ // it next. A daemon the supervisor started asks it for the next one by
64
+ // exiting; the first daemon becomes the supervisor itself. Either way the
65
+ // process the service manager (or the terminal) started is what stays.
66
+ if (outcome.code === RESTART_EXIT_CODE && !readProcessEnv(env)[SUPERVISED_ENV]) {
67
+ return superviseRestarts(command, { ...(outcome.boundApiPort !== undefined ? { boundApiPort: outcome.boundApiPort } : {}), sessions: outcome.sessions }, streams, env);
68
+ }
69
+ return outcome.code;
70
+ };
71
+ const serveHeldHome = async (command, streams, env, createGateway) => {
72
+ // Under a service manager the daemon's stdout is gone, so everything it
73
+ // says is also written to ~/.stratus/logs — that file is what `stratus
74
+ // logs` reads, and the only record of an overnight run.
75
+ const logWriter = command.logToFile === false
76
+ ? undefined
77
+ : createLogWriter({
78
+ dir: logsDirPath(env),
79
+ onError: (error) => writeLine(streams.stderr, `Warning: could not write the log file (${error instanceof Error ? error.message : String(error)}); continuing.`),
80
+ });
81
+ const log = (line) => {
82
+ writeLine(streams.stdout, line);
83
+ void logWriter?.write({ ts: new Date().toISOString(), level: 'info', msg: line });
84
+ };
85
+ const warn = (line) => {
86
+ writeLine(streams.stderr, `Warning: ${line}`);
87
+ void logWriter?.write({ ts: new Date().toISOString(), level: 'warn', msg: line });
88
+ };
89
+ // Agents with stored Slack tokens go live in Slack automatically — the
90
+ // tokens are gateway infrastructure secrets in the channels namespace of
91
+ // ~/.stratus/credentials.json (see @stratusagent/channel-slack's README
92
+ // for the 2-minute per-agent app setup).
93
+ // The approval policy and the Slack approver sets come from the same
94
+ // config block, resolved once here: the daemon must not answer "who can
95
+ // approve this" differently from "is anyone being asked at all".
96
+ const approvalsConfig = await loadServeApprovals(env, command.configPath, warn);
97
+ const approvalMode = command.approvals ?? approvalsConfig.mode ?? 'headless';
98
+ const principalsConfig = await loadServePrincipals(env, command.configPath, warn);
99
+ // Read here rather than inside the gateway for the same reason as the two
100
+ // blocks above: the trust boundary is a property of *which file* said it,
101
+ // and this is where the file precedence is already understood.
102
+ const pluginsConfig = await loadServePlugins(env, command.configPath, warn);
103
+ // Which of the loaded plugins' executors and memory stores serve this
104
+ // daemon — the same trust rule, one step on, and refused by the gateway
105
+ // at start when the name is nobody's.
106
+ const executorName = await loadServeRuntimeSelection('executor', env, command.configPath, warn);
107
+ const memoryStoreName = await loadServeRuntimeSelection('memoryStore', env, command.configPath, warn);
108
+ // Every kind of grant an agent holds — command scopes, origins, standing
109
+ // tool grants — in one file per agent beside its soul, through one store
110
+ // instance for the whole daemon. One instance, because the store caches
111
+ // each file for the life of the process: the control API's list and
112
+ // revoke go through this same object, which is what lets a revoke take
113
+ // effect on the policy's very next decision rather than at the next
114
+ // restart. A whitelist that exists and will not read is said here, once,
115
+ // and never written over — the daemon's log is where a grant list going
116
+ // quiet would otherwise go unnoticed.
117
+ const grantStore = createFileCommandWhitelist({ directory: agentsDirPath(env), warn });
118
+ // The control API is a channel adapter like any other: started after the
119
+ // roster loads, stopped before the store drains. It is optional because
120
+ // installing it is how an operator says they want a port open.
121
+ const apiConfig = await loadServeApi(env, command.configPath, warn);
122
+ const apiWanted = command.api ?? apiConfig.enabled ?? true;
123
+ // Typed as the seam, not as whatever the first push happens to be: the
124
+ // list holds the control API and the Slack adapter alike.
125
+ const controlApiChannels = [];
126
+ /** The API adapter itself, for what the seam does not carry: its address, and the sessions it hands across a restart. */
127
+ let boundApi;
128
+ if (apiWanted) {
129
+ const createControlApi = await loadControlApi();
130
+ // Resolved into locals first. Inlined, `a ?? b !== undefined` parses as
131
+ // `a ?? (b !== undefined)` — so an explicit `--api-port 0`, which is how
132
+ // you ask for any free port, was falsy and fell through to the default.
133
+ // Everything still bound and every test still passed, on whichever port
134
+ // nobody happened to be using.
135
+ const apiHost = command.apiHost ?? apiConfig.host;
136
+ const requestedPort = command.apiPort ?? apiConfig.port;
137
+ // The port the last daemon bound, from the supervisor that started
138
+ // this one — taken only where this daemon's own request is still for
139
+ // any free port. See BOUND_API_PORT_ENV.
140
+ const hinted = Number(readProcessEnv(env)[BOUND_API_PORT_ENV]);
141
+ const apiPort = requestedPort === 0 && Number.isInteger(hinted) && hinted > 0 ? hinted : requestedPort;
142
+ if (createControlApi) {
143
+ const api = createControlApi({
144
+ env,
145
+ ...(apiHost !== undefined ? { host: apiHost } : {}),
146
+ ...(apiPort !== undefined ? { port: apiPort } : {}),
147
+ ...(command.configPath ? { configPath: command.configPath } : {}),
148
+ grants: grantStore,
149
+ log,
150
+ warn,
151
+ });
152
+ boundApi = api;
153
+ controlApiChannels.push(api);
154
+ }
155
+ else if (command.api === true || apiConfig.enabled === true) {
156
+ // Only when someone asked for it explicitly. A daemon that was never
157
+ // told to serve an API should not complain about not having one.
158
+ warn('the control API was requested, but @stratusagent/control-api is not installed. '
159
+ + 'Run `npm install -g @stratusagent/control-api` to bring it online; starting without it.');
160
+ }
161
+ }
162
+ // Listening before anything else is awaited: what the supervisor hands
163
+ // this daemon is queued in the channel until then, and the API keeps it
164
+ // until it is serving.
165
+ const supervised = Boolean(readProcessEnv(env)[SUPERVISED_ENV]);
166
+ const link = env.supervisorLink ?? defaultSupervisorLink;
167
+ if (supervised) {
168
+ link.receive((message) => {
169
+ if (message.type === 'stratusd.sessions') {
170
+ boundApi?.adoptSessions(message.sessions);
171
+ }
172
+ });
173
+ }
174
+ const channelCredentials = await loadChannelCredentials(env);
175
+ const slackAgents = Object.entries(channelCredentials.slack ?? {});
176
+ const channels = [...controlApiChannels];
177
+ // Tracked on its own, never as `channels.length`: the list now holds the
178
+ // control API too, and "can anyone be asked for an approval" is a question
179
+ // about the Slack adapter specifically. Reading it off the list length
180
+ // would tell a daemon with an API and no Slack app that every agent is
181
+ // reachable, and its gated calls would park with nobody rendering them.
182
+ let slackAdapterUp = false;
183
+ if (slackAgents.length > 0) {
184
+ // Channel packages are optional peers: the CLI never bundles a
185
+ // transport nobody asked for (the Slack SDKs alone are ~9 MB). A
186
+ // missing one degrades like a broken app does — the daemon serves
187
+ // every other channel — with an actionable line instead of a
188
+ // module-not-found stack.
189
+ const adapter = await loadSlackAdapter();
190
+ if (adapter) {
191
+ slackAdapterUp = true;
192
+ channels.push(adapter({
193
+ agents: slackAgents.map(([agentId, tokens]) => {
194
+ const route = resolveAgentApprovals(approvalsConfig, agentId);
195
+ const principals = resolveAgentPrincipals(principalsConfig, agentId);
196
+ return {
197
+ agentId,
198
+ appToken: tokens.appToken,
199
+ botToken: tokens.botToken,
200
+ ...(route.slackApprovers ? { approvers: route.slackApprovers } : {}),
201
+ ...(route.slackChannel ? { approvalChannel: route.slackChannel } : {}),
202
+ ...(principals.slackUsers ? { principals: principals.slackUsers } : {}),
203
+ ...(principals.admit ? { admit: principals.admit } : {}),
204
+ };
205
+ }),
206
+ log,
207
+ warn,
208
+ }));
209
+ }
210
+ else {
211
+ warn(`Slack tokens are stored for ${slackAgents.length} agent(s), but @stratusagent/channel-slack is not installed. `
212
+ + 'Run `npm install -g @stratusagent/channel-slack` to bring them online; starting without the Slack channel.');
213
+ }
214
+ }
215
+ // A daemon is the costliest place for a silently demoted subscription:
216
+ // every dispatch for as long as it runs, with nobody watching. The
217
+ // gateway resolves each agent's soul independently on dispatch, so an
218
+ // agent pinning anthropic can pick up an environment key even when the
219
+ // daemon's own default is openai — checking only the default selection
220
+ // would miss exactly the agent that is being billed. Each distinct
221
+ // selection is resolved once here, and duplicate warnings are collapsed
222
+ // so a ten-agent roster does not print ten identical lines.
223
+ {
224
+ const warned = new Set();
225
+ const collect = (line) => {
226
+ if (warned.has(line)) {
227
+ return;
228
+ }
229
+ warned.add(line);
230
+ // Through `warn`, not straight to stderr: under a service manager
231
+ // stderr is gone, and a cost warning that only exists there is
232
+ // invisible in exactly the deployment it matters for. `warn` adds
233
+ // its own prefix, so the one already on the line comes off.
234
+ warn(line.replace(/^Warning: /, ''));
235
+ };
236
+ const captured = {
237
+ stdout: { write: () => true },
238
+ stderr: { write: (chunk) => { collect(chunk.replace(/\n$/, '')); return true; } },
239
+ };
240
+ // The same notice `run` and `chat` give, from config discovery rather
241
+ // than from a resolved runtime: servedRuntimes drops a pass that fails
242
+ // to resolve — a real provider with no usable credential — and that
243
+ // daemon still starts, with nothing saying why the clone's persona is
244
+ // not in force. The per-runtime record below repeats it for a pass
245
+ // that did resolve; the dedupe above collapses the two to one line.
246
+ // `serve` takes no --soul, so the notice names the global config instead.
247
+ warnOnIgnoredConfig(await discoverIgnoredUntrustedConfig(command.configPath ? { configPath: command.configPath } : {}, env), captured, false);
248
+ // A pinned soul does not merely add a provider — the gateway DEMOTES
249
+ // the daemon-wide defaults it outranks, including STRATUS_PROVIDER, so
250
+ // each served runtime is resolved the way a dispatch resolves it.
251
+ for (const served of await servedRuntimes(env, command.configPath)) {
252
+ warnOnUntrustedConfig(served.runtime, captured, false);
253
+ await warnOnCredentialOverride(served.runtime, captured, served.env);
254
+ }
255
+ }
256
+ // The daemon had no policy at all: createGateway falls back to
257
+ // AllowAllApprovalPolicy, so every tool call from every agent
258
+ // auto-approved, indefinitely, with nobody watching. That was survivable
259
+ // while `serve` was something you ran in a terminal you were sitting at;
260
+ // it is not, now that setup installs it under launchd by default.
261
+ //
262
+ // There is no terminal behind a service manager, so the choice is
263
+ // between refusing every gated call (`headless`) and parking the turn to
264
+ // ask through a channel (`remote`). Headless stays the default: a daemon
265
+ // that starts waiting on people who were never told they are on the hook
266
+ // hangs turns instead of refusing them. Every refusal is logged — an
267
+ // unattended denial that appears nowhere reads like an agent that decided
268
+ // not to bother.
269
+ const onDecision = (decision) => {
270
+ if (decision.allowed) {
271
+ // A call that ran because somebody once said "always" is the one
272
+ // allowed decision the trace records: it is how something happened
273
+ // unattended, which is what an incident reconstruction needs to tell
274
+ // apart from a tool that was simply safe. Name and date, never input.
275
+ if (decision.grant) {
276
+ const by = decision.grant.grantedBy ? ` by ${decision.grant.grantedBy}` : '';
277
+ log(`${decision.agentId}: ${decision.toolName} ran under a standing grant (${describeToolGrant(decision.grant)}, granted ${decision.grant.grantedAt}${by}) (session ${decision.sessionId})`);
278
+ }
279
+ return;
280
+ }
281
+ warn(`${decision.agentId}: ${decision.reason} (session ${decision.sessionId})`);
282
+ };
283
+ // A factory, not a policy: remote mode parks turns on a transport the
284
+ // gateway owns, so the policy cannot exist until the gateway is building
285
+ // it. Headless takes the same path so there is one construction site.
286
+ // The command-scope engine, which only has a caller once a shell pack is
287
+ // installed. Wired unconditionally because it costs nothing without one:
288
+ // a tool that carries no command string is judged by its risk exactly as
289
+ // before. The whitelist lives beside the agent's soul, per agent.
290
+ const commands = {
291
+ whitelist: grantStore,
292
+ onScopeRemembered: ({ agentId, scope }) => {
293
+ // An approval that widens what runs unattended, for every future
294
+ // session, is precisely the decision that must not be the one leaving
295
+ // no trace.
296
+ log(`${agentId}: "${describeCommandScope(scope)}" now runs without asking`);
297
+ },
298
+ };
299
+ // The origin-scope engine, whose grants live in the same file as the
300
+ // command scopes: one place an operator looks to see what an agent may
301
+ // do unattended. Wired unconditionally for the same reason — a tool that
302
+ // names no origin is judged by its risk exactly as before.
303
+ const origins = {
304
+ whitelist: grantStore,
305
+ onScopeRemembered: ({ agentId, scope }) => {
306
+ log(`${agentId}: ${describeOriginScope(scope)} is now acted on without asking`);
307
+ },
308
+ };
309
+ const approvals = (transport) => {
310
+ // The standing-grant engine, in the same file again — and the tool's
311
+ // contributor from the gateway, so a grant records which package's
312
+ // tool the operator said yes to and stops applying when that changes.
313
+ const grants = {
314
+ store: grantStore,
315
+ contributorOf: transport.contributorOf,
316
+ onGranted: ({ agentId, grant }) => {
317
+ log(`${agentId}: ${describeToolGrant(grant)} now runs without asking, until revoked${grant.grantedBy ? ` (granted by ${grant.grantedBy})` : ''}`);
318
+ },
319
+ };
320
+ return createPermissionPolicy(
321
+ // The destination scope rides along in BOTH modes — it is what lets a
322
+ // scheduled turn report to the channel a human approved with the
323
+ // schedule, and headless (where every other gated call is refused) is
324
+ // exactly the deployment it exists for. So do the grants: a standing
325
+ // grant is the only path a scope-less gated tool has to running
326
+ // unattended, and headless is where that matters.
327
+ approvalMode === 'remote'
328
+ ? { mode: 'remote', request: transport.request, onDecision, commands, origins, grants, destinations: transport.destinations }
329
+ : { mode: 'headless', onDecision, commands, origins, grants, destinations: transport.destinations });
330
+ };
331
+ if (approvalMode === 'remote') {
332
+ // Only agents whose channel actually came up can be asked: tokens on
333
+ // disk with the Slack package missing means nothing renders the
334
+ // request, and the turn discovers that by hanging.
335
+ const askable = slackAdapterUp ? slackAgents.map(([agentId]) => agentId) : [];
336
+ log(`approvals: remote — gated calls are parked and asked in Slack (${describeApprovers(approvalsConfig, askable)})`);
337
+ }
338
+ if (slackAdapterUp) {
339
+ // Said at startup, because the failure it names is silent otherwise: a
340
+ // daemon with no principals serves every Slack message as `unknown`,
341
+ // and the operator finds out when their agent's memory reads that way.
342
+ log(`provenance: ${describePrincipals(principalsConfig, slackAgents.map(([agentId]) => agentId))}`);
343
+ }
344
+ /**
345
+ * The one signal handler this daemon has, for the whole of its life —
346
+ * installed by the wait below, removed in the finally after the drain.
347
+ * See the wait for why it is never `once` and never removed sooner.
348
+ */
349
+ let stopSignal;
350
+ let repeatWarned = false;
351
+ // The gateway's restart hands the process back here once it has stopped:
352
+ // the outcome is kept and the wait below released, and the tail of this
353
+ // function decides what "come back" means for this process.
354
+ let restart;
355
+ /** A signal or the host's abort asked for a stop — set when the wait ends for that reason. */
356
+ let stopRequested = false;
357
+ let requestShutdown = () => { };
358
+ const shutdownRequested = new Promise((resolve) => {
359
+ requestShutdown = resolve;
360
+ });
361
+ const gateway = createGateway({
362
+ env,
363
+ approvals,
364
+ onRestart: (outcome) => {
365
+ restart = outcome;
366
+ requestShutdown();
367
+ },
368
+ ...(Object.keys(pluginsConfig).length > 0
369
+ ? {
370
+ plugins: pluginsConfig,
371
+ // Resolved from *here*, not from inside the gateway.
372
+ // `import.meta.resolve` answers relative to the module that calls
373
+ // it, and a plugin is installed alongside the thing the operator
374
+ // installed — this CLI. Letting the gateway resolve from its own
375
+ // location works only where the layout happens to be flat.
376
+ pluginHost: {
377
+ resolve: (specifier) => import.meta.resolve(specifier),
378
+ import: (specifier) => import(__rewriteRelativeImportExtension(specifier)),
379
+ },
380
+ }
381
+ : {}),
382
+ ...(executorName !== undefined ? { executor: executorName } : {}),
383
+ ...(memoryStoreName !== undefined ? { memoryStore: memoryStoreName } : {}),
384
+ ...(approvalsConfig.timeoutMs !== undefined ? { approvalTimeoutMs: approvalsConfig.timeoutMs } : {}),
385
+ ...(command.configPath ? { selection: { configPath: command.configPath } } : {}),
386
+ ...(command.idleTimeoutMs !== undefined ? { idleTimeoutMs: command.idleTimeoutMs } : {}),
387
+ ...(channels.length > 0 ? { channels } : {}),
388
+ // The Slack adapter is host-wired, so its (agent, kind) claims are
389
+ // declared here; a plugin channel claiming one of them is refused at
390
+ // load rather than started beside it.
391
+ ...(slackAdapterUp ? { hostChannelClaims: [{ kind: 'slack', agents: slackAgents.map(([agentId]) => agentId) }] } : {}),
392
+ log,
393
+ warn,
394
+ });
395
+ if (command.events) {
396
+ gateway.bus.subscribe((event) => {
397
+ const line = formatEvent(event);
398
+ if (line) {
399
+ writeLine(streams.stdout, line);
400
+ }
401
+ });
402
+ }
403
+ if (logWriter) {
404
+ // Only session.created carries the agent id, so it seeds a map the
405
+ // later events in that session read from. A session resumed after a
406
+ // restart never re-creates, so an unmapped id falls back to the
407
+ // durable store — which is exactly the case where attribution matters.
408
+ const agentBySession = new Map();
409
+ // EventBus.emit awaits its subscribers, and a streaming provider awaits
410
+ // the delta sink — so anything awaited here lands on the critical path
411
+ // of every streamed token. This handler is deliberately synchronous:
412
+ // provider.delta is dropped outright (it carries no detail worth
413
+ // keeping, one record per token), the timestamp is taken now, and the
414
+ // write is queued without awaiting it.
415
+ gateway.bus.subscribe((event) => {
416
+ if (event.type === 'provider.delta') {
417
+ return;
418
+ }
419
+ const ts = new Date().toISOString();
420
+ if (event.type === 'session.created') {
421
+ agentBySession.set(event.sessionId, event.agentId);
422
+ }
423
+ const detail = eventDetail(event);
424
+ const base = {
425
+ ts,
426
+ level: 'event',
427
+ event: event.type,
428
+ sessionId: event.sessionId,
429
+ ...(detail ? { detail } : {}),
430
+ };
431
+ const known = agentBySession.get(event.sessionId);
432
+ if (known) {
433
+ void logWriter.write({ ...base, agentId: known });
434
+ }
435
+ else {
436
+ // A session resumed after a restart never re-creates, so its agent
437
+ // is only in the store. That lookup is deferred off this path; the
438
+ // timestamp above keeps ordering honest.
439
+ void gateway.store.get(event.sessionId)
440
+ .then((session) => {
441
+ const agentId = session?.agent.id;
442
+ if (agentId) {
443
+ agentBySession.set(event.sessionId, agentId);
444
+ }
445
+ return logWriter.write({ ...base, ...(agentId ? { agentId } : {}) });
446
+ })
447
+ .catch(() => logWriter.write(base));
448
+ }
449
+ if (event.type === 'session.completed' || event.type === 'session.failed') {
450
+ agentBySession.delete(event.sessionId);
451
+ }
452
+ });
453
+ }
454
+ await gateway.start();
455
+ // Read now, while the API is bound: its stop forgets the address, and
456
+ // a restart's respawn needs it (see ServeOutcome).
457
+ const boundApiPort = boundApi?.url !== undefined ? Number(new URL(boundApi.url).port) : undefined;
458
+ if (supervised && boundApiPort !== undefined && Number.isInteger(boundApiPort) && boundApiPort > 0) {
459
+ link.send({ type: 'stratusd.bound-api-port', port: boundApiPort });
460
+ }
461
+ // Under one finally from here: a throw anywhere after the start — a
462
+ // host's writable refusing a line, say — must still stop the gateway
463
+ // before the claim on the home is released, or a live gateway would be
464
+ // left behind a lock the next daemon can take.
465
+ let redirectTimer;
466
+ try {
467
+ // The roster is only known once the gateway has loaded it, and in remote
468
+ // mode an agent no channel can ask for is the quietest failure this
469
+ // feature has: its gated calls park with nobody rendering them and wait
470
+ // out the whole timeout before being denied. No channel can detect this
471
+ // on its own — a request is a broadcast, and no adapter knows whether
472
+ // another one is about to answer — so it is reported here, where the
473
+ // roster and the channel list are both in view.
474
+ if (approvalMode === 'remote') {
475
+ const askable = new Set(slackAdapterUp ? slackAgents.map(([agentId]) => agentId) : []);
476
+ const unreachable = gateway.agents().map((agent) => agent.id).filter((id) => !askable.has(id));
477
+ if (unreachable.length > 0) {
478
+ warn(`no channel can ask for ${unreachable.join(', ')}, so their gated calls will wait out the `
479
+ + 'approval timeout and then be denied. Connect a Slack app for them, or run with --approvals headless.');
480
+ }
481
+ }
482
+ writeLine(streams.stdout, 'Press Ctrl+C to stop.');
483
+ // And periodically, for a long-running daemon that warns steadily
484
+ // without ever writing enough records to rotate. Unref'd, so it never
485
+ // holds the process open. Armed only once the daemon is actually
486
+ // serving: every step above can throw, and runServe is an exported
487
+ // function as much as a process entry point — in a host that survives
488
+ // the failure, a timer armed before the throw would outlive the call
489
+ // and go on truncating that environment's redirect logs. The finally
490
+ // below takes it down on every other exit path.
491
+ if (logWriter) {
492
+ redirectTimer = setInterval(() => void truncateRedirectLogs(logsDirPath(env)), 5 * 60_000);
493
+ redirectTimer.unref?.();
494
+ }
495
+ // Hold the event loop open until a shutdown request, then drain: new
496
+ // dispatches are refused while in-flight turns finish.
497
+ await new Promise((resolve) => {
498
+ const keepAlive = setInterval(() => { }, 2_147_000_000);
499
+ let settled = false;
500
+ const shutdown = () => {
501
+ if (settled) {
502
+ return;
503
+ }
504
+ settled = true;
505
+ clearInterval(keepAlive);
506
+ resolve();
507
+ };
508
+ stopSignal = () => {
509
+ if (!settled) {
510
+ shutdown();
511
+ return;
512
+ }
513
+ // A signal while a drain is already running — a repeat, or a stop
514
+ // arriving during a restart's drain. Either way the drain goes on,
515
+ // and a daemon told to stop does not come back.
516
+ stopRequested = true;
517
+ if (!repeatWarned) {
518
+ repeatWarned = true;
519
+ // Said from signal dispatch, outside the promise and its finally:
520
+ // a stream that throws on write (a host's injected stderr) would
521
+ // otherwise end the process from here — mid-drain, the very
522
+ // thing this handler exists to prevent. The word is best effort.
523
+ try {
524
+ warn('stop signal received while draining; still draining, and this daemon will not come back (SIGKILL ends it at once)');
525
+ }
526
+ catch {
527
+ // Nothing to say it to; the drain is what matters.
528
+ }
529
+ }
530
+ };
531
+ // `on`, never `once`, and not removed until the drain is over (the
532
+ // finally below). `once` drops the listener before running it, Node
533
+ // uninstalls its native handler the moment no listener is left, and
534
+ // a second SIGTERM landing in that window ends the process by the
535
+ // default action — mid-drain, with nothing said. The second signal
536
+ // is the normal case, not an edge: a stop delivered to the process
537
+ // group, as systemd's KillMode=control-group and a terminal's Ctrl+C
538
+ // deliver it, reaches a supervised daemon once from the kernel and
539
+ // once more forwarded by its supervisor. Reproduced two runs in
540
+ // three: the replacement died and the supervisor exited 1 on a
541
+ // `stratus service stop` after a restart. SIGKILL still ends the
542
+ // process at once.
543
+ process.on('SIGTERM', stopSignal);
544
+ process.on('SIGINT', stopSignal);
545
+ void shutdownRequested.then(shutdown);
546
+ if (env.shutdownSignal?.aborted) {
547
+ shutdown();
548
+ }
549
+ else {
550
+ env.shutdownSignal?.addEventListener('abort', shutdown, { once: true });
551
+ }
552
+ });
553
+ // Decided now, before the stop below: a restart's drain can still be
554
+ // running when a stop signal releases this wait, and `onRestart` then
555
+ // fires during that stop. A daemon told to stop does not come back,
556
+ // whatever it was told before.
557
+ if (!restart) {
558
+ stopRequested = true;
559
+ }
560
+ if (restart) {
561
+ // Already stopped: the gateway drained and closed before handing the
562
+ // process over, and the stop() below finds nothing to do. Said here,
563
+ // while the structured log still takes writes — the next lines
564
+ // belong to a process that has none.
565
+ if (!restart.drained) {
566
+ // A turn that ignored its abort is still running in this process,
567
+ // possibly still writing sessions. Starting a fresh daemon beside
568
+ // it would be two processes on one store; exiting instead leaves
569
+ // the service manager, where there is one, to bring it back.
570
+ warn(`restart: something did not let go; exiting with status ${UNDRAINED_RESTART_EXIT_CODE} for the service manager to restart, instead of starting a new daemon beside it`);
571
+ }
572
+ else {
573
+ log(`restarting stratusd${restart.reason ? ` (${restart.reason})` : ''}`);
574
+ }
575
+ }
576
+ else {
577
+ writeLine(streams.stdout, 'Stopping — draining in-flight turns.');
578
+ }
579
+ }
580
+ finally {
581
+ try {
582
+ await gateway.stop();
583
+ // Writes are queued and dropped on the hot path so logging never sits
584
+ // on a streamed token. That makes the tail of the log the part most
585
+ // likely to be lost — and the tail is the shutdown reason, the last
586
+ // warning, the line explaining a restart.
587
+ await logWriter?.flush();
588
+ }
589
+ finally {
590
+ // The shutdown is over, the flush included: a stop signal from here
591
+ // on is the process's to handle by default again — and a host that
592
+ // calls runServe repeatedly (the tests) must not accumulate handlers.
593
+ // Last, and under its own finally, because the listener is on the
594
+ // global process: taken off before the flush, a repeated signal
595
+ // landing during it would end the process by the default action
596
+ // (which a supervisor reports as a failure, and a service manager
597
+ // set to restart on failure would then bring back a daemon an
598
+ // operator just stopped); left on by a stop() that rejects (its last
599
+ // log line can throw through an injected stream), a settled handler
600
+ // would swallow the host's next signal.
601
+ if (stopSignal) {
602
+ process.off('SIGTERM', stopSignal);
603
+ process.off('SIGINT', stopSignal);
604
+ }
605
+ if (redirectTimer) {
606
+ clearInterval(redirectTimer);
607
+ }
608
+ }
609
+ }
610
+ const bound = boundApiPort !== undefined && Number.isInteger(boundApiPort) && boundApiPort > 0
611
+ ? { boundApiPort }
612
+ : {};
613
+ if (!restart || stopRequested) {
614
+ return { code: 0, ...bound, sessions: [] };
615
+ }
616
+ if (!restart.drained) {
617
+ (env.exitProcess ?? process.exit)(UNDRAINED_RESTART_EXIT_CODE);
618
+ return { code: UNDRAINED_RESTART_EXIT_CODE, ...bound, sessions: [] };
619
+ }
620
+ // Coming back: the browser sessions live at the stop go to the
621
+ // replacement — up the channel to the supervisor when this daemon has
622
+ // one, else with the outcome to the supervisor this process becomes.
623
+ const sessions = boundApi?.sessionsAtStop() ?? [];
624
+ if (supervised && sessions.length > 0) {
625
+ link.send({ type: 'stratusd.sessions', sessions });
626
+ }
627
+ // Asked to come back. Whether this process starts the next daemon or
628
+ // exits for its supervisor to is `runServe`'s decision, after the home
629
+ // is released: the claim is held until this returns, and a daemon
630
+ // started before that would be refused as a second one on the home.
631
+ return { code: RESTART_EXIT_CODE, ...bound, sessions };
632
+ };
633
+ //# sourceMappingURL=serve.js.map