@stratusagent/cli 0.3.0 → 0.4.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 +142 -6
- package/dist/index.d.ts +16 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +355 -81
- package/dist/index.js.map +1 -1
- package/package.json +12 -12
package/dist/index.js
CHANGED
|
@@ -9,8 +9,8 @@ import { AgentRunner, AllowAllApprovalPolicy, EventBus, ToolRegistry, } from '@s
|
|
|
9
9
|
import { createLocalCommandExecutor, } from '@stratusagent/executor-local';
|
|
10
10
|
import { createPermissionPolicy } from '@stratusagent/permissions';
|
|
11
11
|
import { DEFAULT_ANTHROPIC_MODEL, redactAnthropicRawTurns, } from '@stratusagent/provider-anthropic';
|
|
12
|
-
import { createRememberTool, defineAgent, formatSoul, generateAgentName, parseSoul, } from '@stratusagent/agents';
|
|
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';
|
|
12
|
+
import { agentIdWithSuffix, createRememberTool, defineAgent, formatSoul, generateAgentName, parseSoul, } from '@stratusagent/agents';
|
|
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, resolveConfiguredSoul, logsDirPath, readWorkingDirectory, resolveAgentApprovals, DEFAULT_CONFIG_FILENAME, LEGACY_CONFIG_FILENAME, loadConfigFile, resolveConfigLocation, resolveRuntimeConfig as resolveStateRuntimeConfig, saveChannelCredentials, saveCredentials, withLegacyDefaultMemories, } from '@stratusagent/state';
|
|
14
14
|
import { installService, readServiceStatus, servicePlatform, serviceUnitPath, startService, stopService, uninstallService, } from "./service.js";
|
|
15
15
|
export { installService, launchdPlist, readServiceStatus, servicePlatform, serviceUnitPath, startService, stopService, systemdUnit, uninstallService, SERVICE_LABEL, } from "./service.js";
|
|
16
16
|
import { createLogWriter, currentLogPosition, truncateRedirectLogs, formatLogRecord, readRecentRecords, tailLog, } from "./logs.js";
|
|
@@ -23,7 +23,7 @@ export { createFileMemoryStore } from '@stratusagent/state';
|
|
|
23
23
|
// second copy of a number nobody re-reads: it is drawn in the setup header
|
|
24
24
|
// and served from the dashboard's /api/status, so drift here misreports the
|
|
25
25
|
// running build rather than failing anything.
|
|
26
|
-
export const CLI_VERSION = '0.
|
|
26
|
+
export const CLI_VERSION = '0.4.0';
|
|
27
27
|
const DEFAULT_DASHBOARD_HOST = '127.0.0.1';
|
|
28
28
|
const DASHBOARD_TITLE = 'Stratus Agent Dashboard';
|
|
29
29
|
/**
|
|
@@ -97,9 +97,10 @@ Commands:
|
|
|
97
97
|
serve Run stratusd, the always-on gateway: durable sessions, the
|
|
98
98
|
whole roster live at once (each agent on its own provider),
|
|
99
99
|
delegation, and a watchdog — Ctrl+C / SIGTERM drains cleanly
|
|
100
|
-
(--idle-timeout <seconds>, --
|
|
101
|
-
--config <path>); everything it
|
|
102
|
-
~/.stratus/logs, which
|
|
100
|
+
(--idle-timeout <seconds>, --approvals <headless|remote>,
|
|
101
|
+
--no-events, --no-log-file, --config <path>); everything it
|
|
102
|
+
says is also written to ~/.stratus/logs, which
|
|
103
|
+
"stratus logs" reads
|
|
103
104
|
service Keep stratusd running under launchd (macOS) or systemd
|
|
104
105
|
(Linux): install, uninstall, status, start, stop.
|
|
105
106
|
Installing starts it now and at every login
|
|
@@ -132,7 +133,10 @@ Options:
|
|
|
132
133
|
--config Config file path (run: load settings from it, setup: write it)
|
|
133
134
|
--format Output format: text or json (default: text)
|
|
134
135
|
--no-events Hide event-by-event progress lines in text mode
|
|
135
|
-
--approvals
|
|
136
|
+
--approvals run/chat: tool approval mode — always, ask, or never (default: always)
|
|
137
|
+
serve: how the daemon reaches a human — headless (refuse every
|
|
138
|
+
gated call) or remote (ask in Slack). Default headless, or
|
|
139
|
+
the config file's "approvals.mode"
|
|
136
140
|
--max-turns Maximum provider turns per run (default: 8)
|
|
137
141
|
--port Dashboard port, defaults to an open local port
|
|
138
142
|
--host Dashboard host (default: 127.0.0.1)
|
|
@@ -237,6 +241,15 @@ export const parseCommand = (argv, env = {}) => {
|
|
|
237
241
|
index += 1;
|
|
238
242
|
continue;
|
|
239
243
|
}
|
|
244
|
+
if (token === '--approvals') {
|
|
245
|
+
const value = readOptionValue(rest, index, '--approvals');
|
|
246
|
+
if (value !== 'headless' && value !== 'remote') {
|
|
247
|
+
throw new Error(`Unsupported approvals mode: ${value}. Use headless or remote.`);
|
|
248
|
+
}
|
|
249
|
+
parsed.approvals = value;
|
|
250
|
+
index += 1;
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
240
253
|
if (token === '--no-events') {
|
|
241
254
|
parsed.events = false;
|
|
242
255
|
continue;
|
|
@@ -656,6 +669,10 @@ export const formatEvent = (event) => {
|
|
|
656
669
|
return `• tool.completed ${event.result.toolName} ok=${String(event.result.ok)}`;
|
|
657
670
|
case 'tool.denied':
|
|
658
671
|
return `• tool.denied ${event.call.toolName}`;
|
|
672
|
+
case 'tool.approval-requested':
|
|
673
|
+
return `• tool.approval-requested ${event.call.toolName} (${event.risk}) for ${event.agentId}`;
|
|
674
|
+
case 'tool.approval-resolved':
|
|
675
|
+
return `• tool.approval-resolved ${event.answer} (${event.reason})${event.actor ? ` by ${event.actor}` : ''}`;
|
|
659
676
|
case 'session.completed':
|
|
660
677
|
return `• session.completed ${event.sessionId}`;
|
|
661
678
|
case 'session.failed':
|
|
@@ -668,6 +685,14 @@ export const formatEvent = (event) => {
|
|
|
668
685
|
* The fields worth keeping per event type. Tool inputs and message text
|
|
669
686
|
* are deliberately excluded: the log is a trace of what happened, not a
|
|
670
687
|
* second copy of the transcript.
|
|
688
|
+
*
|
|
689
|
+
* The approval pair is the one place the trace carries a person's id, and
|
|
690
|
+
* it earns it: `always` widens what an agent may do unattended for the rest
|
|
691
|
+
* of the session, and "who decided that" is unanswerable afterwards from
|
|
692
|
+
* anything else. The refusal path is warned about by `onDecision`, but an
|
|
693
|
+
* *approval* produces no warning at all — without these two records a
|
|
694
|
+
* granted permission leaves no trace whatsoever. Still no tool input: what
|
|
695
|
+
* was asked is here, what it was asked with is not.
|
|
671
696
|
*/
|
|
672
697
|
const eventDetail = (event) => {
|
|
673
698
|
switch (event.type) {
|
|
@@ -680,14 +705,30 @@ const eventDetail = (event) => {
|
|
|
680
705
|
return { tool: event.call.toolName };
|
|
681
706
|
case 'tool.completed':
|
|
682
707
|
return { tool: event.result.toolName, ok: event.result.ok };
|
|
708
|
+
case 'tool.approval-requested':
|
|
709
|
+
return { tool: event.call.toolName, risk: event.risk, requestId: event.requestId };
|
|
710
|
+
case 'tool.approval-resolved':
|
|
711
|
+
return {
|
|
712
|
+
requestId: event.requestId,
|
|
713
|
+
answer: event.answer,
|
|
714
|
+
reason: event.reason,
|
|
715
|
+
...(event.actor ? { actor: event.actor } : {}),
|
|
716
|
+
};
|
|
683
717
|
case 'session.failed':
|
|
684
718
|
return { error: event.error };
|
|
685
719
|
default:
|
|
686
720
|
return undefined;
|
|
687
721
|
}
|
|
688
722
|
};
|
|
689
|
-
|
|
690
|
-
|
|
723
|
+
/**
|
|
724
|
+
* Kept with its historical CLI signature: a parsed run command is a
|
|
725
|
+
* RuntimeSelection plus CLI-only fields the resolver ignores.
|
|
726
|
+
*
|
|
727
|
+
* Those fields are *permitted*, not required. Demanding a whole
|
|
728
|
+
* `ParsedRunCommand` made the type claim the resolver cared about
|
|
729
|
+
* `approvals`, `format`, and `events` — it reads none of them — so every
|
|
730
|
+
* caller with a selection in hand had to invent values to get past it.
|
|
731
|
+
*/
|
|
691
732
|
export const resolveRuntimeConfig = (command, env = {}) => resolveStateRuntimeConfig(command, env);
|
|
692
733
|
/**
|
|
693
734
|
* A saved subscription sign-in silently demoted to per-token billing is the
|
|
@@ -1691,7 +1732,12 @@ export const slackAppManifest = (agentName) => JSON.stringify({
|
|
|
1691
1732
|
oauth_config: { scopes: { bot: SLACK_BOT_SCOPES } },
|
|
1692
1733
|
settings: {
|
|
1693
1734
|
event_subscriptions: { bot_events: SLACK_BOT_EVENTS },
|
|
1694
|
-
|
|
1735
|
+
// Required for remote approval: Allow / Always allow / Deny arrive as
|
|
1736
|
+
// block_actions, and Slack delivers none of them to an app that has
|
|
1737
|
+
// interactivity switched off. Enabled unconditionally rather than per
|
|
1738
|
+
// mode — an app is created once, and discovering months later that the
|
|
1739
|
+
// buttons do nothing means editing the manifest and reinstalling.
|
|
1740
|
+
interactivity: { is_enabled: true },
|
|
1695
1741
|
org_deploy_enabled: false,
|
|
1696
1742
|
socket_mode_enabled: true,
|
|
1697
1743
|
token_rotation_enabled: false,
|
|
@@ -1714,6 +1760,104 @@ const KNOWN_CLAUDE_MODELS = [
|
|
|
1714
1760
|
'claude-opus-4-6',
|
|
1715
1761
|
'claude-sonnet-4-6',
|
|
1716
1762
|
];
|
|
1763
|
+
/**
|
|
1764
|
+
* The ids a newly created soul must not claim: every id the served roster
|
|
1765
|
+
* holds, which is three things and not one directory.
|
|
1766
|
+
*
|
|
1767
|
+
* - **What the roster files declare.** A filename is not an id: a soul at
|
|
1768
|
+
* `renamed.md` may declare `id: ava`, so `ava.md` being free proves
|
|
1769
|
+
* nothing — and since a duplicate refuses the whole roster, writing one
|
|
1770
|
+
* would hand back an agent whose daemon cannot start.
|
|
1771
|
+
* - **The configured default soul**, which the daemon registers whether or
|
|
1772
|
+
* not its file lives in the agents directory. It wins a same-id contest
|
|
1773
|
+
* with a roster file — `defaultAgentId` replaces the source when the
|
|
1774
|
+
* path differs — so a new agent sharing its id is not refused, it is
|
|
1775
|
+
* shadowed: created, then undispatchable by id or from Slack, with
|
|
1776
|
+
* nothing saying so.
|
|
1777
|
+
* - **The reserved `stratus`**, since a roster soul claiming it is skipped
|
|
1778
|
+
* at load, so writing one creates an agent that silently never appears.
|
|
1779
|
+
*
|
|
1780
|
+
* The two readable sources fail **independently**, and what they return is
|
|
1781
|
+
* what they know rather than all-or-nothing. A configured soul that is
|
|
1782
|
+
* missing or mid-edit does not stop the daemon serving the roster, so it
|
|
1783
|
+
* must not discard the roster's claims either: collapsing both to unknown
|
|
1784
|
+
* would let this command write the very duplicate that refuses the roster
|
|
1785
|
+
* it could have read. `unread` names what could not be answered, so the
|
|
1786
|
+
* caller can say which check did not run instead of implying none did.
|
|
1787
|
+
*/
|
|
1788
|
+
const declaredAgentIds = async (env) => {
|
|
1789
|
+
const [roster, configured] = await Promise.allSettled([
|
|
1790
|
+
loadRosterSouls(env, () => { }),
|
|
1791
|
+
// Empty selection: the soul a run started here would resolve, by the
|
|
1792
|
+
// same precedence the daemon uses — never a second reading of it.
|
|
1793
|
+
resolveConfiguredSoul({}, env),
|
|
1794
|
+
]);
|
|
1795
|
+
const ids = new Set([DEFAULT_STRATUS_AGENT.id]);
|
|
1796
|
+
const unread = [];
|
|
1797
|
+
if (roster.status === 'fulfilled') {
|
|
1798
|
+
for (const entry of roster.value) {
|
|
1799
|
+
ids.add(entry.soul.agent.id);
|
|
1800
|
+
}
|
|
1801
|
+
}
|
|
1802
|
+
else {
|
|
1803
|
+
unread.push('the roster');
|
|
1804
|
+
}
|
|
1805
|
+
if (configured.status === 'fulfilled') {
|
|
1806
|
+
if (configured.value) {
|
|
1807
|
+
ids.add(configured.value.soul.agent.id);
|
|
1808
|
+
}
|
|
1809
|
+
}
|
|
1810
|
+
else {
|
|
1811
|
+
unread.push('the configured default soul');
|
|
1812
|
+
}
|
|
1813
|
+
return { ids, unread };
|
|
1814
|
+
};
|
|
1815
|
+
/**
|
|
1816
|
+
* Write a new soul under an id nothing else holds, and return the agent
|
|
1817
|
+
* that got written.
|
|
1818
|
+
*
|
|
1819
|
+
* The name stays theirs, but the id — the soul filename, the memory key,
|
|
1820
|
+
* the credential scope — must be unique: the suggestion pool is small, so
|
|
1821
|
+
* a repeat name would otherwise share an earlier agent's memory. Two
|
|
1822
|
+
* claims have to fail here, and only one of them is a filename:
|
|
1823
|
+
*
|
|
1824
|
+
* - An id another soul declares, whatever that soul is called on disk.
|
|
1825
|
+
* Since a duplicate refuses the whole roster, writing one would leave a
|
|
1826
|
+
* daemon that will not start — created by the command meant to help.
|
|
1827
|
+
* - The path itself, via `wx`, which makes the claim atomic against a
|
|
1828
|
+
* concurrent writer that the roster read above cannot see.
|
|
1829
|
+
*
|
|
1830
|
+
* On either, the id takes a fresh suffix and we try again — through the
|
|
1831
|
+
* shared bound, since appending a suffix to a maxed-out base would build
|
|
1832
|
+
* an id the validator refuses, turning a collision into a crash.
|
|
1833
|
+
*/
|
|
1834
|
+
const claimSoulFile = async (env, input, render, note) => {
|
|
1835
|
+
const { ids: taken, unread } = await declaredAgentIds(env);
|
|
1836
|
+
if (unread.length > 0) {
|
|
1837
|
+
note(`Note: could not read ${unread.join(' or ')}, so this id was not checked against the ids it declares.`);
|
|
1838
|
+
}
|
|
1839
|
+
let agent = defineAgent({ ...(input.name ? { name: input.name } : {}), instructions: input.instructions });
|
|
1840
|
+
const baseId = agent.id;
|
|
1841
|
+
for (;;) {
|
|
1842
|
+
const soulPath = path.join(agentsDirPath(env), `${agent.id}.md`);
|
|
1843
|
+
if (!taken.has(agent.id)) {
|
|
1844
|
+
try {
|
|
1845
|
+
await writeFile(soulPath, render(agent), { flag: 'wx' });
|
|
1846
|
+
return { agent, soulPath };
|
|
1847
|
+
}
|
|
1848
|
+
catch (error) {
|
|
1849
|
+
if (error.code !== 'EEXIST') {
|
|
1850
|
+
throw error;
|
|
1851
|
+
}
|
|
1852
|
+
}
|
|
1853
|
+
}
|
|
1854
|
+
agent = defineAgent({
|
|
1855
|
+
id: agentIdWithSuffix(baseId, randomUUID().slice(0, 4)),
|
|
1856
|
+
...(input.name ? { name: input.name } : { name: agent.name }),
|
|
1857
|
+
instructions: input.instructions,
|
|
1858
|
+
});
|
|
1859
|
+
}
|
|
1860
|
+
};
|
|
1717
1861
|
export const runSetup = async (command, streams, env = {}) => {
|
|
1718
1862
|
const cwd = readWorkingDirectory(env);
|
|
1719
1863
|
const processEnv = readProcessEnv(env);
|
|
@@ -2298,17 +2442,13 @@ export const runSetup = async (command, streams, env = {}) => {
|
|
|
2298
2442
|
}
|
|
2299
2443
|
const name = await prompter.ask('Name your agent (Enter to have one generated): ');
|
|
2300
2444
|
const instructions = await prompter.ask('Describe their personality in a sentence or two (Enter for a starter you can edit later): ');
|
|
2301
|
-
const
|
|
2302
|
-
|
|
2303
|
-
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
});
|
|
2309
|
-
const soulPath = path.join(agentsDirPath(env), `${agent.id}.md`);
|
|
2310
|
-
await mkdir(path.dirname(soulPath), { recursive: true });
|
|
2311
|
-
await writeFile(soulPath, soul);
|
|
2445
|
+
const persona = instructions || DEFAULT_SOUL_STARTER;
|
|
2446
|
+
const pin = state.provider !== 'demo'
|
|
2447
|
+
? { provider: state.provider, model: state.model ?? defaultModelFor(state.provider) }
|
|
2448
|
+
: {};
|
|
2449
|
+
await mkdir(agentsDirPath(env), { recursive: true });
|
|
2450
|
+
const claimed = await claimSoulFile(env, { ...(name ? { name } : {}), instructions: persona }, (agent) => formatSoul({ agent, ...pin }), (message) => writeLine(streams.stdout, message));
|
|
2451
|
+
const { agent, soulPath } = claimed;
|
|
2312
2452
|
state.soulPath = soulPath;
|
|
2313
2453
|
writeLine(streams.stdout, `Say hello to ${agent.name}.`);
|
|
2314
2454
|
writeLine(streams.stdout, `Their soul lives at ${soulPath} — edit it any time to change how they talk.`);
|
|
@@ -2500,19 +2640,31 @@ export const runSetup = async (command, streams, env = {}) => {
|
|
|
2500
2640
|
// built-in's place — the gateway skips it, so offering it here would
|
|
2501
2641
|
// name an app after an agent that never receives the messages.
|
|
2502
2642
|
const entries = [{ soul: { agent: { ...DEFAULT_STRATUS_AGENT } } }];
|
|
2503
|
-
//
|
|
2504
|
-
//
|
|
2505
|
-
//
|
|
2506
|
-
|
|
2507
|
-
|
|
2508
|
-
|
|
2509
|
-
|
|
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);
|
|
2643
|
+
// loadRosterSouls refuses a roster whose files collide, and drops the
|
|
2644
|
+
// ones claiming the reserved built-in id, so what comes back here is
|
|
2645
|
+
// already unambiguous.
|
|
2646
|
+
let rosterSouls = [];
|
|
2647
|
+
let rosterLoaded = true;
|
|
2648
|
+
try {
|
|
2649
|
+
rosterSouls = await loadRosterSouls(env, warnOnce);
|
|
2515
2650
|
}
|
|
2651
|
+
catch (error) {
|
|
2652
|
+
// A roster that cannot say who its agents are cannot have channels
|
|
2653
|
+
// configured for them — but the rest of setup (providers, models,
|
|
2654
|
+
// sign-ins) still works, so this reports and moves on rather than
|
|
2655
|
+
// taking the whole command down.
|
|
2656
|
+
rosterLoaded = false;
|
|
2657
|
+
warnOnce(error instanceof Error ? error.message : String(error));
|
|
2658
|
+
// Nothing is offered, not merely the colliding pair. A roster that
|
|
2659
|
+
// refuses to load fails `createGateway.start()` outright, so no
|
|
2660
|
+
// agent is servable — not the built-in seeded above, and not the
|
|
2661
|
+
// configured soul resolved below (which can itself BE one of the
|
|
2662
|
+
// colliding files). Listing any of them invites connecting a Slack
|
|
2663
|
+
// app to an agent the daemon cannot bring online, and connecting an
|
|
2664
|
+
// app is the expensive half of that mistake.
|
|
2665
|
+
return { entries: [], loaded: false };
|
|
2666
|
+
}
|
|
2667
|
+
entries.push(...rosterSouls);
|
|
2516
2668
|
// The soul a run resolves to, in resolveSoulPath's own order: an env
|
|
2517
2669
|
// override outranks the config value this setup session is editing.
|
|
2518
2670
|
// Listing the config soul while `stratus serve` registers the env one
|
|
@@ -2525,11 +2677,11 @@ export const runSetup = async (command, streams, env = {}) => {
|
|
|
2525
2677
|
}
|
|
2526
2678
|
const effectiveSoul = typeof envSoul === 'string' ? envSoul : state.soulPath;
|
|
2527
2679
|
if (!effectiveSoul) {
|
|
2528
|
-
return entries;
|
|
2680
|
+
return { entries, loaded: rosterLoaded };
|
|
2529
2681
|
}
|
|
2530
2682
|
const resolved = path.resolve(readWorkingDirectory(env), effectiveSoul);
|
|
2531
2683
|
if (entries.some((entry) => entry.path === resolved)) {
|
|
2532
|
-
return entries;
|
|
2684
|
+
return { entries, loaded: rosterLoaded };
|
|
2533
2685
|
}
|
|
2534
2686
|
try {
|
|
2535
2687
|
const soul = await loadSoulFile(resolved);
|
|
@@ -2548,7 +2700,7 @@ export const runSetup = async (command, streams, env = {}) => {
|
|
|
2548
2700
|
catch (error) {
|
|
2549
2701
|
warnOnce(`could not read the default soul ${resolved} (${error instanceof Error ? error.message : String(error)})`);
|
|
2550
2702
|
}
|
|
2551
|
-
return entries;
|
|
2703
|
+
return { entries, loaded: rosterLoaded };
|
|
2552
2704
|
};
|
|
2553
2705
|
const serviceSummary = () => {
|
|
2554
2706
|
if (!servicePlatform(serviceEnvFor(env))) {
|
|
@@ -2594,7 +2746,7 @@ export const runSetup = async (command, streams, env = {}) => {
|
|
|
2594
2746
|
};
|
|
2595
2747
|
const chooseChannels = async () => {
|
|
2596
2748
|
while (true) {
|
|
2597
|
-
const roster = await channelRoster();
|
|
2749
|
+
const { entries: roster, loaded: rosterLoaded } = await channelRoster();
|
|
2598
2750
|
const slack = state.channels.slack ?? {};
|
|
2599
2751
|
// The roster always holds at least the built-in agent, so there is
|
|
2600
2752
|
// no empty state to short-circuit on — and short-circuiting would
|
|
@@ -2606,7 +2758,19 @@ export const runSetup = async (command, streams, env = {}) => {
|
|
|
2606
2758
|
});
|
|
2607
2759
|
// Orphans: tokens whose agent left the roster would otherwise be
|
|
2608
2760
|
// invisible here while still being loaded by `stratus serve`.
|
|
2609
|
-
|
|
2761
|
+
//
|
|
2762
|
+
// Only when the roster actually loaded. "No agent has this id" is a
|
|
2763
|
+
// claim about the roster, and a roster that refused to load cannot
|
|
2764
|
+
// support it — every stored token would look orphaned, and this list
|
|
2765
|
+
// offers to DELETE them. Losing a working agent's Slack credentials
|
|
2766
|
+
// because a different pair of files collided is a far worse outcome
|
|
2767
|
+
// than leaving a real orphan on screen for one run.
|
|
2768
|
+
const orphans = rosterLoaded
|
|
2769
|
+
? Object.keys(slack).filter((id) => !roster.some((entry) => entry.soul.agent.id === id))
|
|
2770
|
+
: [];
|
|
2771
|
+
if (!rosterLoaded) {
|
|
2772
|
+
writeLine(streams.stderr, 'Warning: no agents are offered while the roster is unreadable — the daemon cannot start either. Fix the roster first.');
|
|
2773
|
+
}
|
|
2610
2774
|
for (const id of orphans) {
|
|
2611
2775
|
options.push(`${id}`.padEnd(34) + '! tokens without a matching agent');
|
|
2612
2776
|
}
|
|
@@ -3306,12 +3470,28 @@ export const collectDoctorReport = async (command, warn, env = {}) => {
|
|
|
3306
3470
|
}
|
|
3307
3471
|
// Tokens keyed to an agent the roster no longer has are loaded and
|
|
3308
3472
|
// skipped on every start, silently.
|
|
3309
|
-
|
|
3473
|
+
let rosterEntries = [];
|
|
3474
|
+
let rosterLoaded = true;
|
|
3475
|
+
try {
|
|
3476
|
+
rosterEntries = await loadRosterSouls(env, warn);
|
|
3477
|
+
}
|
|
3478
|
+
catch (error) {
|
|
3479
|
+
// Doctor exists to name problems, so a roster it cannot load is a
|
|
3480
|
+
// finding to report — not an exception that replaces the report.
|
|
3481
|
+
rosterLoaded = false;
|
|
3482
|
+
problems.push(error instanceof Error ? error.message : String(error));
|
|
3483
|
+
}
|
|
3484
|
+
const rosterIds = new Set(rosterEntries.map((entry) => entry.soul.agent.id));
|
|
3310
3485
|
rosterIds.add(DEFAULT_STRATUS_AGENT.id);
|
|
3311
3486
|
if (soul) {
|
|
3312
3487
|
rosterIds.add(soul.agent.id);
|
|
3313
3488
|
}
|
|
3314
|
-
|
|
3489
|
+
// Only against a roster that actually loaded. "No agent has this id" is
|
|
3490
|
+
// a claim about the roster, and a refused one cannot support it — every
|
|
3491
|
+
// stored token would be reported as orphaned, and this advises clearing
|
|
3492
|
+
// them. Advice is not deletion, but an operator who follows it loses the
|
|
3493
|
+
// same credentials by hand.
|
|
3494
|
+
const orphans = rosterLoaded ? slackAgents.filter((id) => !rosterIds.has(id)) : [];
|
|
3315
3495
|
if (orphans.length > 0) {
|
|
3316
3496
|
problems.push(`Slack tokens are stored for ${orphans.join(', ')}, which no agent matches — \`stratus serve\` skips them. `
|
|
3317
3497
|
+ 'Clear them from `stratus setup` → Channels.');
|
|
@@ -3476,7 +3656,11 @@ const servedRuntimes = async (env, configPath) => {
|
|
|
3476
3656
|
configPresent: location !== undefined,
|
|
3477
3657
|
};
|
|
3478
3658
|
const passes = [{ selection: {}, env }];
|
|
3479
|
-
|
|
3659
|
+
// A roster that will not load is the gateway's to refuse, with a better
|
|
3660
|
+
// message than this preflight could give — so it checks what it can and
|
|
3661
|
+
// leaves the failing to start().
|
|
3662
|
+
const rosterForRuntimes = await loadRosterSouls(env, () => { }).catch(() => []);
|
|
3663
|
+
for (const entry of rosterForRuntimes) {
|
|
3480
3664
|
const normalized = applySoulPins(entry.soul, {}, env, context);
|
|
3481
3665
|
passes.push({ selection: normalized.selection, env: normalized.env });
|
|
3482
3666
|
}
|
|
@@ -3491,6 +3675,77 @@ const servedRuntimes = async (env, configPath) => {
|
|
|
3491
3675
|
}
|
|
3492
3676
|
return resolved;
|
|
3493
3677
|
};
|
|
3678
|
+
/**
|
|
3679
|
+
* The daemon's `approvals` block, from the config file the daemon itself
|
|
3680
|
+
* would load. Discovery goes through the shared resolver rather than
|
|
3681
|
+
* reading ~/.stratus/config.json directly: `--config` and STRATUS_CONFIG
|
|
3682
|
+
* both move it, and a second copy of that precedence would resolve the
|
|
3683
|
+
* approver set from a file the gateway is not running on.
|
|
3684
|
+
*
|
|
3685
|
+
* **Only from a trusted location.** An auto-discovered project-local
|
|
3686
|
+
* `stratus.config.json` outranks the global one and can be checked into any
|
|
3687
|
+
* repository — which is why stored credentials are already never combined
|
|
3688
|
+
* with an endpoint it selects. This block is the same kind of boundary and
|
|
3689
|
+
* a sharper one: it names the people who may authorize an agent's gated
|
|
3690
|
+
* tool calls, and it would do so through Slack tokens the user configured
|
|
3691
|
+
* globally. A cloned repo must not be able to appoint its own approver, so
|
|
3692
|
+
* an untrusted config's approvals are ignored — loudly, since silently
|
|
3693
|
+
* dropping the block someone is looking at is its own kind of wrong.
|
|
3694
|
+
*
|
|
3695
|
+
* An unreadable config degrades to headless with a warning, matching how
|
|
3696
|
+
* every other consumer treats one: refusing to start would take the whole
|
|
3697
|
+
* fleet down over a policy block that may not even be present.
|
|
3698
|
+
*/
|
|
3699
|
+
const loadServeApprovals = async (env, configPath, warn) => {
|
|
3700
|
+
try {
|
|
3701
|
+
const location = await resolveConfigLocation(configPath ? { configPath } : {}, env);
|
|
3702
|
+
if (!location) {
|
|
3703
|
+
return {};
|
|
3704
|
+
}
|
|
3705
|
+
const approvals = (await loadConfigFile(location.path)).approvals;
|
|
3706
|
+
if (!approvals) {
|
|
3707
|
+
return {};
|
|
3708
|
+
}
|
|
3709
|
+
if (!location.trusted) {
|
|
3710
|
+
warn(`ignoring the approvals config in ${location.path}: a project-local config cannot decide who may approve `
|
|
3711
|
+
+ 'this daemon\'s tool calls. Move it to ~/.stratus/config.json, or pass it with --config.');
|
|
3712
|
+
return {};
|
|
3713
|
+
}
|
|
3714
|
+
return approvals;
|
|
3715
|
+
}
|
|
3716
|
+
catch (error) {
|
|
3717
|
+
warn(`ignoring the approvals config (${error instanceof Error ? error.message : String(error)}); refusing gated calls`);
|
|
3718
|
+
return {};
|
|
3719
|
+
}
|
|
3720
|
+
};
|
|
3721
|
+
/**
|
|
3722
|
+
* One line naming who can actually answer, so a remote daemon does not look
|
|
3723
|
+
* configured when it is not. Two ways it can be hollow, and they fail
|
|
3724
|
+
* differently, so they read differently:
|
|
3725
|
+
*
|
|
3726
|
+
* - No channel running at all (no Slack tokens, or the optional package is
|
|
3727
|
+
* not installed) — nothing renders the request, so the turn waits out the
|
|
3728
|
+
* whole timeout before being denied. That is the bad one, and the only
|
|
3729
|
+
* place it is visible is here, at startup.
|
|
3730
|
+
* - A channel is running but an agent has no approvers — the adapter denies
|
|
3731
|
+
* that agent's requests on arrival, which is at least prompt.
|
|
3732
|
+
*
|
|
3733
|
+
* `agentIds` is therefore the agents that can actually be *asked*, not
|
|
3734
|
+
* every agent with tokens on disk.
|
|
3735
|
+
*/
|
|
3736
|
+
const describeApprovers = (approvals, agentIds) => {
|
|
3737
|
+
if (agentIds.length === 0) {
|
|
3738
|
+
return 'but no channel is running to ask through, so gated calls will wait out the approval timeout and then be denied';
|
|
3739
|
+
}
|
|
3740
|
+
const covered = agentIds.filter((agentId) => (resolveAgentApprovals(approvals, agentId).slackApprovers ?? []).length > 0);
|
|
3741
|
+
if (covered.length === 0) {
|
|
3742
|
+
return 'but no approvers are configured, so every gated call is denied on arrival';
|
|
3743
|
+
}
|
|
3744
|
+
const uncovered = agentIds.filter((agentId) => !covered.includes(agentId));
|
|
3745
|
+
return uncovered.length === 0
|
|
3746
|
+
? `approvers set for ${covered.join(', ')}`
|
|
3747
|
+
: `approvers set for ${covered.join(', ')}; none for ${uncovered.join(', ')}, whose calls are denied on arrival`;
|
|
3748
|
+
};
|
|
3494
3749
|
/**
|
|
3495
3750
|
* `stratus service` — run the daemon under launchd or systemd, so it
|
|
3496
3751
|
* survives logout, crashes, and reboots. `serve` itself stays a plain
|
|
@@ -3604,7 +3859,6 @@ export const runAgentNew = async (command, streams, env = {}) => {
|
|
|
3604
3859
|
const name = (await prompter.ask('Choose your name: ', { prefill: suggested })) || suggested;
|
|
3605
3860
|
const instructions = await prompter.ask('Describe their personality (Enter for a starter you can edit later): ', ...(command.instructions ? [{ prefill: command.instructions }] : []));
|
|
3606
3861
|
const persona = instructions || DEFAULT_SOUL_STARTER;
|
|
3607
|
-
let agent = defineAgent({ name, instructions: persona });
|
|
3608
3862
|
// Frontmatter pins what a run from this directory would actually use:
|
|
3609
3863
|
// env vars outrank the active config file (project-local or explicit
|
|
3610
3864
|
// first, global otherwise), and no provider anywhere falls back to
|
|
@@ -3629,27 +3883,9 @@ export const runAgentNew = async (command, streams, env = {}) => {
|
|
|
3629
3883
|
?? (configModelApplies ? activeConfig.model : undefined)
|
|
3630
3884
|
?? (soulProvider === 'openai' ? DEFAULT_OPENAI_MODEL : DEFAULT_ANTHROPIC_MODEL);
|
|
3631
3885
|
const soulPin = soulProvider !== 'demo' ? { provider: soulProvider, model: soulModel } : {};
|
|
3632
|
-
// The name stays theirs, but the id — the soul filename and the
|
|
3633
|
-
// memory key — must be unique: the suggestion pool is small, so a
|
|
3634
|
-
// repeat name would otherwise silently overwrite an earlier agent's
|
|
3635
|
-
// soul and share their memory. 'wx' makes each claim atomic; on a
|
|
3636
|
-
// collision the id gets a fresh suffix and we try again.
|
|
3637
3886
|
await mkdir(agentsDirPath(env), { recursive: true });
|
|
3638
|
-
const
|
|
3639
|
-
|
|
3640
|
-
for (;;) {
|
|
3641
|
-
try {
|
|
3642
|
-
await writeFile(soulPath, formatSoul({ agent, ...soulPin }), { flag: 'wx' });
|
|
3643
|
-
break;
|
|
3644
|
-
}
|
|
3645
|
-
catch (error) {
|
|
3646
|
-
if (error.code !== 'EEXIST') {
|
|
3647
|
-
throw error;
|
|
3648
|
-
}
|
|
3649
|
-
agent = defineAgent({ id: `${baseId}-${randomUUID().slice(0, 4)}`, name, instructions: persona });
|
|
3650
|
-
soulPath = path.join(agentsDirPath(env), `${agent.id}.md`);
|
|
3651
|
-
}
|
|
3652
|
-
}
|
|
3887
|
+
const claimed = await claimSoulFile(env, { name, instructions: persona }, (candidate) => formatSoul({ agent: candidate, ...soulPin }), (message) => writeLine(streams.stdout, message));
|
|
3888
|
+
const { agent, soulPath } = claimed;
|
|
3653
3889
|
const makeDefault = await prompter.select(`Make ${agent.name} your default agent?`, [
|
|
3654
3890
|
'Yes — every stratus run talks to them',
|
|
3655
3891
|
'Not now',
|
|
@@ -3812,6 +4048,11 @@ export const runServe = async (command, streams, env = {}) => {
|
|
|
3812
4048
|
// tokens are gateway infrastructure secrets in the channels namespace of
|
|
3813
4049
|
// ~/.stratus/credentials.json (see @stratusagent/channel-slack's README
|
|
3814
4050
|
// for the 2-minute per-agent app setup).
|
|
4051
|
+
// The approval policy and the Slack approver sets come from the same
|
|
4052
|
+
// config block, resolved once here: the daemon must not answer "who can
|
|
4053
|
+
// approve this" differently from "is anyone being asked at all".
|
|
4054
|
+
const approvalsConfig = await loadServeApprovals(env, command.configPath, warn);
|
|
4055
|
+
const approvalMode = command.approvals ?? approvalsConfig.mode ?? 'headless';
|
|
3815
4056
|
const channelCredentials = await loadChannelCredentials(env);
|
|
3816
4057
|
const slackAgents = Object.entries(channelCredentials.slack ?? {});
|
|
3817
4058
|
const channels = [];
|
|
@@ -3824,11 +4065,16 @@ export const runServe = async (command, streams, env = {}) => {
|
|
|
3824
4065
|
const adapter = await loadSlackAdapter();
|
|
3825
4066
|
if (adapter) {
|
|
3826
4067
|
channels.push(adapter({
|
|
3827
|
-
agents: slackAgents.map(([agentId, tokens]) =>
|
|
3828
|
-
agentId
|
|
3829
|
-
|
|
3830
|
-
|
|
3831
|
-
|
|
4068
|
+
agents: slackAgents.map(([agentId, tokens]) => {
|
|
4069
|
+
const route = resolveAgentApprovals(approvalsConfig, agentId);
|
|
4070
|
+
return {
|
|
4071
|
+
agentId,
|
|
4072
|
+
appToken: tokens.appToken,
|
|
4073
|
+
botToken: tokens.botToken,
|
|
4074
|
+
...(route.slackApprovers ? { approvers: route.slackApprovers } : {}),
|
|
4075
|
+
...(route.slackChannel ? { approvalChannel: route.slackChannel } : {}),
|
|
4076
|
+
};
|
|
4077
|
+
}),
|
|
3832
4078
|
log,
|
|
3833
4079
|
warn,
|
|
3834
4080
|
}));
|
|
@@ -3876,23 +4122,36 @@ export const runServe = async (command, streams, env = {}) => {
|
|
|
3876
4122
|
// while `serve` was something you ran in a terminal you were sitting at;
|
|
3877
4123
|
// it is not, now that setup installs it under launchd by default.
|
|
3878
4124
|
//
|
|
3879
|
-
//
|
|
3880
|
-
//
|
|
3881
|
-
//
|
|
3882
|
-
//
|
|
3883
|
-
//
|
|
3884
|
-
|
|
3885
|
-
|
|
3886
|
-
|
|
3887
|
-
|
|
3888
|
-
|
|
3889
|
-
|
|
3890
|
-
|
|
3891
|
-
|
|
3892
|
-
|
|
4125
|
+
// There is no terminal behind a service manager, so the choice is
|
|
4126
|
+
// between refusing every gated call (`headless`) and parking the turn to
|
|
4127
|
+
// ask through a channel (`remote`). Headless stays the default: a daemon
|
|
4128
|
+
// that starts waiting on people who were never told they are on the hook
|
|
4129
|
+
// hangs turns instead of refusing them. Every refusal is logged — an
|
|
4130
|
+
// unattended denial that appears nowhere reads like an agent that decided
|
|
4131
|
+
// not to bother.
|
|
4132
|
+
const onDecision = (decision) => {
|
|
4133
|
+
if (decision.allowed) {
|
|
4134
|
+
return;
|
|
4135
|
+
}
|
|
4136
|
+
warn(`${decision.agentId}: ${decision.reason} (session ${decision.sessionId})`);
|
|
4137
|
+
};
|
|
4138
|
+
// A factory, not a policy: remote mode parks turns on a transport the
|
|
4139
|
+
// gateway owns, so the policy cannot exist until the gateway is building
|
|
4140
|
+
// it. Headless takes the same path so there is one construction site.
|
|
4141
|
+
const approvals = (transport) => createPermissionPolicy(approvalMode === 'remote'
|
|
4142
|
+
? { mode: 'remote', request: transport.request, onDecision }
|
|
4143
|
+
: { mode: 'headless', onDecision });
|
|
4144
|
+
if (approvalMode === 'remote') {
|
|
4145
|
+
// Only agents whose channel actually came up can be asked: tokens on
|
|
4146
|
+
// disk with the Slack package missing means nothing renders the
|
|
4147
|
+
// request, and the turn discovers that by hanging.
|
|
4148
|
+
const askable = channels.length > 0 ? slackAgents.map(([agentId]) => agentId) : [];
|
|
4149
|
+
log(`approvals: remote — gated calls are parked and asked in Slack (${describeApprovers(approvalsConfig, askable)})`);
|
|
4150
|
+
}
|
|
3893
4151
|
const gateway = createGateway({
|
|
3894
4152
|
env,
|
|
3895
4153
|
approvals,
|
|
4154
|
+
...(approvalsConfig.timeoutMs !== undefined ? { approvalTimeoutMs: approvalsConfig.timeoutMs } : {}),
|
|
3896
4155
|
...(command.configPath ? { selection: { configPath: command.configPath } } : {}),
|
|
3897
4156
|
...(command.idleTimeoutMs !== undefined ? { idleTimeoutMs: command.idleTimeoutMs } : {}),
|
|
3898
4157
|
...(channels.length > 0 ? { channels } : {}),
|
|
@@ -3959,6 +4218,21 @@ export const runServe = async (command, streams, env = {}) => {
|
|
|
3959
4218
|
});
|
|
3960
4219
|
}
|
|
3961
4220
|
await gateway.start();
|
|
4221
|
+
// The roster is only known once the gateway has loaded it, and in remote
|
|
4222
|
+
// mode an agent no channel can ask for is the quietest failure this
|
|
4223
|
+
// feature has: its gated calls park with nobody rendering them and wait
|
|
4224
|
+
// out the whole timeout before being denied. No channel can detect this
|
|
4225
|
+
// on its own — a request is a broadcast, and no adapter knows whether
|
|
4226
|
+
// another one is about to answer — so it is reported here, where the
|
|
4227
|
+
// roster and the channel list are both in view.
|
|
4228
|
+
if (approvalMode === 'remote') {
|
|
4229
|
+
const askable = new Set(channels.length > 0 ? slackAgents.map(([agentId]) => agentId) : []);
|
|
4230
|
+
const unreachable = gateway.agents().map((agent) => agent.id).filter((id) => !askable.has(id));
|
|
4231
|
+
if (unreachable.length > 0) {
|
|
4232
|
+
warn(`no channel can ask for ${unreachable.join(', ')}, so their gated calls will wait out the `
|
|
4233
|
+
+ 'approval timeout and then be denied. Connect a Slack app for them, or run with --approvals headless.');
|
|
4234
|
+
}
|
|
4235
|
+
}
|
|
3962
4236
|
writeLine(streams.stdout, 'Press Ctrl+C to stop.');
|
|
3963
4237
|
// And periodically, for a long-running daemon that warns steadily
|
|
3964
4238
|
// without ever writing enough records to rotate. Unref'd, so it never
|