@stratusagent/cli 0.2.5 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +235 -3
- package/dist/bin.js +13 -4
- package/dist/bin.js.map +1 -1
- package/dist/index.d.ts +111 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1409 -42
- package/dist/index.js.map +1 -1
- package/dist/logs.d.ts +123 -0
- package/dist/logs.d.ts.map +1 -0
- package/dist/logs.js +358 -0
- package/dist/logs.js.map +1 -0
- package/dist/service.d.ts +82 -0
- package/dist/service.d.ts.map +1 -0
- package/dist/service.js +380 -0
- package/dist/service.js.map +1 -0
- package/package.json +15 -11
package/dist/index.js
CHANGED
|
@@ -7,16 +7,62 @@ import path from 'node:path';
|
|
|
7
7
|
import { createInterface } from 'node:readline';
|
|
8
8
|
import { AgentRunner, AllowAllApprovalPolicy, EventBus, ToolRegistry, } from '@stratusagent/core';
|
|
9
9
|
import { createLocalCommandExecutor, } from '@stratusagent/executor-local';
|
|
10
|
+
import { createPermissionPolicy } from '@stratusagent/permissions';
|
|
10
11
|
import { DEFAULT_ANTHROPIC_MODEL, redactAnthropicRawTurns, } from '@stratusagent/provider-anthropic';
|
|
11
12
|
import { createRememberTool, defineAgent, formatSoul, generateAgentName, parseSoul, } from '@stratusagent/agents';
|
|
12
|
-
import { agentsDirPath, createDemoTool, createFileMemoryStore, createRuntimeProvider, credentialsPath, DEFAULT_ANTHROPIC_BASE_URL, DEFAULT_OPENAI_BASE_URL, DEFAULT_OPENAI_MODEL, DEFAULT_STRATUS_AGENT, discoverActiveConfig, globalConfigPath, loadChannelCredentials, loadCredentials, memoryFilePath, migrateLegacyMemory, parseProviderName, readNonEmptyString, readProcessEnv, readWorkingDirectory, DEFAULT_CONFIG_FILENAME, LEGACY_CONFIG_FILENAME, loadConfigFile, resolveRuntimeConfig as resolveStateRuntimeConfig, saveCredentials, withLegacyDefaultMemories, } from '@stratusagent/state';
|
|
13
|
+
import { agentsDirPath, createDemoTool, createFileMemoryStore, createRuntimeProvider, credentialsPath, DEFAULT_ANTHROPIC_BASE_URL, DEFAULT_OPENAI_BASE_URL, DEFAULT_OPENAI_MODEL, DEFAULT_STRATUS_AGENT, discoverActiveConfig, globalConfigPath, loadChannelCredentials, loadCredentials, loadRosterSouls, loadSoulFile, memoryFilePath, migrateLegacyMemory, parseProviderName, readNonEmptyString, readProcessEnv, logsDirPath, readWorkingDirectory, DEFAULT_CONFIG_FILENAME, LEGACY_CONFIG_FILENAME, loadConfigFile, resolveConfigLocation, resolveRuntimeConfig as resolveStateRuntimeConfig, saveChannelCredentials, saveCredentials, withLegacyDefaultMemories, } from '@stratusagent/state';
|
|
14
|
+
import { installService, readServiceStatus, servicePlatform, serviceUnitPath, startService, stopService, uninstallService, } from "./service.js";
|
|
15
|
+
export { installService, launchdPlist, readServiceStatus, servicePlatform, serviceUnitPath, startService, stopService, systemdUnit, uninstallService, SERVICE_LABEL, } from "./service.js";
|
|
16
|
+
import { createLogWriter, currentLogPosition, truncateRedirectLogs, formatLogRecord, readRecentRecords, tailLog, } from "./logs.js";
|
|
17
|
+
export { createLogWriter, currentLogPosition, truncateRedirectLogs, formatLogRecord, parseLogLines, readRecentRecords, tailLog, } from "./logs.js";
|
|
13
18
|
// The shared state package owns config resolution, credentials, memory, and
|
|
14
19
|
// provider wiring now (the gateway uses the same code); the CLI re-exports
|
|
15
20
|
// its historical surface so existing importers keep working.
|
|
16
21
|
export { createFileMemoryStore } from '@stratusagent/state';
|
|
17
|
-
|
|
22
|
+
// Kept in step with this package's own version by a test, because it is a
|
|
23
|
+
// second copy of a number nobody re-reads: it is drawn in the setup header
|
|
24
|
+
// and served from the dashboard's /api/status, so drift here misreports the
|
|
25
|
+
// running build rather than failing anything.
|
|
26
|
+
export const CLI_VERSION = '0.3.0';
|
|
18
27
|
const DEFAULT_DASHBOARD_HOST = '127.0.0.1';
|
|
19
28
|
const DASHBOARD_TITLE = 'Stratus Agent Dashboard';
|
|
29
|
+
/**
|
|
30
|
+
* The range every package declares in its `engines` field. Two floors, not
|
|
31
|
+
* one: `node:sqlite` was unflagged in 22.13.0 on the LTS line and in 23.4.0
|
|
32
|
+
* on the 23.x line, so 23.0 through 23.3 are NEWER than the 22.x floor and
|
|
33
|
+
* still ship it behind `--experimental-sqlite`. A plain `>=22.13` admits
|
|
34
|
+
* exactly those releases.
|
|
35
|
+
*/
|
|
36
|
+
export const SUPPORTED_NODE_RANGE = '>=22.13 <23 || >=23.4';
|
|
37
|
+
/**
|
|
38
|
+
* Why the CLI checks a version its manifests already declare: `engines` is
|
|
39
|
+
* advisory. npm and pnpm print EBADENGINE and carry on unless the user has
|
|
40
|
+
* turned on engine-strict, so an install on Node 20 succeeds and the floor
|
|
41
|
+
* is discovered later as an ERR_UNKNOWN_BUILTIN_MODULE for `node:sqlite`,
|
|
42
|
+
* thrown from a lazy import inside whichever command first needed the
|
|
43
|
+
* session store. That error names neither Node nor the version required.
|
|
44
|
+
*
|
|
45
|
+
* Returns the message to print, or undefined when the version is fine —
|
|
46
|
+
* including when it cannot be parsed at all, since an unrecognized build
|
|
47
|
+
* string is a bad reason to refuse to run.
|
|
48
|
+
*/
|
|
49
|
+
export const unsupportedNodeMessage = (version) => {
|
|
50
|
+
const parts = version.replace(/^v/, '').split('.');
|
|
51
|
+
const major = Number.parseInt(parts[0] ?? '', 10);
|
|
52
|
+
const minor = Number.parseInt(parts[1] ?? '', 10);
|
|
53
|
+
if (!Number.isInteger(major) || !Number.isInteger(minor)) {
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
const supported = major > 23
|
|
57
|
+
|| (major === 23 && minor >= 4)
|
|
58
|
+
|| (major === 22 && minor >= 13);
|
|
59
|
+
if (supported) {
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
return `Stratus Agent needs Node 22.13 or newer, and 23.4 or newer on the 23.x line — this is Node ${version.replace(/^v/, '')}.\n`
|
|
63
|
+
+ "The gateway's session store uses node:sqlite, unflagged in 22.13.0 and, on the 23.x line, not until 23.4.0.\n"
|
|
64
|
+
+ 'Upgrade with `brew install node` on macOS, or your package manager or nvm on Linux.';
|
|
65
|
+
};
|
|
20
66
|
const HELP_TEXT = `Stratus Agent CLI
|
|
21
67
|
|
|
22
68
|
Usage:
|
|
@@ -31,24 +77,43 @@ Usage:
|
|
|
31
77
|
STRATUS_PROVIDER=openai OPENAI_API_KEY=... stratus run "Say hello"
|
|
32
78
|
stratus run --config ./stratus.config.json --provider openai "Say hello"
|
|
33
79
|
stratus agents
|
|
80
|
+
stratus doctor
|
|
81
|
+
stratus service install
|
|
82
|
+
stratus service status
|
|
83
|
+
stratus logs -f
|
|
84
|
+
stratus logs --agent ava -n 200
|
|
34
85
|
stratus dashboard
|
|
35
86
|
stratus dashboard --port 4123 --host 0.0.0.0 --no-open
|
|
36
87
|
|
|
37
88
|
Commands:
|
|
38
89
|
setup Menu-driven onboarding: pick a provider, sign in (Claude
|
|
39
|
-
subscription or API key), create your agent,
|
|
40
|
-
settings go to ~/.stratus/config.json,
|
|
41
|
-
~/.stratus/credentials.json
|
|
90
|
+
subscription or API key), create your agent, connect it to
|
|
91
|
+
Slack, and test it — settings go to ~/.stratus/config.json,
|
|
92
|
+
sign-ins and channel tokens to ~/.stratus/credentials.json
|
|
93
|
+
(0600)
|
|
42
94
|
chat Talk with your agent — the conversation persists across turns
|
|
43
95
|
and remembered facts accumulate; /exit or Ctrl+C to leave
|
|
44
96
|
run Execute one local Stratus Agent session
|
|
45
97
|
serve Run stratusd, the always-on gateway: durable sessions, the
|
|
46
98
|
whole roster live at once (each agent on its own provider),
|
|
47
99
|
delegation, and a watchdog — Ctrl+C / SIGTERM drains cleanly
|
|
48
|
-
(--idle-timeout <seconds>, --no-events, --
|
|
100
|
+
(--idle-timeout <seconds>, --no-events, --no-log-file,
|
|
101
|
+
--config <path>); everything it says is also written to
|
|
102
|
+
~/.stratus/logs, which "stratus logs" reads
|
|
103
|
+
service Keep stratusd running under launchd (macOS) or systemd
|
|
104
|
+
(Linux): install, uninstall, status, start, stop.
|
|
105
|
+
Installing starts it now and at every login
|
|
106
|
+
(--no-login installs without the login trigger,
|
|
107
|
+
--config <path> pins the daemon to one config file)
|
|
108
|
+
logs Read the daemon's log from any terminal: -f to follow,
|
|
109
|
+
-n <count> for backlog, --agent / --session to filter,
|
|
110
|
+
--format json for the raw records
|
|
49
111
|
agent new Create an agent identity (generates a human-ish name + avatar theme)
|
|
50
112
|
agents List your agents: who they are, where their souls live, what
|
|
51
113
|
they run on, what they remember (also: stratus agent list)
|
|
114
|
+
doctor Show what a run would use right now — provider, model, soul —
|
|
115
|
+
and which file or environment variable decided each, then
|
|
116
|
+
flag anything that would surprise you (--format json)
|
|
52
117
|
dashboard Start the local Stratus Agent dashboard and open it in your browser
|
|
53
118
|
help Show this help message
|
|
54
119
|
|
|
@@ -176,6 +241,10 @@ export const parseCommand = (argv, env = {}) => {
|
|
|
176
241
|
parsed.events = false;
|
|
177
242
|
continue;
|
|
178
243
|
}
|
|
244
|
+
if (token === '--no-log-file') {
|
|
245
|
+
parsed.logToFile = false;
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
179
248
|
throw new Error(`Unknown option: ${token}`);
|
|
180
249
|
}
|
|
181
250
|
return parsed;
|
|
@@ -265,6 +334,129 @@ export const parseCommand = (argv, env = {}) => {
|
|
|
265
334
|
}
|
|
266
335
|
return { command: 'agents', format };
|
|
267
336
|
}
|
|
337
|
+
if (command === 'doctor') {
|
|
338
|
+
let format = 'text';
|
|
339
|
+
let configPath;
|
|
340
|
+
for (let index = 0; index < rest.length; index += 1) {
|
|
341
|
+
const token = rest[index];
|
|
342
|
+
if (!token) {
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
if (token === '--help' || token === '-h') {
|
|
346
|
+
return { command: 'help' };
|
|
347
|
+
}
|
|
348
|
+
if (token === '--format') {
|
|
349
|
+
const value = readOptionValue(rest, index, '--format');
|
|
350
|
+
if (value !== 'text' && value !== 'json') {
|
|
351
|
+
throw new Error(`Unsupported format: ${value}`);
|
|
352
|
+
}
|
|
353
|
+
format = value;
|
|
354
|
+
index += 1;
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
if (token === '--config') {
|
|
358
|
+
configPath = readOptionValue(rest, index, '--config');
|
|
359
|
+
index += 1;
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
throw new Error(`Unknown option: ${token}`);
|
|
363
|
+
}
|
|
364
|
+
return { command: 'doctor', format, ...(configPath ? { configPath } : {}) };
|
|
365
|
+
}
|
|
366
|
+
if (command === 'service') {
|
|
367
|
+
const action = rest[0];
|
|
368
|
+
if (action === '--help' || action === '-h' || action === undefined) {
|
|
369
|
+
return { command: 'help' };
|
|
370
|
+
}
|
|
371
|
+
if (action !== 'install' && action !== 'uninstall' && action !== 'status' && action !== 'start' && action !== 'stop') {
|
|
372
|
+
throw new Error(`Unknown service action: ${action} (expected install, uninstall, status, start, or stop)`);
|
|
373
|
+
}
|
|
374
|
+
const parsed = { command: 'service', action };
|
|
375
|
+
const serviceRest = rest.slice(1);
|
|
376
|
+
for (let index = 0; index < serviceRest.length; index += 1) {
|
|
377
|
+
const token = serviceRest[index];
|
|
378
|
+
if (!token) {
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
if (token === '--help' || token === '-h') {
|
|
382
|
+
return { command: 'help' };
|
|
383
|
+
}
|
|
384
|
+
if (token === '--no-login') {
|
|
385
|
+
if (action !== 'install') {
|
|
386
|
+
throw new Error('--no-login applies to `stratus service install`.');
|
|
387
|
+
}
|
|
388
|
+
parsed.runAtLogin = false;
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
if (token === '--config') {
|
|
392
|
+
if (action !== 'install') {
|
|
393
|
+
throw new Error('--config applies to `stratus service install`.');
|
|
394
|
+
}
|
|
395
|
+
parsed.configPath = readOptionValue(serviceRest, index, '--config');
|
|
396
|
+
index += 1;
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
399
|
+
throw new Error(`Unknown option: ${token}`);
|
|
400
|
+
}
|
|
401
|
+
return parsed;
|
|
402
|
+
}
|
|
403
|
+
if (command === 'logs') {
|
|
404
|
+
let follow = false;
|
|
405
|
+
let limit = 50;
|
|
406
|
+
let agentId;
|
|
407
|
+
let sessionId;
|
|
408
|
+
let format = 'text';
|
|
409
|
+
for (let index = 0; index < rest.length; index += 1) {
|
|
410
|
+
const token = rest[index];
|
|
411
|
+
if (!token) {
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
if (token === '--help' || token === '-h') {
|
|
415
|
+
return { command: 'help' };
|
|
416
|
+
}
|
|
417
|
+
if (token === '--follow' || token === '-f') {
|
|
418
|
+
follow = true;
|
|
419
|
+
continue;
|
|
420
|
+
}
|
|
421
|
+
if (token === '--lines' || token === '-n') {
|
|
422
|
+
const value = Number(readOptionValue(rest, index, token));
|
|
423
|
+
if (!Number.isFinite(value) || value < 0) {
|
|
424
|
+
throw new Error(`Invalid value for ${token}: ${rest[index + 1] ?? '(missing)'}`);
|
|
425
|
+
}
|
|
426
|
+
limit = Math.floor(value);
|
|
427
|
+
index += 1;
|
|
428
|
+
continue;
|
|
429
|
+
}
|
|
430
|
+
if (token === '--agent') {
|
|
431
|
+
agentId = readOptionValue(rest, index, '--agent');
|
|
432
|
+
index += 1;
|
|
433
|
+
continue;
|
|
434
|
+
}
|
|
435
|
+
if (token === '--session') {
|
|
436
|
+
sessionId = readOptionValue(rest, index, '--session');
|
|
437
|
+
index += 1;
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
if (token === '--format') {
|
|
441
|
+
const value = readOptionValue(rest, index, '--format');
|
|
442
|
+
if (value !== 'text' && value !== 'json') {
|
|
443
|
+
throw new Error(`Unsupported format: ${value}`);
|
|
444
|
+
}
|
|
445
|
+
format = value;
|
|
446
|
+
index += 1;
|
|
447
|
+
continue;
|
|
448
|
+
}
|
|
449
|
+
throw new Error(`Unknown option: ${token}`);
|
|
450
|
+
}
|
|
451
|
+
return {
|
|
452
|
+
command: 'logs',
|
|
453
|
+
follow,
|
|
454
|
+
limit,
|
|
455
|
+
format,
|
|
456
|
+
...(agentId ? { agentId } : {}),
|
|
457
|
+
...(sessionId ? { sessionId } : {}),
|
|
458
|
+
};
|
|
459
|
+
}
|
|
268
460
|
if (command === 'agent') {
|
|
269
461
|
const [subcommand, ...agentRest] = rest;
|
|
270
462
|
if (subcommand === '--help' || subcommand === '-h') {
|
|
@@ -472,9 +664,68 @@ export const formatEvent = (event) => {
|
|
|
472
664
|
return null;
|
|
473
665
|
}
|
|
474
666
|
};
|
|
667
|
+
/**
|
|
668
|
+
* The fields worth keeping per event type. Tool inputs and message text
|
|
669
|
+
* are deliberately excluded: the log is a trace of what happened, not a
|
|
670
|
+
* second copy of the transcript.
|
|
671
|
+
*/
|
|
672
|
+
const eventDetail = (event) => {
|
|
673
|
+
switch (event.type) {
|
|
674
|
+
case 'session.updated':
|
|
675
|
+
return { status: event.status };
|
|
676
|
+
case 'provider.response':
|
|
677
|
+
return { parts: event.parts.length };
|
|
678
|
+
case 'tool.called':
|
|
679
|
+
case 'tool.denied':
|
|
680
|
+
return { tool: event.call.toolName };
|
|
681
|
+
case 'tool.completed':
|
|
682
|
+
return { tool: event.result.toolName, ok: event.result.ok };
|
|
683
|
+
case 'session.failed':
|
|
684
|
+
return { error: event.error };
|
|
685
|
+
default:
|
|
686
|
+
return undefined;
|
|
687
|
+
}
|
|
688
|
+
};
|
|
475
689
|
// Kept with its historical CLI signature: a parsed run command is a
|
|
476
690
|
// RuntimeSelection plus CLI-only fields the resolver ignores.
|
|
477
691
|
export const resolveRuntimeConfig = (command, env = {}) => resolveStateRuntimeConfig(command, env);
|
|
692
|
+
/**
|
|
693
|
+
* A saved subscription sign-in silently demoted to per-token billing is the
|
|
694
|
+
* one config surprise that costs money — an API key in the environment
|
|
695
|
+
* outranks the stored credential, and the run otherwise looks identical.
|
|
696
|
+
* Detected from the resolved config: an apiKey where the stored credential
|
|
697
|
+
* is a subscription token means the environment won.
|
|
698
|
+
*/
|
|
699
|
+
export const warnOnCredentialOverride = async (runtime, streams, env = {}) => {
|
|
700
|
+
if (runtime.provider === 'demo') {
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
const stored = (await loadCredentials(env)).anthropic;
|
|
704
|
+
if (stored?.type !== 'oauth_token') {
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
// The resolver records the variable that actually won — guessing it here
|
|
708
|
+
// would name the wrong one whenever a custom apiKeyEnv or the legacy
|
|
709
|
+
// STRATUSCLAW_ prefix supplied the key, sending the reader to unset
|
|
710
|
+
// something that was never the cause.
|
|
711
|
+
const primary = runtime.provider === 'anthropic' && runtime.apiKey ? runtime.apiKeyEnvVar : undefined;
|
|
712
|
+
// A fallback demoted the same way costs exactly as much, and only bites
|
|
713
|
+
// once the primary is already failing — the worst moment to discover it.
|
|
714
|
+
// The fallback path consults the provider's own conventional variable.
|
|
715
|
+
const processEnv = readProcessEnv(env);
|
|
716
|
+
const fallbackVar = runtime.fallback?.provider === 'anthropic'
|
|
717
|
+
&& runtime.fallback.apiKey !== undefined
|
|
718
|
+
&& readNonEmptyString(processEnv.ANTHROPIC_API_KEY) === runtime.fallback.apiKey
|
|
719
|
+
? 'ANTHROPIC_API_KEY'
|
|
720
|
+
: undefined;
|
|
721
|
+
const culprit = primary ?? fallbackVar;
|
|
722
|
+
if (!culprit) {
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
writeLine(streams.stderr, `Warning: ${culprit} in your environment outranks the Claude subscription sign-in saved by \`stratus setup\`, `
|
|
726
|
+
+ `so ${primary ? 'this run is' : 'a fallback retry would be'} billed per token instead of through your plan. `
|
|
727
|
+
+ 'Unset it to use the subscription, or run `stratus doctor` to see everything that is being overridden.');
|
|
728
|
+
};
|
|
478
729
|
const createApprovalPolicy = (mode, streams, env, ask) => {
|
|
479
730
|
if (mode === 'always') {
|
|
480
731
|
return new AllowAllApprovalPolicy();
|
|
@@ -636,6 +887,7 @@ export const runChat = async (command, streams, env = {}) => {
|
|
|
636
887
|
...(command.soul ? { soul: command.soul } : {}),
|
|
637
888
|
...(command.configPath ? { configPath: command.configPath } : {}),
|
|
638
889
|
}, env);
|
|
890
|
+
await warnOnCredentialOverride(runtime, streams, env);
|
|
639
891
|
// Interactive means the real terminal: an injected stdinStream is by
|
|
640
892
|
// definition not a TTY conversation, so it never gets prompts, ANSI
|
|
641
893
|
// styling, or a terminal readline wired to a stream that can't take it.
|
|
@@ -1357,6 +1609,94 @@ const verifyProviderKey = async (provider, key, baseUrl, fetchImpl) => {
|
|
|
1357
1609
|
return { status: 'unreachable', detail: error instanceof Error ? error.message : String(error) };
|
|
1358
1610
|
}
|
|
1359
1611
|
};
|
|
1612
|
+
// Slack verification talks to the Web API over plain fetch rather than
|
|
1613
|
+
// through @stratusagent/channel-slack. Setup must stay usable before that
|
|
1614
|
+
// optional package is installed, and the CLI deliberately does not depend
|
|
1615
|
+
// on it (or on the ~9 MB of Slack SDKs underneath).
|
|
1616
|
+
const SLACK_API_ROOT = 'https://slack.com/api';
|
|
1617
|
+
const callSlack = async (method, token, fetchImpl) => {
|
|
1618
|
+
if (typeof fetchImpl !== 'function') {
|
|
1619
|
+
return { ok: false, verdict: { status: 'unreachable', detail: 'fetch is unavailable' } };
|
|
1620
|
+
}
|
|
1621
|
+
try {
|
|
1622
|
+
const response = await fetchImpl(`${SLACK_API_ROOT}/${method}`, {
|
|
1623
|
+
method: 'POST',
|
|
1624
|
+
headers: {
|
|
1625
|
+
authorization: `Bearer ${token}`,
|
|
1626
|
+
'content-type': 'application/x-www-form-urlencoded',
|
|
1627
|
+
},
|
|
1628
|
+
});
|
|
1629
|
+
if (!response.ok) {
|
|
1630
|
+
return { ok: false, verdict: { status: 'unreachable', detail: `HTTP ${response.status}` } };
|
|
1631
|
+
}
|
|
1632
|
+
const body = await response.json();
|
|
1633
|
+
if (body.ok === true) {
|
|
1634
|
+
return { ok: true, body };
|
|
1635
|
+
}
|
|
1636
|
+
// Slack answers 200 with { ok: false, error } for bad credentials, so
|
|
1637
|
+
// the error string — not the status — is what condemns a token.
|
|
1638
|
+
return { ok: false, verdict: { status: 'rejected', detail: String(body.error ?? 'unknown error') } };
|
|
1639
|
+
}
|
|
1640
|
+
catch (error) {
|
|
1641
|
+
return { ok: false, verdict: { status: 'unreachable', detail: error instanceof Error ? error.message : String(error) } };
|
|
1642
|
+
}
|
|
1643
|
+
};
|
|
1644
|
+
/** Verifies a bot token and reports the identity it belongs to. */
|
|
1645
|
+
const verifySlackBotToken = async (token, fetchImpl) => {
|
|
1646
|
+
const result = await callSlack('auth.test', token, fetchImpl);
|
|
1647
|
+
if (!result.ok) {
|
|
1648
|
+
return result.verdict;
|
|
1649
|
+
}
|
|
1650
|
+
return {
|
|
1651
|
+
status: 'ok',
|
|
1652
|
+
identity: {
|
|
1653
|
+
...(typeof result.body.user_id === 'string' ? { botUserId: result.body.user_id } : {}),
|
|
1654
|
+
...(typeof result.body.team === 'string' ? { teamName: result.body.team } : {}),
|
|
1655
|
+
...(typeof result.body.team_id === 'string' ? { teamId: result.body.team_id } : {}),
|
|
1656
|
+
},
|
|
1657
|
+
};
|
|
1658
|
+
};
|
|
1659
|
+
/**
|
|
1660
|
+
* Verifies an app-level token by opening a Socket Mode URL. The URL is
|
|
1661
|
+
* discarded — this only proves the token carries connections:write, which
|
|
1662
|
+
* is the failure the daemon would otherwise hit at start time.
|
|
1663
|
+
*/
|
|
1664
|
+
const verifySlackAppToken = async (token, fetchImpl) => {
|
|
1665
|
+
const result = await callSlack('apps.connections.open', token, fetchImpl);
|
|
1666
|
+
return result.ok ? { status: 'ok', identity: {} } : result.verdict;
|
|
1667
|
+
};
|
|
1668
|
+
// Scopes and events the adapter needs. Kept in step with the manifest
|
|
1669
|
+
// shipped by @stratusagent/channel-slack (a test pins them together) so
|
|
1670
|
+
// setup can hand over a ready-to-paste manifest without depending on that
|
|
1671
|
+
// package being installed.
|
|
1672
|
+
const SLACK_BOT_SCOPES = [
|
|
1673
|
+
'app_mentions:read',
|
|
1674
|
+
'chat:write',
|
|
1675
|
+
'files:write',
|
|
1676
|
+
'im:history',
|
|
1677
|
+
'im:read',
|
|
1678
|
+
'im:write',
|
|
1679
|
+
'users:read',
|
|
1680
|
+
];
|
|
1681
|
+
const SLACK_BOT_EVENTS = ['app_mention', 'message.im'];
|
|
1682
|
+
export const slackAppManifest = (agentName) => JSON.stringify({
|
|
1683
|
+
display_information: {
|
|
1684
|
+
name: agentName,
|
|
1685
|
+
description: 'A Stratus agent',
|
|
1686
|
+
background_color: '#1a1d21',
|
|
1687
|
+
},
|
|
1688
|
+
features: {
|
|
1689
|
+
bot_user: { display_name: agentName, always_online: true },
|
|
1690
|
+
},
|
|
1691
|
+
oauth_config: { scopes: { bot: SLACK_BOT_SCOPES } },
|
|
1692
|
+
settings: {
|
|
1693
|
+
event_subscriptions: { bot_events: SLACK_BOT_EVENTS },
|
|
1694
|
+
interactivity: { is_enabled: false },
|
|
1695
|
+
org_deploy_enabled: false,
|
|
1696
|
+
socket_mode_enabled: true,
|
|
1697
|
+
token_rotation_enabled: false,
|
|
1698
|
+
},
|
|
1699
|
+
}, null, 2);
|
|
1360
1700
|
const DEFAULT_SOUL_STARTER = [
|
|
1361
1701
|
'You are a helpful, warm generalist. Answer first, explain second, and',
|
|
1362
1702
|
'keep replies short unless the question genuinely needs depth. Use',
|
|
@@ -1394,6 +1734,12 @@ export const runSetup = async (command, streams, env = {}) => {
|
|
|
1394
1734
|
// A path passed via --config is not auto-discovered by `stratus run`, so
|
|
1395
1735
|
// suggested commands must carry it explicitly.
|
|
1396
1736
|
const runConfigFlag = command.configPath ? ` --config ${quoteShellArg(command.configPath)}` : '';
|
|
1737
|
+
// Set once save() detects a project config shadowing the global one.
|
|
1738
|
+
// Suggested commands must carry it too, or they read a different config
|
|
1739
|
+
// than the one just written — for `serve` that means a different roster,
|
|
1740
|
+
// and Slack apps stored here would be skipped as having no agent.
|
|
1741
|
+
let shadowConfigFlag = '';
|
|
1742
|
+
const serveCommand = () => `stratus serve${runConfigFlag}${shadowConfigFlag}`;
|
|
1397
1743
|
// Seed from what is already configured, so re-running setup edits instead
|
|
1398
1744
|
// of clobbering.
|
|
1399
1745
|
let existing = {};
|
|
@@ -1446,6 +1792,24 @@ export const runSetup = async (command, streams, env = {}) => {
|
|
|
1446
1792
|
...(existing.fallbackBaseUrl ? { fallbackBaseUrl: existing.fallbackBaseUrl } : {}),
|
|
1447
1793
|
credentials: await loadCredentials(env),
|
|
1448
1794
|
credentialsDirty: false,
|
|
1795
|
+
channels: await loadChannelCredentials(env),
|
|
1796
|
+
channelsDirty: false,
|
|
1797
|
+
// On by default: setup's whole promise is that you finish it and the
|
|
1798
|
+
// agents are working. An always-on runtime you have to remember to
|
|
1799
|
+
// start is not always-on, and every Slack app configured here stays
|
|
1800
|
+
// silent until stratusd is up. An existing install keeps ITS login
|
|
1801
|
+
// setting, though — rerunning setup and pressing save must not quietly
|
|
1802
|
+
// undo a deliberate `service install --no-login`.
|
|
1803
|
+
service: await (async () => {
|
|
1804
|
+
const status = await readServiceStatus(serviceEnvFor(env)).catch(() => undefined);
|
|
1805
|
+
// An unknown answer (a broken user bus, a timed-out query) must not
|
|
1806
|
+
// read as "they chose --no-login" — that would disable a service on
|
|
1807
|
+
// the next save because of a transient failure.
|
|
1808
|
+
return {
|
|
1809
|
+
install: true,
|
|
1810
|
+
runAtLogin: status?.installed && status.runAtLogin !== undefined ? status.runAtLogin : true,
|
|
1811
|
+
};
|
|
1812
|
+
})(),
|
|
1449
1813
|
};
|
|
1450
1814
|
const prompter = createSetupPrompter(baseStreams, env, {
|
|
1451
1815
|
header: stratusHeaderLines,
|
|
@@ -1512,6 +1876,13 @@ export const runSetup = async (command, streams, env = {}) => {
|
|
|
1512
1876
|
}
|
|
1513
1877
|
return state.soulPath;
|
|
1514
1878
|
};
|
|
1879
|
+
const channelsSummary = () => {
|
|
1880
|
+
const connected = Object.keys(state.channels.slack ?? {}).length;
|
|
1881
|
+
if (connected === 0) {
|
|
1882
|
+
return 'Slack: not connected';
|
|
1883
|
+
}
|
|
1884
|
+
return `Slack: ${connected} agent${connected === 1 ? '' : 's'} connected`;
|
|
1885
|
+
};
|
|
1515
1886
|
const storeCredential = (provider, credential) => {
|
|
1516
1887
|
state.credentials[provider] = credential;
|
|
1517
1888
|
state.credentialsDirty = true;
|
|
@@ -2043,6 +2414,255 @@ export const runSetup = async (command, streams, env = {}) => {
|
|
|
2043
2414
|
...(fallback ? { fallback } : {}),
|
|
2044
2415
|
};
|
|
2045
2416
|
};
|
|
2417
|
+
/**
|
|
2418
|
+
* Walks an agent through connecting their own Slack app: show the
|
|
2419
|
+
* manifest to paste, take both tokens, verify each against Slack, and
|
|
2420
|
+
* store them under the agent's id. Tokens are keyed by a roster agent
|
|
2421
|
+
* picked from a list rather than typed, so the id can never drift from
|
|
2422
|
+
* the roster — the mismatch that otherwise surfaces only as a startup
|
|
2423
|
+
* warning about an agent the gateway cannot find.
|
|
2424
|
+
*/
|
|
2425
|
+
const connectSlackAgent = async (agentId, agentName) => {
|
|
2426
|
+
writeLine(streams.stdout);
|
|
2427
|
+
writeLine(streams.stdout, `Create a Slack app for ${agentName}:`);
|
|
2428
|
+
writeLine(streams.stdout, ' 1. Open https://api.slack.com/apps → Create New App → From a manifest');
|
|
2429
|
+
writeLine(streams.stdout, ' 2. Pick your workspace, then paste this manifest:');
|
|
2430
|
+
writeLine(streams.stdout);
|
|
2431
|
+
for (const line of slackAppManifest(agentName).split('\n')) {
|
|
2432
|
+
writeLine(streams.stdout, ` ${line}`);
|
|
2433
|
+
}
|
|
2434
|
+
writeLine(streams.stdout);
|
|
2435
|
+
writeLine(streams.stdout, ' 3. Create the app, then Basic Information → App-Level Tokens →');
|
|
2436
|
+
writeLine(streams.stdout, ' Generate a token with the connections:write scope (xapp-…)');
|
|
2437
|
+
writeLine(streams.stdout, ' 4. Install App → copy the Bot User OAuth Token (xoxb-…)');
|
|
2438
|
+
writeLine(streams.stdout, ` 5. Basic Information → Display Information → upload ${agentName}'s avatar`);
|
|
2439
|
+
writeLine(streams.stdout);
|
|
2440
|
+
const appToken = await prompter.askSecret('Paste the app-level token (xapp-…, Enter to cancel; input is hidden): ');
|
|
2441
|
+
if (!appToken) {
|
|
2442
|
+
writeLine(streams.stdout, 'Cancelled — nothing was saved.');
|
|
2443
|
+
return;
|
|
2444
|
+
}
|
|
2445
|
+
if (!appToken.startsWith('xapp-')) {
|
|
2446
|
+
writeLine(streams.stdout, '✗ That does not look like an app-level token (they start with xapp-). Nothing was saved.');
|
|
2447
|
+
return;
|
|
2448
|
+
}
|
|
2449
|
+
const botToken = await prompter.askSecret('Paste the bot user OAuth token (xoxb-…, Enter to cancel; input is hidden): ');
|
|
2450
|
+
if (!botToken) {
|
|
2451
|
+
writeLine(streams.stdout, 'Cancelled — nothing was saved.');
|
|
2452
|
+
return;
|
|
2453
|
+
}
|
|
2454
|
+
if (!botToken.startsWith('xoxb-')) {
|
|
2455
|
+
writeLine(streams.stdout, '✗ That does not look like a bot token (they start with xoxb-). Nothing was saved.');
|
|
2456
|
+
return;
|
|
2457
|
+
}
|
|
2458
|
+
writeLine(streams.stdout, 'Checking the tokens with Slack…');
|
|
2459
|
+
const fetchImpl = env.fetch ?? globalThis.fetch;
|
|
2460
|
+
const bot = await verifySlackBotToken(botToken, fetchImpl);
|
|
2461
|
+
if (bot.status === 'rejected') {
|
|
2462
|
+
writeLine(streams.stdout, `✗ Slack rejected the bot token (${bot.detail}). Nothing was saved — reinstall the app and copy the token again.`);
|
|
2463
|
+
return;
|
|
2464
|
+
}
|
|
2465
|
+
const app = await verifySlackAppToken(appToken, fetchImpl);
|
|
2466
|
+
if (app.status === 'rejected') {
|
|
2467
|
+
writeLine(streams.stdout, `✗ Slack rejected the app-level token (${app.detail}). Nothing was saved — check it has the connections:write scope.`);
|
|
2468
|
+
return;
|
|
2469
|
+
}
|
|
2470
|
+
const slack = { ...(state.channels.slack ?? {}) };
|
|
2471
|
+
slack[agentId] = { appToken, botToken };
|
|
2472
|
+
state.channels = { ...state.channels, slack };
|
|
2473
|
+
state.channelsDirty = true;
|
|
2474
|
+
if (bot.status === 'ok' && app.status === 'ok') {
|
|
2475
|
+
const where = bot.identity.teamName ? ` in ${bot.identity.teamName}` : '';
|
|
2476
|
+
const who = bot.identity.botUserId ? ` (bot ${bot.identity.botUserId})` : '';
|
|
2477
|
+
writeLine(streams.stdout, `✓ Verified — ${agentName} is connected to Slack${where}${who}.`);
|
|
2478
|
+
}
|
|
2479
|
+
else {
|
|
2480
|
+
// Unreachable is not a verdict on the tokens: save and let the
|
|
2481
|
+
// daemon report on its first connection attempt.
|
|
2482
|
+
const detail = bot.status === 'unreachable' ? bot.detail : app.detail;
|
|
2483
|
+
writeLine(streams.stdout, `! Could not reach Slack to verify (${detail}). Saved the tokens anyway — \`${serveCommand()}\` will report on startup.`);
|
|
2484
|
+
}
|
|
2485
|
+
};
|
|
2486
|
+
/**
|
|
2487
|
+
* Every agent the gateway would dispatch to: the built-in Stratus
|
|
2488
|
+
* agent, the ~/.stratus/agents roster, and the configured default soul,
|
|
2489
|
+
* which `stratus setup` can point at a file anywhere. The gateway
|
|
2490
|
+
* registers all three, so a Channels list built from the agents
|
|
2491
|
+
* directory alone would hide agents Slack can perfectly well talk to.
|
|
2492
|
+
* Reads state.soulPath rather than the saved config so a soul chosen
|
|
2493
|
+
* earlier in this same setup session is already connectable.
|
|
2494
|
+
*/
|
|
2495
|
+
const channelRoster = async () => {
|
|
2496
|
+
const warnOnce = (message) => writeLine(streams.stderr, `Warning: ${message}.`);
|
|
2497
|
+
// Seeded before the roster loads, exactly as loadRoster does it: a
|
|
2498
|
+
// fresh install with no soul files still has an agent to put on
|
|
2499
|
+
// Slack, and a roster file that declares id "stratus" cannot take the
|
|
2500
|
+
// built-in's place — the gateway skips it, so offering it here would
|
|
2501
|
+
// name an app after an agent that never receives the messages.
|
|
2502
|
+
const entries = [{ soul: { agent: { ...DEFAULT_STRATUS_AGENT } } }];
|
|
2503
|
+
// Mirror the gateway's loadRoster: two soul files declaring the same
|
|
2504
|
+
// id are not two agents. Offering both would let someone create and
|
|
2505
|
+
// name an app for the loser, which can never receive a message —
|
|
2506
|
+
// Slack credentials are keyed by the shared id, and dispatch reaches
|
|
2507
|
+
// the first sorted file.
|
|
2508
|
+
for (const entry of await loadRosterSouls(env, warnOnce)) {
|
|
2509
|
+
const clash = entries.find((seen) => seen.soul.agent.id === entry.soul.agent.id);
|
|
2510
|
+
if (clash) {
|
|
2511
|
+
warnOnce(`duplicate agent id ${entry.soul.agent.id} (${clash.path ?? 'built-in'} vs ${entry.path}); keeping the first`);
|
|
2512
|
+
continue;
|
|
2513
|
+
}
|
|
2514
|
+
entries.push(entry);
|
|
2515
|
+
}
|
|
2516
|
+
// The soul a run resolves to, in resolveSoulPath's own order: an env
|
|
2517
|
+
// override outranks the config value this setup session is editing.
|
|
2518
|
+
// Listing the config soul while `stratus serve` registers the env one
|
|
2519
|
+
// would store tokens against an id the adapter then skips.
|
|
2520
|
+
const processEnv = readProcessEnv(env);
|
|
2521
|
+
const envSoul = readNonEmptyString(processEnv.STRATUS_SOUL)
|
|
2522
|
+
?? readNonEmptyString(processEnv.STRATUSCLAW_SOUL);
|
|
2523
|
+
if (typeof envSoul === 'string' && state.soulPath && envSoul !== state.soulPath) {
|
|
2524
|
+
warnOnce(`STRATUS_SOUL points at ${envSoul}, which outranks the configured ${state.soulPath} — Channels lists what a run would actually use`);
|
|
2525
|
+
}
|
|
2526
|
+
const effectiveSoul = typeof envSoul === 'string' ? envSoul : state.soulPath;
|
|
2527
|
+
if (!effectiveSoul) {
|
|
2528
|
+
return entries;
|
|
2529
|
+
}
|
|
2530
|
+
const resolved = path.resolve(readWorkingDirectory(env), effectiveSoul);
|
|
2531
|
+
if (entries.some((entry) => entry.path === resolved)) {
|
|
2532
|
+
return entries;
|
|
2533
|
+
}
|
|
2534
|
+
try {
|
|
2535
|
+
const soul = await loadSoulFile(resolved);
|
|
2536
|
+
// An explicit id can collide with a roster file, or with the
|
|
2537
|
+
// built-in. The gateway's defaultAgentId replaces the registered
|
|
2538
|
+
// source whenever it is pathless (the built-in) or resolves to a
|
|
2539
|
+
// different file, so the configured soul is the one Slack actually
|
|
2540
|
+
// dispatches to — offering the namesake here would connect an app
|
|
2541
|
+
// to a different agent than it names.
|
|
2542
|
+
const collision = entries.findIndex((entry) => entry.soul.agent.id === soul.agent.id);
|
|
2543
|
+
if (collision >= 0) {
|
|
2544
|
+
entries.splice(collision, 1);
|
|
2545
|
+
}
|
|
2546
|
+
entries.unshift({ soul, path: resolved });
|
|
2547
|
+
}
|
|
2548
|
+
catch (error) {
|
|
2549
|
+
warnOnce(`could not read the default soul ${resolved} (${error instanceof Error ? error.message : String(error)})`);
|
|
2550
|
+
}
|
|
2551
|
+
return entries;
|
|
2552
|
+
};
|
|
2553
|
+
const serviceSummary = () => {
|
|
2554
|
+
if (!servicePlatform(serviceEnvFor(env))) {
|
|
2555
|
+
return `not available on ${env.processEnv?.OSTYPE ?? process.platform} — run \`stratus serve\` yourself`;
|
|
2556
|
+
}
|
|
2557
|
+
if (!state.service.install) {
|
|
2558
|
+
return 'off — start stratusd yourself with `stratus serve`';
|
|
2559
|
+
}
|
|
2560
|
+
return state.service.runAtLogin
|
|
2561
|
+
? 'stratusd runs after setup, and at every login'
|
|
2562
|
+
: 'stratusd runs after setup, but not at login';
|
|
2563
|
+
};
|
|
2564
|
+
/**
|
|
2565
|
+
* Whether the roster keeps answering once this terminal closes. On by
|
|
2566
|
+
* default: an agent you have to remember to start is not always-on, and
|
|
2567
|
+
* every Slack app configured above is silent until stratusd runs.
|
|
2568
|
+
*/
|
|
2569
|
+
const chooseService = async () => {
|
|
2570
|
+
if (!servicePlatform(serviceEnvFor(env))) {
|
|
2571
|
+
writeLine(streams.stdout);
|
|
2572
|
+
writeLine(streams.stdout, `Stratus has no service integration for ${process.platform} yet — run \`stratus serve\` yourself, or supervise it however you prefer.`);
|
|
2573
|
+
await prompter.ask('Press Enter to return to the menu… ');
|
|
2574
|
+
return;
|
|
2575
|
+
}
|
|
2576
|
+
const choice = await prompter.select('Always on — keep stratusd running in the background', [
|
|
2577
|
+
'Run after setup, and start again at every login (recommended)',
|
|
2578
|
+
'Run after setup, but do not start at login',
|
|
2579
|
+
'Do not run it for me — I will start `stratus serve` myself',
|
|
2580
|
+
'Back',
|
|
2581
|
+
], {
|
|
2582
|
+
footnote: process.platform === 'darwin'
|
|
2583
|
+
// Said here rather than left to be discovered after a reboot.
|
|
2584
|
+
? 'A LaunchAgent starts at login, not at power-on. For a machine that should recover unattended, turn on automatic login too.'
|
|
2585
|
+
: 'A user service starts at login. `loginctl enable-linger` keeps it up on a machine you do not stay logged in to.',
|
|
2586
|
+
});
|
|
2587
|
+
if (choice.kind !== 'index' || choice.index === 3) {
|
|
2588
|
+
return;
|
|
2589
|
+
}
|
|
2590
|
+
state.service = {
|
|
2591
|
+
install: choice.index !== 2,
|
|
2592
|
+
runAtLogin: choice.index === 0,
|
|
2593
|
+
};
|
|
2594
|
+
};
|
|
2595
|
+
const chooseChannels = async () => {
|
|
2596
|
+
while (true) {
|
|
2597
|
+
const roster = await channelRoster();
|
|
2598
|
+
const slack = state.channels.slack ?? {};
|
|
2599
|
+
// The roster always holds at least the built-in agent, so there is
|
|
2600
|
+
// no empty state to short-circuit on — and short-circuiting would
|
|
2601
|
+
// strand orphaned tokens, which are only reachable from this list.
|
|
2602
|
+
const options = roster.map((entry) => {
|
|
2603
|
+
const id = entry.soul.agent.id;
|
|
2604
|
+
const status = slack[id] ? '✓ connected' : '— not connected';
|
|
2605
|
+
return `${entry.soul.agent.name} (${id})`.padEnd(34) + status;
|
|
2606
|
+
});
|
|
2607
|
+
// Orphans: tokens whose agent left the roster would otherwise be
|
|
2608
|
+
// invisible here while still being loaded by `stratus serve`.
|
|
2609
|
+
const orphans = Object.keys(slack).filter((id) => !roster.some((entry) => entry.soul.agent.id === id));
|
|
2610
|
+
for (const id of orphans) {
|
|
2611
|
+
options.push(`${id}`.padEnd(34) + '! tokens without a matching agent');
|
|
2612
|
+
}
|
|
2613
|
+
options.push('Back');
|
|
2614
|
+
const choice = await prompter.select('Channels — Slack (one app per agent: its own name, avatar, and presence)', options, { footnote: `Run \`${serveCommand()}\` afterwards to bring the connected agents online.` });
|
|
2615
|
+
if (choice.kind !== 'index' || choice.index === options.length - 1) {
|
|
2616
|
+
return;
|
|
2617
|
+
}
|
|
2618
|
+
if (choice.index >= roster.length) {
|
|
2619
|
+
// An orphaned entry — offer to clear it.
|
|
2620
|
+
const orphanId = orphans[choice.index - roster.length];
|
|
2621
|
+
if (!orphanId) {
|
|
2622
|
+
return;
|
|
2623
|
+
}
|
|
2624
|
+
const confirm = await prompter.select(`No agent with id ${orphanId} is in the roster.`, [
|
|
2625
|
+
'Remove these Slack tokens',
|
|
2626
|
+
'Keep them',
|
|
2627
|
+
]);
|
|
2628
|
+
if (confirm.kind === 'index' && confirm.index === 0) {
|
|
2629
|
+
const next = { ...slack };
|
|
2630
|
+
delete next[orphanId];
|
|
2631
|
+
state.channels = { ...state.channels, slack: next };
|
|
2632
|
+
state.channelsDirty = true;
|
|
2633
|
+
writeLine(streams.stdout, `Removed the Slack tokens for ${orphanId}.`);
|
|
2634
|
+
}
|
|
2635
|
+
continue;
|
|
2636
|
+
}
|
|
2637
|
+
const entry = roster[choice.index];
|
|
2638
|
+
if (!entry) {
|
|
2639
|
+
return;
|
|
2640
|
+
}
|
|
2641
|
+
const agentId = entry.soul.agent.id;
|
|
2642
|
+
const agentName = entry.soul.agent.name;
|
|
2643
|
+
if (!slack[agentId]) {
|
|
2644
|
+
await connectSlackAgent(agentId, agentName);
|
|
2645
|
+
continue;
|
|
2646
|
+
}
|
|
2647
|
+
const action = await prompter.select(`${agentName} is connected to Slack.`, [
|
|
2648
|
+
'Replace the tokens (re-run the app setup)',
|
|
2649
|
+
'Disconnect from Slack (forget the tokens)',
|
|
2650
|
+
'Back',
|
|
2651
|
+
]);
|
|
2652
|
+
if (action.kind !== 'index' || action.index === 2) {
|
|
2653
|
+
continue;
|
|
2654
|
+
}
|
|
2655
|
+
if (action.index === 0) {
|
|
2656
|
+
await connectSlackAgent(agentId, agentName);
|
|
2657
|
+
continue;
|
|
2658
|
+
}
|
|
2659
|
+
const next = { ...slack };
|
|
2660
|
+
delete next[agentId];
|
|
2661
|
+
state.channels = { ...state.channels, slack: next };
|
|
2662
|
+
state.channelsDirty = true;
|
|
2663
|
+
writeLine(streams.stdout, `${agentName} is no longer connected to Slack. The Slack app itself still exists — delete it at api.slack.com/apps if you are done with it.`);
|
|
2664
|
+
}
|
|
2665
|
+
};
|
|
2046
2666
|
const testRun = async () => {
|
|
2047
2667
|
const runtime = await buildTestRuntime();
|
|
2048
2668
|
if (!runtime) {
|
|
@@ -2092,7 +2712,6 @@ export const runSetup = async (command, streams, env = {}) => {
|
|
|
2092
2712
|
};
|
|
2093
2713
|
};
|
|
2094
2714
|
const save = async () => {
|
|
2095
|
-
let shadowConfigFlag = '';
|
|
2096
2715
|
const config = { provider: state.provider };
|
|
2097
2716
|
if (state.provider !== 'demo') {
|
|
2098
2717
|
config.model = state.model ?? defaultModelFor(state.provider);
|
|
@@ -2149,6 +2768,66 @@ export const runSetup = async (command, streams, env = {}) => {
|
|
|
2149
2768
|
await saveCredentials(env, state.credentials);
|
|
2150
2769
|
writeLine(streams.stdout, `Saved your sign-in to ${credentialsPath(env)} (readable only by you).`);
|
|
2151
2770
|
}
|
|
2771
|
+
if (state.channelsDirty) {
|
|
2772
|
+
// Channel tokens live in their own namespace of the same file; the
|
|
2773
|
+
// writers merge, so this never clobbers the provider sign-in above.
|
|
2774
|
+
await saveChannelCredentials(env, state.channels);
|
|
2775
|
+
const connected = Object.keys(state.channels.slack ?? {}).length;
|
|
2776
|
+
writeLine(streams.stdout, connected > 0
|
|
2777
|
+
? `Saved Slack tokens for ${connected} agent${connected === 1 ? '' : 's'} to ${credentialsPath(env)} — run \`${serveCommand()}\` to bring them online.`
|
|
2778
|
+
: `Removed the stored Slack tokens from ${credentialsPath(env)}.`);
|
|
2779
|
+
}
|
|
2780
|
+
// Last, so the daemon starts against the config and credentials that
|
|
2781
|
+
// were just written rather than the ones it would have found a moment
|
|
2782
|
+
// ago. A service failure is reported and never fails setup: the
|
|
2783
|
+
// settings are already saved, and `stratus serve` still works by hand.
|
|
2784
|
+
if (!state.service.install && servicePlatform(serviceEnvFor(env))) {
|
|
2785
|
+
// Opting out has to actually take effect. Skipping the install would
|
|
2786
|
+
// leave a unit from an earlier setup running and enabled at login,
|
|
2787
|
+
// while the menu said "off" — still burning provider usage and still
|
|
2788
|
+
// answering in Slack after an explicit opt-out.
|
|
2789
|
+
const existing = await readServiceStatus(serviceEnvFor(env)).catch(() => undefined);
|
|
2790
|
+
if (existing?.installed) {
|
|
2791
|
+
// Removal deletes the unit file, so it can reject the same way the
|
|
2792
|
+
// install can. Setup's settings are already written by this point;
|
|
2793
|
+
// the optional service must not take the whole command down.
|
|
2794
|
+
const removed = await uninstallService(serviceEnvFor(env)).catch((error) => ({
|
|
2795
|
+
ok: false,
|
|
2796
|
+
messages: [
|
|
2797
|
+
`Could not remove the always-on service: ${error instanceof Error ? error.message : String(error)}`,
|
|
2798
|
+
`${serviceUnitPath(serviceEnvFor(env))} is still in place — remove it by hand, or it will start again at login.`,
|
|
2799
|
+
],
|
|
2800
|
+
}));
|
|
2801
|
+
for (const message of removed.messages) {
|
|
2802
|
+
writeLine(removed.ok ? streams.stdout : streams.stderr, message);
|
|
2803
|
+
}
|
|
2804
|
+
}
|
|
2805
|
+
}
|
|
2806
|
+
else if (state.service.install && servicePlatform(serviceEnvFor(env))) {
|
|
2807
|
+
// The unit is pinned to the file setup just wrote. Its working
|
|
2808
|
+
// directory is the home directory, so discovery from there would
|
|
2809
|
+
// find a different config whenever setup was run with --config or
|
|
2810
|
+
// STRATUS_CONFIG — the daemon would come up on another roster and
|
|
2811
|
+
// leave the Slack apps configured above offline.
|
|
2812
|
+
// installService writes files, so it can reject outright — an
|
|
2813
|
+
// inaccessible ~/Library/LaunchAgents, a read-only home. Letting
|
|
2814
|
+
// that escape would fail setup itself, after the config and
|
|
2815
|
+
// credentials were already saved, when the always-on service is the
|
|
2816
|
+
// one optional part of it.
|
|
2817
|
+
const result = await installService(serviceEnvFor(env), {
|
|
2818
|
+
runAtLogin: state.service.runAtLogin,
|
|
2819
|
+
configPath,
|
|
2820
|
+
}).catch((error) => ({
|
|
2821
|
+
ok: false,
|
|
2822
|
+
messages: [`Could not install the always-on service: ${error instanceof Error ? error.message : String(error)}`],
|
|
2823
|
+
}));
|
|
2824
|
+
for (const message of result.messages) {
|
|
2825
|
+
writeLine(result.ok ? streams.stdout : streams.stderr, message);
|
|
2826
|
+
}
|
|
2827
|
+
if (!result.ok) {
|
|
2828
|
+
writeLine(streams.stderr, `Setup is saved either way — start the daemon yourself with \`${serveCommand()}\`.`);
|
|
2829
|
+
}
|
|
2830
|
+
}
|
|
2152
2831
|
writeLine(streams.stdout);
|
|
2153
2832
|
// Exported STRATUS_* variables outrank the config file, so warn when one
|
|
2154
2833
|
// would make `stratus run` behave differently from what was just saved.
|
|
@@ -2210,11 +2889,13 @@ export const runSetup = async (command, streams, env = {}) => {
|
|
|
2210
2889
|
`Providers ${providersSummary()}`,
|
|
2211
2890
|
`Models ${modelsSummary()}`,
|
|
2212
2891
|
`Agent ${agentSummary()}`,
|
|
2892
|
+
`Channels ${channelsSummary()}`,
|
|
2893
|
+
`Always on ${serviceSummary()}`,
|
|
2213
2894
|
'Test run say hello with the current settings',
|
|
2214
2895
|
'Save & finish',
|
|
2215
2896
|
]);
|
|
2216
2897
|
// Backing out of the top level (Esc, or the input ending) saves.
|
|
2217
|
-
if (choice.kind !== 'index' || choice.index ===
|
|
2898
|
+
if (choice.kind !== 'index' || choice.index === 6) {
|
|
2218
2899
|
break;
|
|
2219
2900
|
}
|
|
2220
2901
|
if (choice.index === 0) {
|
|
@@ -2227,6 +2908,12 @@ export const runSetup = async (command, streams, env = {}) => {
|
|
|
2227
2908
|
await chooseAgent();
|
|
2228
2909
|
}
|
|
2229
2910
|
else if (choice.index === 3) {
|
|
2911
|
+
await chooseChannels();
|
|
2912
|
+
}
|
|
2913
|
+
else if (choice.index === 4) {
|
|
2914
|
+
await chooseService();
|
|
2915
|
+
}
|
|
2916
|
+
else if (choice.index === 5) {
|
|
2230
2917
|
await testRun();
|
|
2231
2918
|
}
|
|
2232
2919
|
}
|
|
@@ -2381,6 +3068,519 @@ export const runAgents = async (command, streams, env = {}) => {
|
|
|
2381
3068
|
: 'Talk to the default with stratus run, or to anyone with stratus run --soul <file>.');
|
|
2382
3069
|
return 0;
|
|
2383
3070
|
};
|
|
3071
|
+
/**
|
|
3072
|
+
* `stratus doctor` — what a run would resolve to right now, and which file
|
|
3073
|
+
* or variable decided each part. Every "why is it using X" question needs
|
|
3074
|
+
* the precedence chain, and reading it out of the source is not something
|
|
3075
|
+
* anyone should have to do.
|
|
3076
|
+
*/
|
|
3077
|
+
export const collectDoctorReport = async (command, warn, env = {}) => {
|
|
3078
|
+
const processEnv = readProcessEnv(env);
|
|
3079
|
+
const cwd = readWorkingDirectory(env);
|
|
3080
|
+
const problems = [];
|
|
3081
|
+
// Config discovery, spelled out rather than delegated: doctor has to
|
|
3082
|
+
// report the files that LOST as well as the one in use, which is the
|
|
3083
|
+
// whole point when a stray project config is the answer.
|
|
3084
|
+
// Which of the three named it, not just that one did: telling someone to
|
|
3085
|
+
// fix STRATUS_CONFIG when STRATUSCLAW_CONFIG is what is set leaves the
|
|
3086
|
+
// real override in place — the same mistake as provider and key
|
|
3087
|
+
// attribution, in the one place a typo is most likely.
|
|
3088
|
+
const explicitSource = command.configPath !== undefined
|
|
3089
|
+
? { name: '--config', value: command.configPath }
|
|
3090
|
+
: readNonEmptyString(processEnv.STRATUS_CONFIG)
|
|
3091
|
+
? { name: 'STRATUS_CONFIG', value: String(processEnv.STRATUS_CONFIG) }
|
|
3092
|
+
: readNonEmptyString(processEnv.STRATUSCLAW_CONFIG)
|
|
3093
|
+
? { name: 'STRATUSCLAW_CONFIG', value: String(processEnv.STRATUSCLAW_CONFIG) }
|
|
3094
|
+
: undefined;
|
|
3095
|
+
const explicit = explicitSource?.value;
|
|
3096
|
+
const candidates = explicitSource
|
|
3097
|
+
? [{ path: path.resolve(cwd, explicitSource.value), label: explicitSource.name }]
|
|
3098
|
+
: [
|
|
3099
|
+
{ path: path.join(cwd, DEFAULT_CONFIG_FILENAME), label: 'project' },
|
|
3100
|
+
{ path: path.join(cwd, LEGACY_CONFIG_FILENAME), label: 'project (legacy)' },
|
|
3101
|
+
{ path: globalConfigPath(env), label: 'global' },
|
|
3102
|
+
];
|
|
3103
|
+
const present = [];
|
|
3104
|
+
let unreadable;
|
|
3105
|
+
for (const candidate of candidates) {
|
|
3106
|
+
try {
|
|
3107
|
+
await readFile(candidate.path, 'utf8');
|
|
3108
|
+
present.push(candidate);
|
|
3109
|
+
}
|
|
3110
|
+
catch (error) {
|
|
3111
|
+
const code = error.code;
|
|
3112
|
+
if (code !== 'ENOENT') {
|
|
3113
|
+
// resolveConfigLocation throws here rather than trying the next
|
|
3114
|
+
// candidate, so discovery stops: a lower-priority file that a real
|
|
3115
|
+
// run never reaches must not be reported as the config in use.
|
|
3116
|
+
unreadable = candidate;
|
|
3117
|
+
problems.push(`${candidate.path} exists but cannot be read (${error.message}). Every run fails here — discovery never reaches a lower-priority config.`);
|
|
3118
|
+
break;
|
|
3119
|
+
}
|
|
3120
|
+
if (explicit) {
|
|
3121
|
+
// Discovery treats a missing candidate as "try the next one", but
|
|
3122
|
+
// an explicitly named config has no next one: a real run calls
|
|
3123
|
+
// loadConfigFile on it and fails. Reporting built-in defaults here
|
|
3124
|
+
// would describe a run that cannot happen, and hide the typo.
|
|
3125
|
+
problems.push(`${candidate.path} does not exist, but ${candidate.label} names it — every run with this setting fails. `
|
|
3126
|
+
+ 'Fix the path, or drop the setting to fall back to config discovery.');
|
|
3127
|
+
}
|
|
3128
|
+
}
|
|
3129
|
+
}
|
|
3130
|
+
const winner = unreadable ?? present[0];
|
|
3131
|
+
const shadowed = unreadable ? [] : present.slice(1);
|
|
3132
|
+
if (winner && shadowed.length > 0 && winner.label.startsWith('project')) {
|
|
3133
|
+
problems.push(`${winner.path} outranks ${shadowed.map((entry) => entry.path).join(' and ')} for runs started in this directory. `
|
|
3134
|
+
+ 'Run from elsewhere, or pass --config to pick one explicitly.');
|
|
3135
|
+
}
|
|
3136
|
+
let fileConfig = {};
|
|
3137
|
+
let configFatal = unreadable !== undefined;
|
|
3138
|
+
if (winner && !unreadable) {
|
|
3139
|
+
try {
|
|
3140
|
+
fileConfig = await loadConfigFile(winner.path);
|
|
3141
|
+
}
|
|
3142
|
+
catch (error) {
|
|
3143
|
+
// resolveRuntimeConfig propagates this — no run reaches provider or
|
|
3144
|
+
// credential resolution, so presenting defaults as resolved settings
|
|
3145
|
+
// would describe a run that cannot happen.
|
|
3146
|
+
configFatal = true;
|
|
3147
|
+
problems.push(`${winner.path} could not be parsed (${error.message}). Every run fails until it is fixed or removed.`);
|
|
3148
|
+
warn(`ignoring unreadable config ${winner.path}`);
|
|
3149
|
+
}
|
|
3150
|
+
}
|
|
3151
|
+
// Which variable supplied a value, not just the value: naming the wrong
|
|
3152
|
+
// one sends the reader to unset something that was never the cause,
|
|
3153
|
+
// leaving the real override in place. The legacy STRATUSCLAW_ prefix is
|
|
3154
|
+
// still honored by the resolver, so it has to be reportable too.
|
|
3155
|
+
const envPick = (...names) => {
|
|
3156
|
+
for (const name of names) {
|
|
3157
|
+
const value = readNonEmptyString(processEnv[name]);
|
|
3158
|
+
if (typeof value === 'string') {
|
|
3159
|
+
return { name, value };
|
|
3160
|
+
}
|
|
3161
|
+
}
|
|
3162
|
+
return undefined;
|
|
3163
|
+
};
|
|
3164
|
+
/**
|
|
3165
|
+
* The verdict comes from the resolver, not from a second copy of its
|
|
3166
|
+
* rules. Everything below only *attributes* values to the file or
|
|
3167
|
+
* variable that supplied them — which the resolver does not report —
|
|
3168
|
+
* while whether a run works at all, and what it bills, is whatever
|
|
3169
|
+
* resolveRuntimeConfig actually returns or throws.
|
|
3170
|
+
*/
|
|
3171
|
+
const envSoul = envPick('STRATUS_SOUL', 'STRATUSCLAW_SOUL');
|
|
3172
|
+
const soulValue = envSoul?.value ?? fileConfig.soul;
|
|
3173
|
+
const soulSource = envSoul ? envSoul.name : (winner ? winner.path : '');
|
|
3174
|
+
const soulPath = typeof soulValue === 'string' ? path.resolve(cwd, soulValue) : undefined;
|
|
3175
|
+
// Loaded here for ATTRIBUTION only — the soul's frontmatter is what
|
|
3176
|
+
// explains a provider nobody wrote in a config file. Whether a run works
|
|
3177
|
+
// is still the resolver's verdict below.
|
|
3178
|
+
const soul = soulPath ? await loadSoulFile(soulPath).catch(() => undefined) : undefined;
|
|
3179
|
+
let resolved;
|
|
3180
|
+
if (!configFatal) {
|
|
3181
|
+
try {
|
|
3182
|
+
resolved = await resolveStateRuntimeConfig(command.configPath ? { configPath: command.configPath } : {}, env);
|
|
3183
|
+
}
|
|
3184
|
+
catch (error) {
|
|
3185
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
3186
|
+
problems.push(`No run can start: ${message}`);
|
|
3187
|
+
if (soulPath && /soul/i.test(message)) {
|
|
3188
|
+
// Worth saying explicitly: a soul that fails to load is fatal, not
|
|
3189
|
+
// a quiet downgrade to the built-in agent.
|
|
3190
|
+
problems.push(`Every run fails until ${soulPath} is fixed or the soul setting is removed — the built-in agent is not substituted.`);
|
|
3191
|
+
}
|
|
3192
|
+
}
|
|
3193
|
+
}
|
|
3194
|
+
const envProviderPick = envPick('STRATUS_PROVIDER', 'STRATUSCLAW_PROVIDER');
|
|
3195
|
+
const providerSource = envProviderPick
|
|
3196
|
+
? envProviderPick.name
|
|
3197
|
+
: soul?.provider
|
|
3198
|
+
? `${soulPath} (soul frontmatter)`
|
|
3199
|
+
: fileConfig.provider
|
|
3200
|
+
? (winner?.path ?? 'config')
|
|
3201
|
+
: 'built-in default — nothing set a provider';
|
|
3202
|
+
// When resolution fails the intended provider is still worth reporting:
|
|
3203
|
+
// "why is it anthropic" is exactly the question, and the problems below
|
|
3204
|
+
// already say no run can start.
|
|
3205
|
+
const intendedProvider = envProviderPick?.value ?? soul?.provider ?? fileConfig.provider ?? 'demo';
|
|
3206
|
+
const provider = resolved
|
|
3207
|
+
? { value: resolved.provider, source: providerSource }
|
|
3208
|
+
: { value: `${intendedProvider} (unreachable)`, source: providerSource };
|
|
3209
|
+
if (provider.value === 'demo') {
|
|
3210
|
+
// Setup writes the config file, which is the LOWEST precedence of the
|
|
3211
|
+
// three — so when an env var or a soul chose demo, "run setup" is
|
|
3212
|
+
// advice that changes nothing and leaves every run on demo.
|
|
3213
|
+
const fix = envProviderPick
|
|
3214
|
+
? `Unset ${envProviderPick.name}; it outranks both your config and any soul.`
|
|
3215
|
+
: soul?.provider === 'demo'
|
|
3216
|
+
? `Change or remove the provider pin in ${soulPath}; a soul outranks the config file.`
|
|
3217
|
+
: fileConfig.provider === 'demo'
|
|
3218
|
+
? `Change "provider" in ${winner?.path}, or run \`stratus setup\` → Providers.`
|
|
3219
|
+
: 'Run `stratus setup` → Providers and sign in, then Save & finish.';
|
|
3220
|
+
problems.push(`Provider is the offline demo model, so replies are canned. ${fix}`);
|
|
3221
|
+
}
|
|
3222
|
+
let model;
|
|
3223
|
+
if (resolved && resolved.provider !== 'demo') {
|
|
3224
|
+
const envModel = envPick('STRATUS_MODEL', 'STRATUSCLAW_MODEL');
|
|
3225
|
+
// A soul's model belongs to the soul's provider, and the config's model
|
|
3226
|
+
// to the config's provider — either can be stranded by an override.
|
|
3227
|
+
const soulModelApplies = soul?.provider === undefined || soul.provider === resolved.provider;
|
|
3228
|
+
const configModelApplies = (fileConfig.provider ?? 'openai') === resolved.provider;
|
|
3229
|
+
model = {
|
|
3230
|
+
value: resolved.model,
|
|
3231
|
+
source: envModel
|
|
3232
|
+
? envModel.name
|
|
3233
|
+
: soulModelApplies && soul?.model === resolved.model
|
|
3234
|
+
? `${soulPath} (soul frontmatter)`
|
|
3235
|
+
: configModelApplies && fileConfig.model === resolved.model
|
|
3236
|
+
? (winner?.path ?? 'config')
|
|
3237
|
+
: 'built-in default for this provider',
|
|
3238
|
+
};
|
|
3239
|
+
}
|
|
3240
|
+
const credentials = await loadCredentials(env);
|
|
3241
|
+
const signIns = [];
|
|
3242
|
+
const usedKeyEnvVar = resolved && resolved.provider !== 'demo' ? resolved.apiKeyEnvVar : undefined;
|
|
3243
|
+
const usesAuthToken = resolved?.provider === 'anthropic' && resolved.authToken !== undefined;
|
|
3244
|
+
const fallback = resolved && resolved.provider !== 'demo' ? resolved.fallback : undefined;
|
|
3245
|
+
for (const target of ['anthropic', 'openai']) {
|
|
3246
|
+
const stored = credentials[target];
|
|
3247
|
+
const label = stored?.type === 'oauth_token' ? 'Claude subscription (Pro/Max)' : 'API key';
|
|
3248
|
+
const isDefault = resolved?.provider === target;
|
|
3249
|
+
const isFallback = fallback?.provider === target;
|
|
3250
|
+
// A provider no resolved run consults is reported, never diagnosed:
|
|
3251
|
+
// flagging an override on a credential nothing reads is a false alarm.
|
|
3252
|
+
if (!isDefault && !isFallback) {
|
|
3253
|
+
signIns.push({
|
|
3254
|
+
provider: target,
|
|
3255
|
+
status: stored
|
|
3256
|
+
? `${label} (unused — ${resolved ? resolved.provider : 'nothing'} serves your runs)`
|
|
3257
|
+
: 'not signed in',
|
|
3258
|
+
});
|
|
3259
|
+
continue;
|
|
3260
|
+
}
|
|
3261
|
+
const envVar = isDefault ? usedKeyEnvVar : (target === 'openai' ? 'OPENAI_API_KEY' : 'ANTHROPIC_API_KEY');
|
|
3262
|
+
const viaEnv = isDefault
|
|
3263
|
+
? usedKeyEnvVar !== undefined
|
|
3264
|
+
: fallback?.apiKey !== undefined && fallback.apiKey === readNonEmptyString(processEnv[String(envVar)]);
|
|
3265
|
+
const where = isDefault ? 'runs are' : 'fallback runs are';
|
|
3266
|
+
if (stored && viaEnv) {
|
|
3267
|
+
signIns.push({ provider: target, status: `${label}, overridden by ${envVar} in your environment` });
|
|
3268
|
+
// The costly case: an env key silently demotes a subscription to
|
|
3269
|
+
// per-token billing, and nothing in a normal run says so.
|
|
3270
|
+
problems.push(stored.type === 'oauth_token'
|
|
3271
|
+
? `${envVar} in your environment outranks your saved ${target} subscription sign-in, so ${where} billed per token instead of through your plan. Unset it (check your shell profile) to use the subscription.`
|
|
3272
|
+
: `${envVar} in your environment outranks your saved ${target} sign-in. Unset it to use the one \`stratus setup\` stored.`);
|
|
3273
|
+
continue;
|
|
3274
|
+
}
|
|
3275
|
+
if (viaEnv) {
|
|
3276
|
+
signIns.push({ provider: target, status: `using ${envVar} from your environment` });
|
|
3277
|
+
continue;
|
|
3278
|
+
}
|
|
3279
|
+
if (stored) {
|
|
3280
|
+
signIns.push({ provider: target, status: isDefault && usesAuthToken ? `${label} — runs go through the Claude Code runtime` : label });
|
|
3281
|
+
continue;
|
|
3282
|
+
}
|
|
3283
|
+
signIns.push({ provider: target, status: 'not signed in' });
|
|
3284
|
+
}
|
|
3285
|
+
// A fallback the resolver dropped is the quiet failure: the config still
|
|
3286
|
+
// names one, but a failing primary has nothing to retry on.
|
|
3287
|
+
if (fileConfig.fallbackModel !== undefined && resolved && resolved.provider !== 'demo' && !fallback) {
|
|
3288
|
+
problems.push(`A fallback model (${fileConfig.fallbackModel}) is configured but could not be resolved — usually no sign-in for its provider, or an endpoint its saved key is not sent to. `
|
|
3289
|
+
+ 'A failing primary model has nothing to retry on.');
|
|
3290
|
+
}
|
|
3291
|
+
const channels = await loadChannelCredentials(env);
|
|
3292
|
+
const slackAgents = Object.keys(channels.slack ?? {}).sort();
|
|
3293
|
+
const slackPackageInstalled = (await loadSlackAdapter()) !== undefined;
|
|
3294
|
+
if (slackAgents.length > 0 && !slackPackageInstalled) {
|
|
3295
|
+
problems.push(`Slack tokens are stored for ${slackAgents.length} agent(s) but @stratusagent/channel-slack is not installed, so \`stratus serve\` skips them. `
|
|
3296
|
+
+ 'Install it with: npm install -g @stratusagent/channel-slack');
|
|
3297
|
+
}
|
|
3298
|
+
let rosterCount = 0;
|
|
3299
|
+
try {
|
|
3300
|
+
rosterCount = (await readdir(agentsDirPath(env))).filter((file) => file.endsWith('.md')).length;
|
|
3301
|
+
}
|
|
3302
|
+
catch (error) {
|
|
3303
|
+
if (error.code !== 'ENOENT') {
|
|
3304
|
+
throw error;
|
|
3305
|
+
}
|
|
3306
|
+
}
|
|
3307
|
+
// Tokens keyed to an agent the roster no longer has are loaded and
|
|
3308
|
+
// skipped on every start, silently.
|
|
3309
|
+
const rosterIds = new Set((await loadRosterSouls(env, warn)).map((entry) => entry.soul.agent.id));
|
|
3310
|
+
rosterIds.add(DEFAULT_STRATUS_AGENT.id);
|
|
3311
|
+
if (soul) {
|
|
3312
|
+
rosterIds.add(soul.agent.id);
|
|
3313
|
+
}
|
|
3314
|
+
const orphans = slackAgents.filter((id) => !rosterIds.has(id));
|
|
3315
|
+
if (orphans.length > 0) {
|
|
3316
|
+
problems.push(`Slack tokens are stored for ${orphans.join(', ')}, which no agent matches — \`stratus serve\` skips them. `
|
|
3317
|
+
+ 'Clear them from `stratus setup` → Channels.');
|
|
3318
|
+
}
|
|
3319
|
+
return {
|
|
3320
|
+
...(winner ? { configInUse: winner.path } : {}),
|
|
3321
|
+
configShadowed: shadowed.map((entry) => entry.path),
|
|
3322
|
+
provider,
|
|
3323
|
+
...(model ? { model } : {}),
|
|
3324
|
+
...(soulPath ? { soul: { value: soulPath, source: soulSource } } : {}),
|
|
3325
|
+
...(soul ? { agent: `${soul.agent.name} (${soul.agent.id})` } : {}),
|
|
3326
|
+
...(fallback ? { fallback: { provider: fallback.provider, model: fallback.model } } : {}),
|
|
3327
|
+
signIns,
|
|
3328
|
+
slackAgents,
|
|
3329
|
+
slackPackageInstalled,
|
|
3330
|
+
rosterCount,
|
|
3331
|
+
problems,
|
|
3332
|
+
};
|
|
3333
|
+
};
|
|
3334
|
+
export const runDoctor = async (command, streams, env = {}) => {
|
|
3335
|
+
const report = await collectDoctorReport(command, (message) => {
|
|
3336
|
+
writeLine(streams.stderr, `Warning: ${message}.`);
|
|
3337
|
+
}, env);
|
|
3338
|
+
if (command.format === 'json') {
|
|
3339
|
+
writeLine(streams.stdout, JSON.stringify(report, null, 2));
|
|
3340
|
+
return report.problems.length > 0 ? 1 : 0;
|
|
3341
|
+
}
|
|
3342
|
+
const field = (label, setting) => {
|
|
3343
|
+
if (!setting) {
|
|
3344
|
+
return;
|
|
3345
|
+
}
|
|
3346
|
+
writeLine(streams.stdout, ` ${label.padEnd(10)}${setting.value}`);
|
|
3347
|
+
writeLine(streams.stdout, ` ${''.padEnd(10)}from ${setting.source}`);
|
|
3348
|
+
};
|
|
3349
|
+
writeLine(streams.stdout, 'Stratus Agent — what a run would use right now');
|
|
3350
|
+
writeLine(streams.stdout);
|
|
3351
|
+
field('provider', report.provider);
|
|
3352
|
+
field('model', report.model);
|
|
3353
|
+
field('soul', report.soul);
|
|
3354
|
+
if (report.agent) {
|
|
3355
|
+
writeLine(streams.stdout, ` ${'agent'.padEnd(10)}${report.agent}`);
|
|
3356
|
+
}
|
|
3357
|
+
else if (report.soul) {
|
|
3358
|
+
// A soul is configured but did not load — saying "built-in" here would
|
|
3359
|
+
// describe a run that cannot happen.
|
|
3360
|
+
writeLine(streams.stdout, ` ${'agent'.padEnd(10)}unresolved — the configured soul could not be read`);
|
|
3361
|
+
}
|
|
3362
|
+
else {
|
|
3363
|
+
writeLine(streams.stdout, ` ${'agent'.padEnd(10)}${DEFAULT_STRATUS_AGENT.name} (built-in — no soul configured)`);
|
|
3364
|
+
}
|
|
3365
|
+
writeLine(streams.stdout);
|
|
3366
|
+
writeLine(streams.stdout, 'Files');
|
|
3367
|
+
writeLine(streams.stdout, ` config ${report.configInUse ?? 'none found — built-in defaults apply'}`);
|
|
3368
|
+
for (const shadowedPath of report.configShadowed) {
|
|
3369
|
+
writeLine(streams.stdout, ` (${shadowedPath} exists but is outranked)`);
|
|
3370
|
+
}
|
|
3371
|
+
writeLine(streams.stdout, ` agents ${report.rosterCount} soul file${report.rosterCount === 1 ? '' : 's'}`);
|
|
3372
|
+
if (report.fallback) {
|
|
3373
|
+
writeLine(streams.stdout, ` ${'fallback'.padEnd(10)}${report.fallback.provider} · ${report.fallback.model}`);
|
|
3374
|
+
writeLine(streams.stdout, ` ${''.padEnd(10)}used when the default model errors mid-run`);
|
|
3375
|
+
}
|
|
3376
|
+
writeLine(streams.stdout);
|
|
3377
|
+
writeLine(streams.stdout, 'Sign-ins');
|
|
3378
|
+
for (const entry of report.signIns) {
|
|
3379
|
+
writeLine(streams.stdout, ` ${entry.provider.padEnd(10)}${entry.status}`);
|
|
3380
|
+
}
|
|
3381
|
+
writeLine(streams.stdout);
|
|
3382
|
+
writeLine(streams.stdout, 'Channels');
|
|
3383
|
+
writeLine(streams.stdout, ` slack ${report.slackAgents.length === 0
|
|
3384
|
+
? 'no agents connected'
|
|
3385
|
+
: `${report.slackAgents.length} connected (${report.slackAgents.join(', ')})`}`);
|
|
3386
|
+
writeLine(streams.stdout, ` @stratusagent/channel-slack ${report.slackPackageInstalled ? 'installed' : 'not installed'}`);
|
|
3387
|
+
writeLine(streams.stdout);
|
|
3388
|
+
if (report.problems.length === 0) {
|
|
3389
|
+
writeLine(streams.stdout, 'No problems found.');
|
|
3390
|
+
return 0;
|
|
3391
|
+
}
|
|
3392
|
+
writeLine(streams.stdout, `${report.problems.length} problem${report.problems.length === 1 ? '' : 's'} found:`);
|
|
3393
|
+
for (const problem of report.problems) {
|
|
3394
|
+
writeLine(streams.stdout, ` ! ${problem}`);
|
|
3395
|
+
}
|
|
3396
|
+
return 1;
|
|
3397
|
+
};
|
|
3398
|
+
/**
|
|
3399
|
+
* `stratus logs` — the daemon's structured log, filtered. `serve` streams
|
|
3400
|
+
* to its own stdout, which is gone the moment it runs under a service
|
|
3401
|
+
* manager; this reads the file it also writes, from any terminal.
|
|
3402
|
+
*/
|
|
3403
|
+
export const runLogs = async (command, streams, env = {}) => {
|
|
3404
|
+
const dir = logsDirPath(env);
|
|
3405
|
+
const filter = {
|
|
3406
|
+
...(command.agentId ? { agentId: command.agentId } : {}),
|
|
3407
|
+
...(command.sessionId ? { sessionId: command.sessionId } : {}),
|
|
3408
|
+
};
|
|
3409
|
+
const emit = (record) => {
|
|
3410
|
+
writeLine(streams.stdout, command.format === 'json' ? JSON.stringify(record) : formatLogRecord(record));
|
|
3411
|
+
};
|
|
3412
|
+
// Captured BEFORE the backlog is read: the daemon keeps writing while a
|
|
3413
|
+
// large backlog prints, and a follower that takes its offset afterwards
|
|
3414
|
+
// skips everything written in between — permanently.
|
|
3415
|
+
const followFrom = command.follow ? await currentLogPosition(dir) : undefined;
|
|
3416
|
+
// Bounded by the same offset the follower resumes from: without it, a
|
|
3417
|
+
// record written between the two reads is printed by the backlog and
|
|
3418
|
+
// then again by the stream.
|
|
3419
|
+
const recent = await readRecentRecords(dir, command.limit, filter, followFrom);
|
|
3420
|
+
for (const record of recent) {
|
|
3421
|
+
emit(record);
|
|
3422
|
+
}
|
|
3423
|
+
if (!command.follow) {
|
|
3424
|
+
if (recent.length === 0 && command.format === 'text') {
|
|
3425
|
+
writeLine(streams.stdout, `No log records yet in ${dir}. Start the daemon with \`stratus serve\`.`);
|
|
3426
|
+
}
|
|
3427
|
+
return 0;
|
|
3428
|
+
}
|
|
3429
|
+
const controller = new AbortController();
|
|
3430
|
+
const stop = () => controller.abort();
|
|
3431
|
+
process.once('SIGINT', stop);
|
|
3432
|
+
process.once('SIGTERM', stop);
|
|
3433
|
+
env.shutdownSignal?.addEventListener('abort', stop, { once: true });
|
|
3434
|
+
if (env.shutdownSignal?.aborted) {
|
|
3435
|
+
stop();
|
|
3436
|
+
}
|
|
3437
|
+
try {
|
|
3438
|
+
await tailLog({
|
|
3439
|
+
dir,
|
|
3440
|
+
filter,
|
|
3441
|
+
...(followFrom !== undefined ? { startPosition: followFrom } : {}),
|
|
3442
|
+
signal: controller.signal,
|
|
3443
|
+
onRecord: emit,
|
|
3444
|
+
});
|
|
3445
|
+
}
|
|
3446
|
+
finally {
|
|
3447
|
+
process.off('SIGINT', stop);
|
|
3448
|
+
process.off('SIGTERM', stop);
|
|
3449
|
+
}
|
|
3450
|
+
return 0;
|
|
3451
|
+
};
|
|
3452
|
+
/** Builds the service view of the CLI environment (home, exec paths, runner). */
|
|
3453
|
+
const serviceEnvFor = (env) => ({
|
|
3454
|
+
...(env.homeDir !== undefined ? { homeDir: env.homeDir } : {}),
|
|
3455
|
+
cwd: readWorkingDirectory(env),
|
|
3456
|
+
...(env.serviceRunner !== undefined ? { run: env.serviceRunner } : {}),
|
|
3457
|
+
});
|
|
3458
|
+
/**
|
|
3459
|
+
* A credential that exists only in this shell cannot reach the daemon: a
|
|
3460
|
+
* service manager starts with its own environment and never sources a
|
|
3461
|
+
* profile, so the unit would come up unauthenticated while setup had just
|
|
3462
|
+
* reported everything ready. Returns the variable to name, if so.
|
|
3463
|
+
*/
|
|
3464
|
+
/**
|
|
3465
|
+
* Every runtime the daemon would resolve: the config-wide default plus one
|
|
3466
|
+
* per roster soul, normalized the way a dispatch normalizes it. A soul that
|
|
3467
|
+
* pins its own provider resolves to different credentials entirely, so any
|
|
3468
|
+
* check that looks only at the default misses exactly the agent that is
|
|
3469
|
+
* misconfigured.
|
|
3470
|
+
*/
|
|
3471
|
+
const servedRuntimes = async (env, configPath) => {
|
|
3472
|
+
const { applySoulPins } = await import('@stratusagent/gateway');
|
|
3473
|
+
const { config: activeConfig, location } = await discoverActiveConfig(env, () => { });
|
|
3474
|
+
const context = {
|
|
3475
|
+
...(activeConfig.provider !== undefined ? { configProvider: activeConfig.provider } : {}),
|
|
3476
|
+
configPresent: location !== undefined,
|
|
3477
|
+
};
|
|
3478
|
+
const passes = [{ selection: {}, env }];
|
|
3479
|
+
for (const entry of await loadRosterSouls(env, () => { })) {
|
|
3480
|
+
const normalized = applySoulPins(entry.soul, {}, env, context);
|
|
3481
|
+
passes.push({ selection: normalized.selection, env: normalized.env });
|
|
3482
|
+
}
|
|
3483
|
+
const resolved = [];
|
|
3484
|
+
for (const pass of passes) {
|
|
3485
|
+
// A runtime that cannot resolve is the gateway's to report, per
|
|
3486
|
+
// dispatch and with far better context than a startup pass has.
|
|
3487
|
+
const runtime = await resolveStateRuntimeConfig({ ...pass.selection, ...(configPath ? { configPath } : {}) }, pass.env).catch(() => undefined);
|
|
3488
|
+
if (runtime) {
|
|
3489
|
+
resolved.push({ runtime, env: pass.env });
|
|
3490
|
+
}
|
|
3491
|
+
}
|
|
3492
|
+
return resolved;
|
|
3493
|
+
};
|
|
3494
|
+
/**
|
|
3495
|
+
* `stratus service` — run the daemon under launchd or systemd, so it
|
|
3496
|
+
* survives logout, crashes, and reboots. `serve` itself stays a plain
|
|
3497
|
+
* foreground process; this only tells the platform how to keep it up.
|
|
3498
|
+
*/
|
|
3499
|
+
export const runService = async (command, streams, env = {}) => {
|
|
3500
|
+
const serviceEnv = serviceEnvFor(env);
|
|
3501
|
+
if (command.action === 'status') {
|
|
3502
|
+
const status = await readServiceStatus(serviceEnv);
|
|
3503
|
+
if (!status) {
|
|
3504
|
+
writeLine(streams.stdout, `No service manager for ${process.platform}. Run \`stratus serve\` yourself.`);
|
|
3505
|
+
return 1;
|
|
3506
|
+
}
|
|
3507
|
+
writeLine(streams.stdout, `stratusd ${status.running ? 'running' : status.installed ? 'installed, not running' : 'not installed'}`);
|
|
3508
|
+
writeLine(streams.stdout, ` manager ${status.platform}`);
|
|
3509
|
+
writeLine(streams.stdout, ` unit ${status.unitPath}`);
|
|
3510
|
+
if (status.detail) {
|
|
3511
|
+
writeLine(streams.stdout, ` note ${status.detail}`);
|
|
3512
|
+
}
|
|
3513
|
+
if (status.installed) {
|
|
3514
|
+
writeLine(streams.stdout, ` at login ${status.runAtLogin === undefined
|
|
3515
|
+
? 'unknown — the service manager did not answer'
|
|
3516
|
+
: status.runAtLogin ? 'yes' : 'no'}`);
|
|
3517
|
+
}
|
|
3518
|
+
writeLine(streams.stdout, status.installed
|
|
3519
|
+
? ' logs stratus logs -f'
|
|
3520
|
+
: ' install stratus service install');
|
|
3521
|
+
return status.running ? 0 : 1;
|
|
3522
|
+
}
|
|
3523
|
+
// A service manager passes none of this shell's environment on, so a
|
|
3524
|
+
// config selected by STRATUS_CONFIG has to be baked into the unit
|
|
3525
|
+
// exactly as --config is. Without it the daemon rediscovers from the
|
|
3526
|
+
// install directory and can come up on a different roster entirely.
|
|
3527
|
+
const processEnv = readProcessEnv(env);
|
|
3528
|
+
const selectedConfig = command.configPath
|
|
3529
|
+
?? readNonEmptyString(processEnv.STRATUS_CONFIG)
|
|
3530
|
+
?? readNonEmptyString(processEnv.STRATUSCLAW_CONFIG);
|
|
3531
|
+
if (command.action === 'install') {
|
|
3532
|
+
// A config the daemon cannot parse kills it during gateway.start(),
|
|
3533
|
+
// and the manager — having accepted the start — restarts it on a
|
|
3534
|
+
// loop. Better to refuse now, while there is someone reading stderr.
|
|
3535
|
+
// Without an explicit selection the unit carries no --config flag and
|
|
3536
|
+
// discovers from its working directory — the same directory this is
|
|
3537
|
+
// running in — so the discovered file has to be validated too, not
|
|
3538
|
+
// just an explicitly named one.
|
|
3539
|
+
let configToCheck;
|
|
3540
|
+
if (selectedConfig) {
|
|
3541
|
+
configToCheck = path.resolve(readWorkingDirectory(env), String(selectedConfig));
|
|
3542
|
+
}
|
|
3543
|
+
else {
|
|
3544
|
+
try {
|
|
3545
|
+
configToCheck = (await resolveConfigLocation({}, env))?.path;
|
|
3546
|
+
}
|
|
3547
|
+
catch (error) {
|
|
3548
|
+
// Discovery throws when a candidate exists but cannot be read.
|
|
3549
|
+
// Treating that as "no config" would install a daemon that hits
|
|
3550
|
+
// the same error on its first dispatch.
|
|
3551
|
+
writeLine(streams.stderr, `Not installing: ${error instanceof Error ? error.message : String(error)}`);
|
|
3552
|
+
writeLine(streams.stderr, 'The daemon would fail the same way on startup. Fix the file, or move it aside.');
|
|
3553
|
+
return 1;
|
|
3554
|
+
}
|
|
3555
|
+
}
|
|
3556
|
+
if (configToCheck) {
|
|
3557
|
+
try {
|
|
3558
|
+
await loadConfigFile(configToCheck);
|
|
3559
|
+
}
|
|
3560
|
+
catch (error) {
|
|
3561
|
+
writeLine(streams.stderr, `Not installing: ${configToCheck} cannot be used (${error instanceof Error ? error.message : String(error)}).`);
|
|
3562
|
+
writeLine(streams.stderr, 'The daemon would exit on startup and be restarted in a loop. Fix the file, or move it aside.');
|
|
3563
|
+
return 1;
|
|
3564
|
+
}
|
|
3565
|
+
}
|
|
3566
|
+
}
|
|
3567
|
+
const action = command.action === 'install'
|
|
3568
|
+
? installService(serviceEnv, {
|
|
3569
|
+
...(command.runAtLogin === false ? { runAtLogin: false } : {}),
|
|
3570
|
+
// Absolute: resolved against the directory the install ran in.
|
|
3571
|
+
...(selectedConfig ? { configPath: path.resolve(readWorkingDirectory(env), String(selectedConfig)) } : {}),
|
|
3572
|
+
})
|
|
3573
|
+
: command.action === 'uninstall'
|
|
3574
|
+
? uninstallService(serviceEnv)
|
|
3575
|
+
: command.action === 'start'
|
|
3576
|
+
? startService(serviceEnv)
|
|
3577
|
+
: stopService(serviceEnv);
|
|
3578
|
+
const result = await action;
|
|
3579
|
+
for (const message of result.messages) {
|
|
3580
|
+
writeLine(result.ok ? streams.stdout : streams.stderr, message);
|
|
3581
|
+
}
|
|
3582
|
+
return result.ok ? 0 : 1;
|
|
3583
|
+
};
|
|
2384
3584
|
export const runAgentNew = async (command, streams, env = {}) => {
|
|
2385
3585
|
// On a real terminal, creating an agent is the same guided experience as
|
|
2386
3586
|
// setup: a headed screen, a prefilled (editable) name, a personality, and
|
|
@@ -2579,12 +3779,35 @@ const loadSlackAdapter = async () => {
|
|
|
2579
3779
|
* SIGINT, or the injected shutdown signal.
|
|
2580
3780
|
*/
|
|
2581
3781
|
export const runServe = async (command, streams, env = {}) => {
|
|
3782
|
+
// First, before anything that can throw. A broken install makes the
|
|
3783
|
+
// gateway import below fail, the manager restarts, and the CLI's error
|
|
3784
|
+
// and help text append to the redirect file again — the crash loop this
|
|
3785
|
+
// bounding exists for. Running it after the import would skip exactly
|
|
3786
|
+
// that case.
|
|
3787
|
+
if (command.logToFile !== false) {
|
|
3788
|
+
await truncateRedirectLogs(logsDirPath(env)).catch(() => undefined);
|
|
3789
|
+
}
|
|
2582
3790
|
// Loaded lazily: the gateway pulls in node:sqlite, which every other CLI
|
|
2583
3791
|
// command neither needs nor should pay for (Node still prints an
|
|
2584
3792
|
// experimental warning for it).
|
|
2585
3793
|
const { createGateway } = await import('@stratusagent/gateway');
|
|
2586
|
-
|
|
2587
|
-
|
|
3794
|
+
// Under a service manager the daemon's stdout is gone, so everything it
|
|
3795
|
+
// says is also written to ~/.stratus/logs — that file is what `stratus
|
|
3796
|
+
// logs` reads, and the only record of an overnight run.
|
|
3797
|
+
const logWriter = command.logToFile === false
|
|
3798
|
+
? undefined
|
|
3799
|
+
: createLogWriter({
|
|
3800
|
+
dir: logsDirPath(env),
|
|
3801
|
+
onError: (error) => writeLine(streams.stderr, `Warning: could not write the log file (${error instanceof Error ? error.message : String(error)}); continuing.`),
|
|
3802
|
+
});
|
|
3803
|
+
const log = (line) => {
|
|
3804
|
+
writeLine(streams.stdout, line);
|
|
3805
|
+
void logWriter?.write({ ts: new Date().toISOString(), level: 'info', msg: line });
|
|
3806
|
+
};
|
|
3807
|
+
const warn = (line) => {
|
|
3808
|
+
writeLine(streams.stderr, `Warning: ${line}`);
|
|
3809
|
+
void logWriter?.write({ ts: new Date().toISOString(), level: 'warn', msg: line });
|
|
3810
|
+
};
|
|
2588
3811
|
// Agents with stored Slack tokens go live in Slack automatically — the
|
|
2589
3812
|
// tokens are gateway infrastructure secrets in the channels namespace of
|
|
2590
3813
|
// ~/.stratus/credentials.json (see @stratusagent/channel-slack's README
|
|
@@ -2615,8 +3838,61 @@ export const runServe = async (command, streams, env = {}) => {
|
|
|
2615
3838
|
+ 'Run `npm install -g @stratusagent/channel-slack` to bring them online; starting without the Slack channel.');
|
|
2616
3839
|
}
|
|
2617
3840
|
}
|
|
3841
|
+
// A daemon is the costliest place for a silently demoted subscription:
|
|
3842
|
+
// every dispatch for as long as it runs, with nobody watching. The
|
|
3843
|
+
// gateway resolves each agent's soul independently on dispatch, so an
|
|
3844
|
+
// agent pinning anthropic can pick up an environment key even when the
|
|
3845
|
+
// daemon's own default is openai — checking only the default selection
|
|
3846
|
+
// would miss exactly the agent that is being billed. Each distinct
|
|
3847
|
+
// selection is resolved once here, and duplicate warnings are collapsed
|
|
3848
|
+
// so a ten-agent roster does not print ten identical lines.
|
|
3849
|
+
{
|
|
3850
|
+
const warned = new Set();
|
|
3851
|
+
const collect = (line) => {
|
|
3852
|
+
if (warned.has(line)) {
|
|
3853
|
+
return;
|
|
3854
|
+
}
|
|
3855
|
+
warned.add(line);
|
|
3856
|
+
// Through `warn`, not straight to stderr: under a service manager
|
|
3857
|
+
// stderr is gone, and a cost warning that only exists there is
|
|
3858
|
+
// invisible in exactly the deployment it matters for. `warn` adds
|
|
3859
|
+
// its own prefix, so the one already on the line comes off.
|
|
3860
|
+
warn(line.replace(/^Warning: /, ''));
|
|
3861
|
+
};
|
|
3862
|
+
const captured = {
|
|
3863
|
+
stdout: { write: () => true },
|
|
3864
|
+
stderr: { write: (chunk) => { collect(chunk.replace(/\n$/, '')); return true; } },
|
|
3865
|
+
};
|
|
3866
|
+
// A pinned soul does not merely add a provider — the gateway DEMOTES
|
|
3867
|
+
// the daemon-wide defaults it outranks, including STRATUS_PROVIDER, so
|
|
3868
|
+
// each served runtime is resolved the way a dispatch resolves it.
|
|
3869
|
+
for (const served of await servedRuntimes(env, command.configPath)) {
|
|
3870
|
+
await warnOnCredentialOverride(served.runtime, captured, served.env);
|
|
3871
|
+
}
|
|
3872
|
+
}
|
|
3873
|
+
// The daemon had no policy at all: createGateway falls back to
|
|
3874
|
+
// AllowAllApprovalPolicy, so every tool call from every agent
|
|
3875
|
+
// auto-approved, indefinitely, with nobody watching. That was survivable
|
|
3876
|
+
// while `serve` was something you ran in a terminal you were sitting at;
|
|
3877
|
+
// it is not, now that setup installs it under launchd by default.
|
|
3878
|
+
//
|
|
3879
|
+
// Headless is the only honest mode here until a turn can park and ask
|
|
3880
|
+
// through a channel: there is no terminal behind a service manager, so
|
|
3881
|
+
// `safe` runs and anything riskier is refused rather than waved through.
|
|
3882
|
+
// Every refusal is logged — an unattended denial that appears nowhere
|
|
3883
|
+
// reads like an agent that decided not to bother.
|
|
3884
|
+
const approvals = createPermissionPolicy({
|
|
3885
|
+
mode: 'headless',
|
|
3886
|
+
onDecision: (decision) => {
|
|
3887
|
+
if (decision.allowed) {
|
|
3888
|
+
return;
|
|
3889
|
+
}
|
|
3890
|
+
warn(`${decision.agentId}: ${decision.reason} (session ${decision.sessionId})`);
|
|
3891
|
+
},
|
|
3892
|
+
});
|
|
2618
3893
|
const gateway = createGateway({
|
|
2619
3894
|
env,
|
|
3895
|
+
approvals,
|
|
2620
3896
|
...(command.configPath ? { selection: { configPath: command.configPath } } : {}),
|
|
2621
3897
|
...(command.idleTimeoutMs !== undefined ? { idleTimeoutMs: command.idleTimeoutMs } : {}),
|
|
2622
3898
|
...(channels.length > 0 ? { channels } : {}),
|
|
@@ -2631,35 +3907,111 @@ export const runServe = async (command, streams, env = {}) => {
|
|
|
2631
3907
|
}
|
|
2632
3908
|
});
|
|
2633
3909
|
}
|
|
3910
|
+
if (logWriter) {
|
|
3911
|
+
// Only session.created carries the agent id, so it seeds a map the
|
|
3912
|
+
// later events in that session read from. A session resumed after a
|
|
3913
|
+
// restart never re-creates, so an unmapped id falls back to the
|
|
3914
|
+
// durable store — which is exactly the case where attribution matters.
|
|
3915
|
+
const agentBySession = new Map();
|
|
3916
|
+
// EventBus.emit awaits its subscribers, and a streaming provider awaits
|
|
3917
|
+
// the delta sink — so anything awaited here lands on the critical path
|
|
3918
|
+
// of every streamed token. This handler is deliberately synchronous:
|
|
3919
|
+
// provider.delta is dropped outright (it carries no detail worth
|
|
3920
|
+
// keeping, one record per token), the timestamp is taken now, and the
|
|
3921
|
+
// write is queued without awaiting it.
|
|
3922
|
+
gateway.bus.subscribe((event) => {
|
|
3923
|
+
if (event.type === 'provider.delta') {
|
|
3924
|
+
return;
|
|
3925
|
+
}
|
|
3926
|
+
const ts = new Date().toISOString();
|
|
3927
|
+
if (event.type === 'session.created') {
|
|
3928
|
+
agentBySession.set(event.sessionId, event.agentId);
|
|
3929
|
+
}
|
|
3930
|
+
const detail = eventDetail(event);
|
|
3931
|
+
const base = {
|
|
3932
|
+
ts,
|
|
3933
|
+
level: 'event',
|
|
3934
|
+
event: event.type,
|
|
3935
|
+
sessionId: event.sessionId,
|
|
3936
|
+
...(detail ? { detail } : {}),
|
|
3937
|
+
};
|
|
3938
|
+
const known = agentBySession.get(event.sessionId);
|
|
3939
|
+
if (known) {
|
|
3940
|
+
void logWriter.write({ ...base, agentId: known });
|
|
3941
|
+
}
|
|
3942
|
+
else {
|
|
3943
|
+
// A session resumed after a restart never re-creates, so its agent
|
|
3944
|
+
// is only in the store. That lookup is deferred off this path; the
|
|
3945
|
+
// timestamp above keeps ordering honest.
|
|
3946
|
+
void gateway.store.get(event.sessionId)
|
|
3947
|
+
.then((session) => {
|
|
3948
|
+
const agentId = session?.agent.id;
|
|
3949
|
+
if (agentId) {
|
|
3950
|
+
agentBySession.set(event.sessionId, agentId);
|
|
3951
|
+
}
|
|
3952
|
+
return logWriter.write({ ...base, ...(agentId ? { agentId } : {}) });
|
|
3953
|
+
})
|
|
3954
|
+
.catch(() => logWriter.write(base));
|
|
3955
|
+
}
|
|
3956
|
+
if (event.type === 'session.completed' || event.type === 'session.failed') {
|
|
3957
|
+
agentBySession.delete(event.sessionId);
|
|
3958
|
+
}
|
|
3959
|
+
});
|
|
3960
|
+
}
|
|
2634
3961
|
await gateway.start();
|
|
2635
3962
|
writeLine(streams.stdout, 'Press Ctrl+C to stop.');
|
|
2636
|
-
//
|
|
2637
|
-
//
|
|
2638
|
-
|
|
2639
|
-
|
|
2640
|
-
|
|
2641
|
-
|
|
2642
|
-
|
|
2643
|
-
|
|
3963
|
+
// And periodically, for a long-running daemon that warns steadily
|
|
3964
|
+
// without ever writing enough records to rotate. Unref'd, so it never
|
|
3965
|
+
// holds the process open. Armed only once the daemon is actually
|
|
3966
|
+
// serving: every step above can throw, and runServe is an exported
|
|
3967
|
+
// function as much as a process entry point — in a host that survives
|
|
3968
|
+
// the failure, a timer armed before the throw would outlive the call
|
|
3969
|
+
// and go on truncating that environment's redirect logs. The finally
|
|
3970
|
+
// below takes it down on every other exit path.
|
|
3971
|
+
let redirectTimer;
|
|
3972
|
+
if (logWriter) {
|
|
3973
|
+
redirectTimer = setInterval(() => void truncateRedirectLogs(logsDirPath(env)), 5 * 60_000);
|
|
3974
|
+
redirectTimer.unref?.();
|
|
3975
|
+
}
|
|
3976
|
+
try {
|
|
3977
|
+
// Hold the event loop open until a shutdown request, then drain: new
|
|
3978
|
+
// dispatches are refused while in-flight turns finish.
|
|
3979
|
+
await new Promise((resolve) => {
|
|
3980
|
+
const keepAlive = setInterval(() => { }, 2_147_000_000);
|
|
3981
|
+
let settled = false;
|
|
3982
|
+
const shutdown = () => {
|
|
3983
|
+
if (settled) {
|
|
3984
|
+
return;
|
|
3985
|
+
}
|
|
3986
|
+
settled = true;
|
|
3987
|
+
clearInterval(keepAlive);
|
|
3988
|
+
process.off('SIGTERM', shutdown);
|
|
3989
|
+
process.off('SIGINT', shutdown);
|
|
3990
|
+
resolve();
|
|
3991
|
+
};
|
|
3992
|
+
process.once('SIGTERM', shutdown);
|
|
3993
|
+
process.once('SIGINT', shutdown);
|
|
3994
|
+
if (env.shutdownSignal?.aborted) {
|
|
3995
|
+
shutdown();
|
|
2644
3996
|
}
|
|
2645
|
-
|
|
2646
|
-
|
|
2647
|
-
|
|
2648
|
-
|
|
2649
|
-
|
|
2650
|
-
|
|
2651
|
-
|
|
2652
|
-
|
|
2653
|
-
|
|
2654
|
-
|
|
2655
|
-
|
|
2656
|
-
|
|
2657
|
-
|
|
2658
|
-
|
|
2659
|
-
|
|
2660
|
-
|
|
2661
|
-
|
|
2662
|
-
|
|
3997
|
+
else {
|
|
3998
|
+
env.shutdownSignal?.addEventListener('abort', shutdown, { once: true });
|
|
3999
|
+
}
|
|
4000
|
+
});
|
|
4001
|
+
writeLine(streams.stdout, 'Stopping — draining in-flight turns.');
|
|
4002
|
+
await gateway.stop();
|
|
4003
|
+
return 0;
|
|
4004
|
+
}
|
|
4005
|
+
finally {
|
|
4006
|
+
if (redirectTimer) {
|
|
4007
|
+
clearInterval(redirectTimer);
|
|
4008
|
+
}
|
|
4009
|
+
// Writes are queued and dropped on the hot path so logging never sits
|
|
4010
|
+
// on a streamed token. That makes the tail of the log the part most
|
|
4011
|
+
// likely to be lost — and the tail is the shutdown reason, the last
|
|
4012
|
+
// warning, the line explaining a restart.
|
|
4013
|
+
await logWriter?.flush();
|
|
4014
|
+
}
|
|
2663
4015
|
};
|
|
2664
4016
|
export const runCli = async ({ argv, streams = process, env = {} }) => {
|
|
2665
4017
|
try {
|
|
@@ -2674,25 +4026,40 @@ export const runCli = async ({ argv, streams = process, env = {} }) => {
|
|
|
2674
4026
|
writeLine(streams.stdout, HELP_TEXT);
|
|
2675
4027
|
return 0;
|
|
2676
4028
|
}
|
|
4029
|
+
// Every handler is awaited, never bare-returned: a bare `return
|
|
4030
|
+
// promise` inside try/catch settles the async function before the
|
|
4031
|
+
// catch can see it, so a command that fails at runtime — a gateway
|
|
4032
|
+
// that cannot open its store, a missing credential — would escape as
|
|
4033
|
+
// a raw rejection instead of the error line and exit code below.
|
|
2677
4034
|
if (command.command === 'agent-new') {
|
|
2678
|
-
return runAgentNew(command, streams, resolvedEnv);
|
|
4035
|
+
return await runAgentNew(command, streams, resolvedEnv);
|
|
2679
4036
|
}
|
|
2680
4037
|
if (command.command === 'agents') {
|
|
2681
|
-
return runAgents(command, streams, resolvedEnv);
|
|
4038
|
+
return await runAgents(command, streams, resolvedEnv);
|
|
4039
|
+
}
|
|
4040
|
+
if (command.command === 'doctor') {
|
|
4041
|
+
return await runDoctor(command, streams, resolvedEnv);
|
|
4042
|
+
}
|
|
4043
|
+
if (command.command === 'logs') {
|
|
4044
|
+
return await runLogs(command, streams, resolvedEnv);
|
|
4045
|
+
}
|
|
4046
|
+
if (command.command === 'service') {
|
|
4047
|
+
return await runService(command, streams, resolvedEnv);
|
|
2682
4048
|
}
|
|
2683
4049
|
if (command.command === 'chat') {
|
|
2684
|
-
return runChat(command, streams, resolvedEnv);
|
|
4050
|
+
return await runChat(command, streams, resolvedEnv);
|
|
2685
4051
|
}
|
|
2686
4052
|
if (command.command === 'setup') {
|
|
2687
|
-
return runSetup(command, streams, resolvedEnv);
|
|
4053
|
+
return await runSetup(command, streams, resolvedEnv);
|
|
2688
4054
|
}
|
|
2689
4055
|
if (command.command === 'dashboard') {
|
|
2690
|
-
return runDashboard(command, streams, resolvedEnv);
|
|
4056
|
+
return await runDashboard(command, streams, resolvedEnv);
|
|
2691
4057
|
}
|
|
2692
4058
|
if (command.command === 'serve') {
|
|
2693
|
-
return runServe(command, streams, resolvedEnv);
|
|
4059
|
+
return await runServe(command, streams, resolvedEnv);
|
|
2694
4060
|
}
|
|
2695
4061
|
const runtime = await resolveRuntimeConfig(command, resolvedEnv);
|
|
4062
|
+
await warnOnCredentialOverride(runtime, streams, resolvedEnv);
|
|
2696
4063
|
if (command.format === 'text') {
|
|
2697
4064
|
writeLine(streams.stdout, formatRuntimeBanner(runtime));
|
|
2698
4065
|
}
|