@stratusagent/cli 0.7.0 → 0.8.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 +111 -7
- package/dist/index.d.ts +31 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +245 -28
- package/dist/index.js.map +1 -1
- package/package.json +21 -21
package/dist/index.js
CHANGED
|
@@ -8,7 +8,7 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
|
|
|
8
8
|
};
|
|
9
9
|
import { randomUUID } from 'node:crypto';
|
|
10
10
|
import { spawn } from 'node:child_process';
|
|
11
|
-
import { mkdtemp, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
|
11
|
+
import { mkdtemp, readdir, readFile, realpath, rm, stat, writeFile } from 'node:fs/promises';
|
|
12
12
|
import os from 'node:os';
|
|
13
13
|
import path from 'node:path';
|
|
14
14
|
import { createInterface } from 'node:readline';
|
|
@@ -17,8 +17,8 @@ import { createLocalCommandExecutor, } from '@stratusagent/executor-local';
|
|
|
17
17
|
import { loadPlugins } from '@stratusagent/plugins';
|
|
18
18
|
import { createFileCommandWhitelist, createPermissionPolicy, describeCommandScope, } from '@stratusagent/permissions';
|
|
19
19
|
import { DEFAULT_ANTHROPIC_MODEL, redactAnthropicRawTurns, } from '@stratusagent/provider-anthropic';
|
|
20
|
-
import { createRememberTool, defineAgent, formatSoul, generateAgentName, parseSoul, } from '@stratusagent/agents';
|
|
21
|
-
import { agentsDirPath, claimSoulFile, collectAvailableModels as collectModels, createDemoTool, createFileMemoryStore, createRuntimeProvider, credentialsPath, defaultApiKeyEnvName, DEFAULT_ANTHROPIC_BASE_URL, DEFAULT_OPENAI_BASE_URL, DEFAULT_OPENAI_MODEL, DEFAULT_STRATUS_AGENT, discoverActiveConfig, globalConfigPath, loadChannelCredentials, loadCredentials, installSkillsFromDirectory, loadOperatorSkills, loadRosterSouls, skillsDirPath, listAgentSummaries, loadSoulFile, memoryFilePath, migrateLegacyMemory, parseProviderName, readNonEmptyString, readProcessEnv, logsDirPath, readWorkingDirectory, resolveAgentApprovals, DEFAULT_CONFIG_FILENAME, loadConfigFile, resolveConfigLocation, resolveRuntimeConfig as resolveStateRuntimeConfig, saveChannelCredentials, saveConfigFile, readTrustedConfigBlock, saveCredentials, servedRuntimes, verifyProviderKey, withLegacyDefaultMemories, workspacesDirPath, gatewayInfoPath, gatewayTokenPath, } from '@stratusagent/state';
|
|
20
|
+
import { canonicalDestination, createForgetTool, createRecallTool, createRememberTool, defineAgent, describeCadence, describeSchedule, FORGET_TOOL_NAME, MEMORY_TOOL_NAME, formatSoul, generateAgentName, parseSoul, } from '@stratusagent/agents';
|
|
21
|
+
import { agentsDirPath, claimSoulFile, collectAvailableModels as collectModels, createDemoTool, createFileMemoryStore, createRuntimeProvider, credentialsPath, defaultApiKeyEnvName, DEFAULT_ANTHROPIC_BASE_URL, DEFAULT_OPENAI_BASE_URL, DEFAULT_OPENAI_MODEL, DEFAULT_STRATUS_AGENT, discoverActiveConfig, globalConfigPath, loadChannelCredentials, loadCredentials, installSkillsFromDirectory, loadOperatorSkills, loadRosterSouls, skillsDirPath, listAgentSummaries, loadSoulFile, memoryFilePath, migrateLegacyMemory, parseProviderName, readNonEmptyString, readProcessEnv, resolveConfiguredSoul, logsDirPath, readWorkingDirectory, resolveAgentApprovals, DEFAULT_CONFIG_FILENAME, loadConfigFile, resolveConfigLocation, resolveRuntimeConfig as resolveStateRuntimeConfig, saveChannelCredentials, saveConfigFile, readTrustedConfigBlock, saveCredentials, servedRuntimes, verifyProviderKey, withLegacyDefaultMemories, workspacesDirPath, gatewayInfoPath, gatewayTokenPath, } from '@stratusagent/state';
|
|
22
22
|
import { installService, readServiceStatus, servicePlatform, serviceUnitPath, startService, stopService, uninstallService, } from "./service.js";
|
|
23
23
|
export { installService, launchdPlist, readServiceStatus, servicePlatform, serviceUnitPath, startService, stopService, systemdUnit, uninstallService, SERVICE_LABEL, } from "./service.js";
|
|
24
24
|
import { createLogWriter, currentLogPosition, truncateRedirectLogs, formatLogRecord, readRecentRecords, tailLog, } from "./logs.js";
|
|
@@ -127,6 +127,8 @@ Usage:
|
|
|
127
127
|
stratus skill add stratuslabs/skill-code-review
|
|
128
128
|
stratus skill add ./my-skills --skill code-review --agent ava
|
|
129
129
|
stratus skills
|
|
130
|
+
stratus schedules
|
|
131
|
+
stratus schedules cancel <id>
|
|
130
132
|
stratus doctor
|
|
131
133
|
stratus service install
|
|
132
134
|
stratus service status
|
|
@@ -173,6 +175,12 @@ Commands:
|
|
|
173
175
|
--force replaces an already-installed id
|
|
174
176
|
skills List installed skills and which agents enable each
|
|
175
177
|
(also: stratus skill list)
|
|
178
|
+
schedules List every schedule the fleet has set — cadence, prompt,
|
|
179
|
+
pre-authorized destination, next firing — straight from the
|
|
180
|
+
daemon's database (--format json). "stratus schedules
|
|
181
|
+
cancel <id>" stops the next firing and revokes the
|
|
182
|
+
destination that was approved with it
|
|
183
|
+
(also: stratus schedule list / schedule cancel <id>)
|
|
176
184
|
agent new Create an agent identity (generates a human-ish name + avatar theme)
|
|
177
185
|
agents List your agents: who they are, where their souls live, what
|
|
178
186
|
they run on, what they remember (also: stratus agent list).
|
|
@@ -485,6 +493,48 @@ export const parseCommand = (argv, env = {}) => {
|
|
|
485
493
|
...(token ? { token } : {}),
|
|
486
494
|
};
|
|
487
495
|
}
|
|
496
|
+
if (command === 'schedules' || (command === 'schedule' && rest[0] === 'list') || (command === 'schedule' && rest[0] === 'cancel')) {
|
|
497
|
+
const schedulesRest = command === 'schedules' ? rest : rest.slice(1);
|
|
498
|
+
const action = (command === 'schedule' ? rest[0] : schedulesRest[0]) === 'cancel' ? 'cancel' : 'list';
|
|
499
|
+
const tokens = action === 'cancel' && command === 'schedules' ? schedulesRest.slice(1) : schedulesRest;
|
|
500
|
+
let format = 'text';
|
|
501
|
+
let scheduleId;
|
|
502
|
+
for (let index = 0; index < tokens.length; index += 1) {
|
|
503
|
+
const token = tokens[index];
|
|
504
|
+
if (!token || (action === 'list' && token === 'list' && index === 0)) {
|
|
505
|
+
continue;
|
|
506
|
+
}
|
|
507
|
+
if (token === '--help' || token === '-h') {
|
|
508
|
+
return { command: 'help' };
|
|
509
|
+
}
|
|
510
|
+
if (token === '--format') {
|
|
511
|
+
const value = readOptionValue(tokens, index, '--format');
|
|
512
|
+
if (value !== 'text' && value !== 'json') {
|
|
513
|
+
throw new Error(`Unsupported format: ${value}`);
|
|
514
|
+
}
|
|
515
|
+
format = value;
|
|
516
|
+
index += 1;
|
|
517
|
+
continue;
|
|
518
|
+
}
|
|
519
|
+
if (token.startsWith('--')) {
|
|
520
|
+
throw new Error(`Unknown option: ${token}`);
|
|
521
|
+
}
|
|
522
|
+
if (action === 'cancel' && scheduleId === undefined) {
|
|
523
|
+
scheduleId = token;
|
|
524
|
+
continue;
|
|
525
|
+
}
|
|
526
|
+
throw new Error(`Unexpected argument: ${token}. Try: stratus schedules, stratus schedules cancel <id>`);
|
|
527
|
+
}
|
|
528
|
+
if (action === 'cancel' && !scheduleId) {
|
|
529
|
+
throw new Error('schedules cancel needs the schedule id: stratus schedules cancel <id>.');
|
|
530
|
+
}
|
|
531
|
+
return {
|
|
532
|
+
command: 'schedules',
|
|
533
|
+
action,
|
|
534
|
+
...(scheduleId ? { scheduleId } : {}),
|
|
535
|
+
format,
|
|
536
|
+
};
|
|
537
|
+
}
|
|
488
538
|
if (command === 'doctor') {
|
|
489
539
|
let format = 'text';
|
|
490
540
|
let configPath;
|
|
@@ -895,7 +945,7 @@ export const formatEvent = (event) => {
|
|
|
895
945
|
* granted permission leaves no trace whatsoever. Still no tool input: what
|
|
896
946
|
* was asked is here, what it was asked with is not.
|
|
897
947
|
*/
|
|
898
|
-
const eventDetail = (event) => {
|
|
948
|
+
export const eventDetail = (event) => {
|
|
899
949
|
switch (event.type) {
|
|
900
950
|
case 'session.updated':
|
|
901
951
|
return { status: event.status };
|
|
@@ -904,8 +954,19 @@ const eventDetail = (event) => {
|
|
|
904
954
|
case 'tool.called':
|
|
905
955
|
case 'tool.denied':
|
|
906
956
|
return { tool: event.call.toolName };
|
|
907
|
-
case 'tool.completed':
|
|
908
|
-
|
|
957
|
+
case 'tool.completed': {
|
|
958
|
+
// A memory write or retirement names the entry it touched — the id
|
|
959
|
+
// is a reference, not content, and "when did the agent learn/drop
|
|
960
|
+
// this" is unanswerable later without it. The fact itself stays out
|
|
961
|
+
// of the trace, like every other tool input and output.
|
|
962
|
+
const output = event.result.output;
|
|
963
|
+
const entry = (event.result.toolName === MEMORY_TOOL_NAME || event.result.toolName === FORGET_TOOL_NAME)
|
|
964
|
+
&& event.result.ok && typeof output === 'object' && output !== null && !Array.isArray(output)
|
|
965
|
+
&& typeof output.id === 'string'
|
|
966
|
+
? output.id
|
|
967
|
+
: undefined;
|
|
968
|
+
return { tool: event.result.toolName, ok: event.result.ok, ...(entry !== undefined ? { entry } : {}) };
|
|
969
|
+
}
|
|
909
970
|
case 'tool.approval-requested':
|
|
910
971
|
return { tool: event.call.toolName, risk: event.risk, requestId: event.requestId };
|
|
911
972
|
case 'tool.approval-resolved':
|
|
@@ -1019,6 +1080,8 @@ const createAgentRuntime = async (streams, options) => {
|
|
|
1019
1080
|
const tools = new ToolRegistry();
|
|
1020
1081
|
tools.register(createDemoTool());
|
|
1021
1082
|
tools.register(createRememberTool(memory));
|
|
1083
|
+
tools.register(createRecallTool(memory));
|
|
1084
|
+
tools.register(createForgetTool(memory));
|
|
1022
1085
|
// The same skills the daemon would serve, from the same directory, for
|
|
1023
1086
|
// the same reason the plugins below match: a skill that routes in
|
|
1024
1087
|
// `stratus run` routes in `stratus serve`, and one that is broken is
|
|
@@ -1769,11 +1832,17 @@ const verifySlackAppToken = async (token, fetchImpl) => {
|
|
|
1769
1832
|
// package being installed.
|
|
1770
1833
|
const SLACK_BOT_SCOPES = [
|
|
1771
1834
|
'app_mentions:read',
|
|
1835
|
+
// The conversations read family backs outbound destination validation
|
|
1836
|
+
// (conversations.info): whether a channel a schedule wants to report to
|
|
1837
|
+
// exists, and whether this app is a member of it.
|
|
1838
|
+
'channels:read',
|
|
1772
1839
|
'chat:write',
|
|
1773
1840
|
'files:write',
|
|
1841
|
+
'groups:read',
|
|
1774
1842
|
'im:history',
|
|
1775
1843
|
'im:read',
|
|
1776
1844
|
'im:write',
|
|
1845
|
+
'mpim:read',
|
|
1777
1846
|
'users:read',
|
|
1778
1847
|
];
|
|
1779
1848
|
const SLACK_BOT_EVENTS = ['app_mention', 'message.im'];
|
|
@@ -1803,7 +1872,8 @@ export const slackAppManifest = (agentName) => JSON.stringify({
|
|
|
1803
1872
|
const DEFAULT_SOUL_STARTER = [
|
|
1804
1873
|
'You are a helpful, warm generalist. Answer first, explain second, and',
|
|
1805
1874
|
'keep replies short unless the question genuinely needs depth. Use',
|
|
1806
|
-
'memory.remember for durable facts about the people you work with
|
|
1875
|
+
'memory.remember for durable facts about the people you work with, and',
|
|
1876
|
+
'memory.recall to look up what you know when a conversation calls for it.',
|
|
1807
1877
|
].join('\n');
|
|
1808
1878
|
export const runSetup = async (command, streams, env = {}) => {
|
|
1809
1879
|
const cwd = readWorkingDirectory(env);
|
|
@@ -3736,7 +3806,11 @@ const resolveSkillSource = async (source, env) => {
|
|
|
3736
3806
|
const localPath = path.resolve(readWorkingDirectory(env), source);
|
|
3737
3807
|
try {
|
|
3738
3808
|
if ((await stat(localPath)).isDirectory()) {
|
|
3739
|
-
|
|
3809
|
+
// The real directory, not the path as typed: a source that is
|
|
3810
|
+
// itself a symlink would otherwise be copied as that link — an
|
|
3811
|
+
// installed entry pointing back at the source tree, which the
|
|
3812
|
+
// loader ignores as not-a-directory.
|
|
3813
|
+
return { kind: 'local', directory: await realpath(localPath) };
|
|
3740
3814
|
}
|
|
3741
3815
|
}
|
|
3742
3816
|
catch {
|
|
@@ -3750,8 +3824,30 @@ const resolveSkillSource = async (source, env) => {
|
|
|
3750
3824
|
}
|
|
3751
3825
|
throw new Error(`Cannot read ${JSON.stringify(source)} as a skill source. Pass a GitHub owner/repo, a git URL, or a local path.`);
|
|
3752
3826
|
};
|
|
3827
|
+
/**
|
|
3828
|
+
* A source URL safe to print: userinfo stripped. A token travels in exactly
|
|
3829
|
+
* this position (`https://user:token@host/repo.git`), stdout is commonly
|
|
3830
|
+
* retained in CI logs, and git's own stderr already redacts — only the
|
|
3831
|
+
* unredacted URL is handed to git itself.
|
|
3832
|
+
*/
|
|
3833
|
+
const redactedSourceUrl = (url) => {
|
|
3834
|
+
try {
|
|
3835
|
+
const parsed = new URL(url);
|
|
3836
|
+
if (parsed.username || parsed.password) {
|
|
3837
|
+
parsed.username = '***';
|
|
3838
|
+
parsed.password = '';
|
|
3839
|
+
}
|
|
3840
|
+
return parsed.toString();
|
|
3841
|
+
}
|
|
3842
|
+
catch {
|
|
3843
|
+
// scp-style (git@host:path) or anything else URL() refuses: keep the
|
|
3844
|
+
// shape, hide whatever sits before the @.
|
|
3845
|
+
return url.replace(/^[^@/]+@/, '***@');
|
|
3846
|
+
}
|
|
3847
|
+
};
|
|
3753
3848
|
/** Shallow-clone a skills source. Git owns every transport we would otherwise re-implement. */
|
|
3754
3849
|
const cloneSkillSource = async (url, destination) => {
|
|
3850
|
+
const display = redactedSourceUrl(url);
|
|
3755
3851
|
await new Promise((resolve, reject) => {
|
|
3756
3852
|
const child = spawn('git', ['clone', '--depth', '1', '--quiet', url, destination], {
|
|
3757
3853
|
stdio: ['ignore', 'ignore', 'pipe'],
|
|
@@ -3761,18 +3857,54 @@ const cloneSkillSource = async (url, destination) => {
|
|
|
3761
3857
|
stderr += String(chunk);
|
|
3762
3858
|
});
|
|
3763
3859
|
child.on('error', (error) => {
|
|
3764
|
-
reject(new Error(`Could not run git to fetch ${
|
|
3860
|
+
reject(new Error(`Could not run git to fetch ${display}: ${error.message}`));
|
|
3765
3861
|
});
|
|
3766
3862
|
child.on('close', (code) => {
|
|
3767
3863
|
if (code === 0) {
|
|
3768
3864
|
resolve();
|
|
3769
3865
|
}
|
|
3770
3866
|
else {
|
|
3771
|
-
|
|
3867
|
+
// git redacts credentials in its own messages; the URL we echo is
|
|
3868
|
+
// ours to redact.
|
|
3869
|
+
reject(new Error(`git clone failed for ${display}: ${stderr.trim() || `exit code ${code}`}`));
|
|
3772
3870
|
}
|
|
3773
3871
|
});
|
|
3774
3872
|
});
|
|
3775
3873
|
};
|
|
3874
|
+
/**
|
|
3875
|
+
* The souls skill enablement is judged against: the agents directory plus
|
|
3876
|
+
* the configured default soul (config `soul:` / STRATUS_SOUL), which may
|
|
3877
|
+
* live outside it and is served all the same. The daemon's roster and
|
|
3878
|
+
* `stratus agents` both include it, so `skill add --agent` and `stratus
|
|
3879
|
+
* skills` must not answer from a narrower set.
|
|
3880
|
+
*/
|
|
3881
|
+
const rosterSoulsWithConfigured = async (env, warn) => {
|
|
3882
|
+
const entries = await loadRosterSouls(env, warn);
|
|
3883
|
+
let complete = true;
|
|
3884
|
+
try {
|
|
3885
|
+
const configured = await resolveConfiguredSoul({}, env);
|
|
3886
|
+
if (configured) {
|
|
3887
|
+
const entry = { soul: configured.soul, path: configured.path };
|
|
3888
|
+
const clash = entries.findIndex((candidate) => candidate.soul.agent.id === configured.soul.agent.id);
|
|
3889
|
+
// Replaced, not skipped: a roster file claiming the configured
|
|
3890
|
+
// soul's id is the one the gateway stops serving — both commands
|
|
3891
|
+
// must answer for the soul a dispatch actually runs.
|
|
3892
|
+
if (clash >= 0) {
|
|
3893
|
+
entries[clash] = entry;
|
|
3894
|
+
}
|
|
3895
|
+
else {
|
|
3896
|
+
entries.push(entry);
|
|
3897
|
+
}
|
|
3898
|
+
}
|
|
3899
|
+
}
|
|
3900
|
+
catch (error) {
|
|
3901
|
+
// The configured agent's allowlist was not read, so the set is not
|
|
3902
|
+
// the roster — callers making enablement claims must withhold them.
|
|
3903
|
+
complete = false;
|
|
3904
|
+
warn(`could not read the configured soul: ${error instanceof Error ? error.message : String(error)}`);
|
|
3905
|
+
}
|
|
3906
|
+
return { entries, complete };
|
|
3907
|
+
};
|
|
3776
3908
|
export const runSkillAdd = async (command, streams, env = {}) => {
|
|
3777
3909
|
const resolved = await resolveSkillSource(command.source, env);
|
|
3778
3910
|
let sourceDir;
|
|
@@ -3783,7 +3915,7 @@ export const runSkillAdd = async (command, streams, env = {}) => {
|
|
|
3783
3915
|
else {
|
|
3784
3916
|
const scratch = await mkdtemp(path.join(os.tmpdir(), 'stratus-skill-add-'));
|
|
3785
3917
|
cleanup = () => rm(scratch, { recursive: true, force: true });
|
|
3786
|
-
writeLine(streams.stdout, `Fetching ${resolved.url} …`);
|
|
3918
|
+
writeLine(streams.stdout, `Fetching ${redactedSourceUrl(resolved.url)} …`);
|
|
3787
3919
|
try {
|
|
3788
3920
|
await cloneSkillSource(resolved.url, scratch);
|
|
3789
3921
|
}
|
|
@@ -3794,17 +3926,30 @@ export const runSkillAdd = async (command, streams, env = {}) => {
|
|
|
3794
3926
|
sourceDir = scratch;
|
|
3795
3927
|
}
|
|
3796
3928
|
try {
|
|
3929
|
+
// For a cloned source the temp directory's random basename must not
|
|
3930
|
+
// name a root-level skill — the repository's own name is what a
|
|
3931
|
+
// nameless root SKILL.md installs under.
|
|
3932
|
+
const rootId = resolved.kind === 'git'
|
|
3933
|
+
? path.basename(new URL(resolved.url.replace(/^git@([^:]+):/, 'ssh://git@$1/')).pathname, '.git')
|
|
3934
|
+
: undefined;
|
|
3797
3935
|
const result = await installSkillsFromDirectory(env, sourceDir, {
|
|
3798
3936
|
...(command.skillIds ? { only: command.skillIds } : {}),
|
|
3799
3937
|
...(command.force ? { force: true } : {}),
|
|
3938
|
+
...(rootId !== undefined && rootId.length > 0 ? { rootId } : {}),
|
|
3800
3939
|
});
|
|
3801
3940
|
for (const skill of result.installed) {
|
|
3802
3941
|
writeLine(streams.stdout, `installed ${skill.id} — ${skill.description}`);
|
|
3803
3942
|
}
|
|
3943
|
+
// Present already is a no-op, not a failure — and exactly what the
|
|
3944
|
+
// "rerun with --agent" hint below produces, so these stay eligible
|
|
3945
|
+
// for the enablement step.
|
|
3946
|
+
for (const skill of result.alreadyInstalled) {
|
|
3947
|
+
writeLine(streams.stdout, `already installed ${skill.id} — ${skill.description}`);
|
|
3948
|
+
}
|
|
3804
3949
|
for (const skip of result.skipped) {
|
|
3805
3950
|
writeLine(streams.stderr, `Warning: skipped ${skip.id}: ${skip.reason}`);
|
|
3806
3951
|
}
|
|
3807
|
-
if (result.installed.length === 0) {
|
|
3952
|
+
if (result.installed.length === 0 && result.alreadyInstalled.length === 0) {
|
|
3808
3953
|
writeLine(streams.stderr, result.skipped.length > 0
|
|
3809
3954
|
? 'Error: nothing was installed.'
|
|
3810
3955
|
: `Error: no skills found in ${command.source}. A skill is a directory with a SKILL.md.`);
|
|
@@ -3813,7 +3958,7 @@ export const runSkillAdd = async (command, streams, env = {}) => {
|
|
|
3813
3958
|
// Installed is not enabled: a soul opts in through its skills:
|
|
3814
3959
|
// allowlist, and that stays true when the install came through a
|
|
3815
3960
|
// command. --agent is the explicit way to say both at once.
|
|
3816
|
-
const ids = result.installed.map((skill) => skill.id);
|
|
3961
|
+
const ids = [...result.installed, ...result.alreadyInstalled].map((skill) => skill.id);
|
|
3817
3962
|
if (command.agentId === undefined) {
|
|
3818
3963
|
writeLine(streams.stdout);
|
|
3819
3964
|
writeLine(streams.stdout, 'Installed, not yet enabled. Add to an agent\'s soul frontmatter:');
|
|
@@ -3824,10 +3969,10 @@ export const runSkillAdd = async (command, streams, env = {}) => {
|
|
|
3824
3969
|
writeLine(streams.stdout, `(or rerun with --agent <id>, or list them: stratus skills)`);
|
|
3825
3970
|
return 0;
|
|
3826
3971
|
}
|
|
3827
|
-
const roster = await
|
|
3972
|
+
const { entries: roster } = await rosterSoulsWithConfigured(env, (line) => writeLine(streams.stderr, `Warning: ${line}`));
|
|
3828
3973
|
const entry = roster.find((candidate) => candidate.soul.agent.id === command.agentId);
|
|
3829
3974
|
if (!entry) {
|
|
3830
|
-
writeLine(streams.stderr, `Error: no agent with id ${command.agentId} in ${agentsDirPath(env)}. The skills are installed; enable them by editing a soul.`);
|
|
3975
|
+
writeLine(streams.stderr, `Error: no agent with id ${command.agentId} in ${agentsDirPath(env)} or the configured soul. The skills are installed; enable them by editing a soul.`);
|
|
3831
3976
|
return 1;
|
|
3832
3977
|
}
|
|
3833
3978
|
const existing = entry.soul.agent.skills ?? [];
|
|
@@ -3850,6 +3995,53 @@ export const runSkillAdd = async (command, streams, env = {}) => {
|
|
|
3850
3995
|
await cleanup?.();
|
|
3851
3996
|
}
|
|
3852
3997
|
};
|
|
3998
|
+
/**
|
|
3999
|
+
* List and cancel what the fleet has scheduled — against the daemon's own
|
|
4000
|
+
* database, the way `stratus logs` reads the daemon's own log. WAL makes a
|
|
4001
|
+
* second process on the file routine; a running daemon re-reads due rows
|
|
4002
|
+
* every tick and the destination grant on every send, so a cancel from
|
|
4003
|
+
* here takes effect without asking it anything.
|
|
4004
|
+
*/
|
|
4005
|
+
export const runSchedules = async (command, streams, env = {}) => {
|
|
4006
|
+
// Lazy like the serve path: node:sqlite loads only for the command that
|
|
4007
|
+
// needs it.
|
|
4008
|
+
const { SqliteScheduleStore, defaultSessionDbPath } = await import('@stratusagent/gateway');
|
|
4009
|
+
const store = new SqliteScheduleStore(defaultSessionDbPath(env));
|
|
4010
|
+
try {
|
|
4011
|
+
if (command.action === 'cancel') {
|
|
4012
|
+
const id = command.scheduleId ?? '';
|
|
4013
|
+
const record = store.get(id);
|
|
4014
|
+
if (!record || !store.delete(id)) {
|
|
4015
|
+
writeLine(streams.stderr, `No schedule with id ${id}. \`stratus schedules\` lists what exists.`);
|
|
4016
|
+
return 1;
|
|
4017
|
+
}
|
|
4018
|
+
writeLine(streams.stdout, `Cancelled ${id} (${record.agentId}, ${describeCadence(record.cadence)}).`);
|
|
4019
|
+
if (record.destination) {
|
|
4020
|
+
writeLine(streams.stdout, `Its pre-authorized destination ${canonicalDestination(record.destination)} is revoked with it.`);
|
|
4021
|
+
}
|
|
4022
|
+
return 0;
|
|
4023
|
+
}
|
|
4024
|
+
const schedules = store.list();
|
|
4025
|
+
if (command.format === 'json') {
|
|
4026
|
+
writeLine(streams.stdout, JSON.stringify({ schedules: schedules.map(describeSchedule) }, null, 2));
|
|
4027
|
+
return 0;
|
|
4028
|
+
}
|
|
4029
|
+
if (schedules.length === 0) {
|
|
4030
|
+
writeLine(streams.stdout, 'No schedules set. An agent sets one with the schedule.every / schedule.at tools.');
|
|
4031
|
+
return 0;
|
|
4032
|
+
}
|
|
4033
|
+
for (const record of schedules) {
|
|
4034
|
+
const destination = record.destination ? ` → ${canonicalDestination(record.destination)}` : '';
|
|
4035
|
+
writeLine(streams.stdout, `${record.id} [${record.agentId}] ${describeCadence(record.cadence)}${destination}`);
|
|
4036
|
+
writeLine(streams.stdout, ` next: ${record.nextFireAt ?? '(spent — awaiting cleanup)'}${record.lastFiredAt ? ` last: ${record.lastFiredAt}` : ''}`);
|
|
4037
|
+
writeLine(streams.stdout, ` prompt: ${record.prompt}`);
|
|
4038
|
+
}
|
|
4039
|
+
return 0;
|
|
4040
|
+
}
|
|
4041
|
+
finally {
|
|
4042
|
+
store.close();
|
|
4043
|
+
}
|
|
4044
|
+
};
|
|
3853
4045
|
export const runSkills = async (streams, env = {}) => {
|
|
3854
4046
|
const registry = new SkillRegistry();
|
|
3855
4047
|
const skills = await loadOperatorSkills(env, registry, (line) => {
|
|
@@ -3864,17 +4056,34 @@ export const runSkills = async (streams, env = {}) => {
|
|
|
3864
4056
|
// follows "what is installed" — read from the same roster a dispatch
|
|
3865
4057
|
// serves. Plugin-contributed skills are the daemon's to list
|
|
3866
4058
|
// (/catalog/tools); this command reads the operator directory.
|
|
3867
|
-
|
|
4059
|
+
//
|
|
4060
|
+
// A roster that will not load (a duplicate agent id, deliberately
|
|
4061
|
+
// fatal) is unreadable enablement, not empty enablement: say so and
|
|
4062
|
+
// withhold the claim rather than reporting every skill unused.
|
|
4063
|
+
let roster = [];
|
|
4064
|
+
let rosterUnreadable = false;
|
|
4065
|
+
try {
|
|
4066
|
+
const resolved = await rosterSoulsWithConfigured(env, (line) => writeLine(streams.stderr, `Warning: ${line}`));
|
|
4067
|
+
roster = resolved.entries;
|
|
4068
|
+
rosterUnreadable = !resolved.complete;
|
|
4069
|
+
}
|
|
4070
|
+
catch (error) {
|
|
4071
|
+
rosterUnreadable = true;
|
|
4072
|
+
writeLine(streams.stderr, `Warning: cannot say who enables what — the roster did not load: ${error instanceof Error ? error.message : String(error)}`);
|
|
4073
|
+
}
|
|
3868
4074
|
for (const skill of skills) {
|
|
3869
|
-
|
|
3870
|
-
|
|
3871
|
-
const
|
|
3872
|
-
|
|
3873
|
-
|
|
3874
|
-
|
|
3875
|
-
|
|
3876
|
-
|
|
3877
|
-
|
|
4075
|
+
let suffix = '';
|
|
4076
|
+
if (!rosterUnreadable) {
|
|
4077
|
+
const enabledBy = roster
|
|
4078
|
+
.filter((entry) => {
|
|
4079
|
+
const allowlist = entry.soul.agent.skills;
|
|
4080
|
+
return allowlist !== undefined
|
|
4081
|
+
&& allowlist.length > 0
|
|
4082
|
+
&& matchesSkillAllowlist(skill.id, allowlist);
|
|
4083
|
+
})
|
|
4084
|
+
.map((entry) => entry.soul.agent.name);
|
|
4085
|
+
suffix = enabledBy.length > 0 ? ` — enabled by ${enabledBy.join(', ')}` : ' — enabled by nobody yet';
|
|
4086
|
+
}
|
|
3878
4087
|
writeLine(streams.stdout, `${skill.id.padEnd(24)}${skill.description}${suffix}`);
|
|
3879
4088
|
}
|
|
3880
4089
|
return 0;
|
|
@@ -4450,9 +4659,14 @@ export const runServe = async (command, streams, env = {}) => {
|
|
|
4450
4659
|
log(`${agentId}: "${describeCommandScope(scope)}" now runs without asking`);
|
|
4451
4660
|
},
|
|
4452
4661
|
};
|
|
4453
|
-
const approvals = (transport) => createPermissionPolicy(
|
|
4454
|
-
|
|
4455
|
-
|
|
4662
|
+
const approvals = (transport) => createPermissionPolicy(
|
|
4663
|
+
// The destination scope rides along in BOTH modes — it is what lets a
|
|
4664
|
+
// scheduled turn report to the channel a human approved with the
|
|
4665
|
+
// schedule, and headless (where every other gated call is refused) is
|
|
4666
|
+
// exactly the deployment it exists for.
|
|
4667
|
+
approvalMode === 'remote'
|
|
4668
|
+
? { mode: 'remote', request: transport.request, onDecision, commands, destinations: transport.destinations }
|
|
4669
|
+
: { mode: 'headless', onDecision, commands, destinations: transport.destinations });
|
|
4456
4670
|
if (approvalMode === 'remote') {
|
|
4457
4671
|
// Only agents whose channel actually came up can be asked: tokens on
|
|
4458
4672
|
// disk with the Slack package missing means nothing renders the
|
|
@@ -4643,6 +4857,9 @@ export const runCli = async ({ argv, streams = process, env = {} }) => {
|
|
|
4643
4857
|
if (command.command === 'skills') {
|
|
4644
4858
|
return await runSkills(streams, resolvedEnv);
|
|
4645
4859
|
}
|
|
4860
|
+
if (command.command === 'schedules') {
|
|
4861
|
+
return await runSchedules(command, streams, resolvedEnv);
|
|
4862
|
+
}
|
|
4646
4863
|
if (command.command === 'doctor') {
|
|
4647
4864
|
return await runDoctor(command, streams, resolvedEnv);
|
|
4648
4865
|
}
|