lazyclaw 6.0.0 → 6.0.1
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/channels-discord/index.mjs +76 -0
- package/channels-discord/package.json +14 -0
- package/channels-email/index.mjs +109 -0
- package/channels-email/package.json +14 -0
- package/channels-signal/index.mjs +69 -0
- package/channels-signal/package.json +9 -0
- package/channels-voice/index.mjs +81 -0
- package/channels-voice/package.json +9 -0
- package/channels-whatsapp/index.mjs +70 -0
- package/channels-whatsapp/package.json +13 -0
- package/commands/agents.mjs +669 -0
- package/commands/auth_nodes.mjs +323 -0
- package/commands/automation.mjs +582 -0
- package/commands/channels.mjs +255 -0
- package/commands/chat.mjs +1217 -0
- package/commands/config.mjs +315 -0
- package/commands/daemon.mjs +260 -0
- package/commands/misc.mjs +128 -0
- package/commands/providers.mjs +511 -0
- package/commands/sessions.mjs +343 -0
- package/commands/setup.mjs +741 -0
- package/commands/skills.mjs +218 -0
- package/commands/workflow.mjs +661 -0
- package/daemon/lib/auth.mjs +58 -0
- package/daemon/lib/cost.mjs +30 -0
- package/daemon/lib/provider.mjs +69 -0
- package/daemon/lib/respond.mjs +83 -0
- package/daemon/route_table.mjs +96 -0
- package/daemon/routes/_deps.mjs +36 -0
- package/daemon/routes/config.mjs +99 -0
- package/daemon/routes/conversation.mjs +371 -0
- package/daemon/routes/meta.mjs +239 -0
- package/daemon/routes/ops.mjs +185 -0
- package/daemon/routes/providers.mjs +211 -0
- package/daemon/routes/rates.mjs +90 -0
- package/daemon/routes/registry.mjs +223 -0
- package/daemon/routes/sessions.mjs +213 -0
- package/daemon/routes/skills.mjs +260 -0
- package/daemon/routes/workflows.mjs +224 -0
- package/dotenv_min.mjs +23 -0
- package/first_run.mjs +15 -0
- package/goals_cron.mjs +37 -0
- package/lib/args.mjs +216 -0
- package/lib/config.mjs +113 -0
- package/lib/registry_boot.mjs +55 -0
- package/package.json +14 -1
- package/secure_write.mjs +46 -0
|
@@ -0,0 +1,669 @@
|
|
|
1
|
+
// Multi-agent commands: cmdAgent (one-shot run), cmdTask, cmdTeam, and the
|
|
2
|
+
// agent registry (cmdAgentRegistry), extracted from cli.mjs (Phase D3).
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import { configPath, readConfig, _resolveAuthKey, _resolveBaseUrl } from '../lib/config.mjs';
|
|
6
|
+
import { ensureRegistry, getRegistry } from '../lib/registry_boot.mjs';
|
|
7
|
+
import { loadDotenvIfAny as _loadDotenvShared } from '../dotenv_min.mjs';
|
|
8
|
+
|
|
9
|
+
// Thin .env loader wrapper kept local so the module stays self-contained.
|
|
10
|
+
export function _loadDotenvIfAny(cfgDir) { return _loadDotenvShared(cfgDir); }
|
|
11
|
+
|
|
12
|
+
export async function cmdAgent(prompt, flags) {
|
|
13
|
+
// OpenClaw-style one-shot: send a single prompt, stream the response,
|
|
14
|
+
// exit. Useful in scripts and pipelines. Honors --provider and --model
|
|
15
|
+
// flags as overrides over config.json. Reads stdin when prompt is "-"
|
|
16
|
+
// so callers can pipe input.
|
|
17
|
+
await ensureRegistry();
|
|
18
|
+
const skillsMod = await import('../skills.mjs');
|
|
19
|
+
const cfg = readConfig();
|
|
20
|
+
const provName = flags.provider || cfg.provider || 'mock';
|
|
21
|
+
let prov = getRegistry().PROVIDERS[provName];
|
|
22
|
+
if (!prov) { console.error(`unknown provider: ${provName}`); process.exit(2); }
|
|
23
|
+
// --fallback "openai,ollama" wraps the primary in a withFallback chain so
|
|
24
|
+
// RATE_LIMIT/CONNECTION_REFUSED/5xx on the primary trips through to the
|
|
25
|
+
// listed providers in order. Unknown names exit 2 — better than a silent
|
|
26
|
+
// skip, the chain lengths matter for user expectations.
|
|
27
|
+
const fallbackList = (flags.fallback ? String(flags.fallback) : '')
|
|
28
|
+
.split(',').map(s => s.trim()).filter(Boolean);
|
|
29
|
+
if (fallbackList.length > 0) {
|
|
30
|
+
const chain = [prov];
|
|
31
|
+
for (const fb of fallbackList) {
|
|
32
|
+
const fp = getRegistry().PROVIDERS[fb];
|
|
33
|
+
if (!fp) { console.error(`unknown fallback provider: ${fb}`); process.exit(2); }
|
|
34
|
+
chain.push(fp);
|
|
35
|
+
}
|
|
36
|
+
const { withFallback } = await import('../providers/fallback.mjs');
|
|
37
|
+
prov = withFallback(chain);
|
|
38
|
+
}
|
|
39
|
+
// --retry N wraps the chosen provider with the rate-limit-aware retry
|
|
40
|
+
// helper. N is exclusive of the initial call (--retry 3 = up to 4 tries).
|
|
41
|
+
// Default 0 keeps behavior identical to before for callers that don't
|
|
42
|
+
// explicitly opt in.
|
|
43
|
+
const retryN = flags.retry !== undefined ? parseInt(flags.retry, 10) : 0;
|
|
44
|
+
if (Number.isFinite(retryN) && retryN > 0) {
|
|
45
|
+
const { withRateLimitRetry } = await import('../providers/retry.mjs');
|
|
46
|
+
prov = withRateLimitRetry(prov, { attempts: retryN });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// --skill resolves a comma-separated list to a composed system prompt.
|
|
50
|
+
// Defaults from config.skills (same shape) if --skill not passed.
|
|
51
|
+
const skillNames = (flags.skill ? String(flags.skill) : (Array.isArray(cfg.skills) ? cfg.skills.join(',') : ''))
|
|
52
|
+
.split(',').map(s => s.trim()).filter(Boolean);
|
|
53
|
+
// --workspace <name> stitches AGENTS.md / SOUL.md / TOOLS.md from
|
|
54
|
+
// <configDir>/workspaces/<name>/ at the head of the system prompt.
|
|
55
|
+
// Workspace + skill compose: workspace block first, skill block
|
|
56
|
+
// after — same order as `lazyclaw workspace show` so the user can
|
|
57
|
+
// preview exactly what the LLM will see.
|
|
58
|
+
const workspaceName = flags.workspace || cfg.workspace || '';
|
|
59
|
+
const promptParts = [];
|
|
60
|
+
if (workspaceName) {
|
|
61
|
+
try {
|
|
62
|
+
const ws = await import('../workspace.mjs');
|
|
63
|
+
const wsPrompt = ws.composeWorkspacePrompt(path.dirname(configPath()), workspaceName);
|
|
64
|
+
if (wsPrompt) promptParts.push(wsPrompt);
|
|
65
|
+
} catch (e) { console.error(`workspace error: ${e.message}`); process.exit(2); }
|
|
66
|
+
}
|
|
67
|
+
if (skillNames.length > 0) {
|
|
68
|
+
try {
|
|
69
|
+
const skillPrompt = skillsMod.composeSystemPrompt(skillNames, path.dirname(configPath()));
|
|
70
|
+
if (skillPrompt) promptParts.push(skillPrompt);
|
|
71
|
+
} catch (e) { console.error(`skill error: ${e.message}`); process.exit(2); }
|
|
72
|
+
}
|
|
73
|
+
const systemPrompt = promptParts.length ? promptParts.join('\n\n---\n\n') : null;
|
|
74
|
+
|
|
75
|
+
let text = prompt;
|
|
76
|
+
if (text === '-' || text === undefined) {
|
|
77
|
+
text = await new Promise(resolve => {
|
|
78
|
+
let buf = '';
|
|
79
|
+
process.stdin.setEncoding('utf8');
|
|
80
|
+
process.stdin.on('data', d => { buf += d; });
|
|
81
|
+
process.stdin.on('end', () => resolve(buf));
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
if (!text || !String(text).trim()) {
|
|
85
|
+
console.error('agent: empty prompt'); process.exit(2);
|
|
86
|
+
}
|
|
87
|
+
const messages = [];
|
|
88
|
+
if (systemPrompt) messages.push({ role: 'system', content: systemPrompt });
|
|
89
|
+
messages.push({ role: 'user', content: String(text) });
|
|
90
|
+
// --thinking <budgetTokens> enables Anthropic extended thinking. Other
|
|
91
|
+
// providers ignore the flag silently because their opts shape doesn't
|
|
92
|
+
// carry it.
|
|
93
|
+
const thinkingBudget = flags.thinking ? parseInt(flags.thinking, 10) : 0;
|
|
94
|
+
// --show-thinking prints thinking deltas to stderr while text deltas
|
|
95
|
+
// continue to stream to stdout. This keeps stdout clean for piping.
|
|
96
|
+
const showThinking = flags['show-thinking'];
|
|
97
|
+
// --usage prints normalized token totals to stderr after the response
|
|
98
|
+
// streams. --cost adds a cost line when cfg.rates has a card matching
|
|
99
|
+
// the active provider/model. Both write to stderr so piping the answer
|
|
100
|
+
// text downstream isn't polluted with metadata.
|
|
101
|
+
const showUsage = flags.usage;
|
|
102
|
+
const showCost = flags.cost;
|
|
103
|
+
// Loading rates is lazy: only when --cost is on, and we resolve once
|
|
104
|
+
// up-front so the onUsage callback below doesn't need to import on a
|
|
105
|
+
// hot path.
|
|
106
|
+
let costFromUsage = null;
|
|
107
|
+
if (showCost) {
|
|
108
|
+
const ratesMod = await import('../providers/rates.mjs');
|
|
109
|
+
costFromUsage = ratesMod.costFromUsage;
|
|
110
|
+
}
|
|
111
|
+
// --sandbox docker:<image> routes the underlying subprocess
|
|
112
|
+
// (currently only the claude-cli provider hits this branch)
|
|
113
|
+
// through `docker run`. parseSandboxSpec returns null when the
|
|
114
|
+
// flag is absent / "off" so the no-flag path is bit-identical.
|
|
115
|
+
let sandboxSpec = null;
|
|
116
|
+
if (flags.sandbox) {
|
|
117
|
+
const sb = await import('../sandbox.mjs');
|
|
118
|
+
try { sandboxSpec = sb.parseSandboxSpec(flags.sandbox, flags); }
|
|
119
|
+
catch (e) { console.error(`error: ${e.message}`); process.exit(2); }
|
|
120
|
+
if (sandboxSpec && provName !== 'claude-cli') {
|
|
121
|
+
process.stderr.write(`warn: --sandbox only wraps subprocess providers; ${provName} ignores it\n`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
try {
|
|
125
|
+
for await (const chunk of prov.sendMessage(messages, {
|
|
126
|
+
apiKey: _resolveAuthKey(cfg, provName),
|
|
127
|
+
model: flags.model || cfg.model,
|
|
128
|
+
sandbox: sandboxSpec,
|
|
129
|
+
thinking: thinkingBudget > 0 ? { enabled: true, budgetTokens: thinkingBudget } : undefined,
|
|
130
|
+
onThinking: showThinking ? t => process.stderr.write(t) : undefined,
|
|
131
|
+
onUsage: (showUsage || showCost) ? (u) => {
|
|
132
|
+
if (showUsage) process.stderr.write('usage: ' + JSON.stringify(u) + '\n');
|
|
133
|
+
if (showCost && cfg.rates) {
|
|
134
|
+
const c = costFromUsage(
|
|
135
|
+
{ provider: flags.provider || cfg.provider, model: flags.model || cfg.model, usage: u },
|
|
136
|
+
cfg.rates,
|
|
137
|
+
);
|
|
138
|
+
if (c) process.stderr.write('cost: ' + JSON.stringify(c) + '\n');
|
|
139
|
+
}
|
|
140
|
+
} : undefined,
|
|
141
|
+
})) {
|
|
142
|
+
process.stdout.write(chunk);
|
|
143
|
+
}
|
|
144
|
+
process.stdout.write('\n');
|
|
145
|
+
} catch (err) {
|
|
146
|
+
process.stderr.write(`error: ${err?.message || String(err)}\n`);
|
|
147
|
+
process.exit(1);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Cursor-style ghost autocomplete for the chat prompt. When the
|
|
152
|
+
// current readline buffer starts with `/` and prefix-matches a known
|
|
153
|
+
// slash command, the rest of the command is rendered in dim grey
|
|
154
|
+
// after the cursor. Right-arrow at end-of-line accepts the suggestion
|
|
155
|
+
// (replaces rl.line with the full command). Tab still goes through
|
|
156
|
+
// readline's tab-completer for cycling.
|
|
157
|
+
export async function cmdTask(sub, positional, flags = {}) {
|
|
158
|
+
const tasksMod = await import('../tasks.mjs');
|
|
159
|
+
const teamsMod = await import('../teams.mjs');
|
|
160
|
+
const agentsMod = await import('../agents.mjs');
|
|
161
|
+
const cfgDir = path.dirname(configPath());
|
|
162
|
+
const idOrFirst = positional[0];
|
|
163
|
+
|
|
164
|
+
const emitJson = (obj) => process.stdout.write(JSON.stringify(obj, null, 2) + '\n');
|
|
165
|
+
|
|
166
|
+
// Open a thread root in Slack and return its ts (or '' if we deliberately
|
|
167
|
+
// skipped posting). Caller decides what to do with the ts.
|
|
168
|
+
const postKickoff = async ({ task, team, leadAgent }) => {
|
|
169
|
+
if (!task.slackChannel) {
|
|
170
|
+
process.stderr.write('[task] team has no slackChannel — skipping Slack post\n');
|
|
171
|
+
return '';
|
|
172
|
+
}
|
|
173
|
+
try { _loadDotenvIfAny(cfgDir); } catch { /* best-effort */ }
|
|
174
|
+
const { SlackChannel } = await import('../channels/slack.mjs');
|
|
175
|
+
const slack = new SlackChannel({ requireInbound: false });
|
|
176
|
+
try {
|
|
177
|
+
await slack.start(async () => '', {});
|
|
178
|
+
} catch (err) {
|
|
179
|
+
if (err?.code === 'SLACK_MISSING_ENV') {
|
|
180
|
+
throw new Error(`SLACK_BOT_TOKEN missing — set it in ${path.join(cfgDir, '.env')} or unset team.slackChannel`);
|
|
181
|
+
}
|
|
182
|
+
throw err;
|
|
183
|
+
}
|
|
184
|
+
try {
|
|
185
|
+
const text = tasksMod.buildKickoffMessage({
|
|
186
|
+
id: task.id,
|
|
187
|
+
title: task.title,
|
|
188
|
+
description: task.description,
|
|
189
|
+
leadDisplayName: leadAgent?.displayName || task.lead,
|
|
190
|
+
teamDisplayName: team.displayName || team.name,
|
|
191
|
+
});
|
|
192
|
+
const res = await slack.send(task.slackChannel, text);
|
|
193
|
+
return res?.ts || '';
|
|
194
|
+
} finally {
|
|
195
|
+
await slack.stop().catch(() => {});
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
switch (sub) {
|
|
200
|
+
case undefined:
|
|
201
|
+
case 'list': {
|
|
202
|
+
emitJson(tasksMod.listTasks(cfgDir));
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
case 'start': {
|
|
206
|
+
const teamName = flags.team;
|
|
207
|
+
const title = flags.title;
|
|
208
|
+
if (!teamName || !title) {
|
|
209
|
+
console.error('Usage: lazyclaw task start --team <team> --title "..." [--description "..."] [--lead <agent>]');
|
|
210
|
+
process.exit(2);
|
|
211
|
+
}
|
|
212
|
+
try {
|
|
213
|
+
const team = teamsMod.getTeam(teamName, cfgDir);
|
|
214
|
+
if (!team) { console.error(`task start: no team "${teamName}"`); process.exit(2); }
|
|
215
|
+
const leadName = flags.lead || team.lead;
|
|
216
|
+
const leadAgent = agentsMod.getAgent(leadName, cfgDir);
|
|
217
|
+
// Create the task record first (status=pending) so we can roll its
|
|
218
|
+
// id into the Slack message; then post and patch in the ts.
|
|
219
|
+
const seeded = tasksMod.registerTask({
|
|
220
|
+
title,
|
|
221
|
+
description: flags.description || '',
|
|
222
|
+
team: teamName,
|
|
223
|
+
lead: leadName,
|
|
224
|
+
slackChannel: team.slackChannel,
|
|
225
|
+
status: 'pending',
|
|
226
|
+
}, cfgDir);
|
|
227
|
+
let ts = '';
|
|
228
|
+
try {
|
|
229
|
+
ts = await postKickoff({ task: seeded, team, leadAgent });
|
|
230
|
+
} catch (err) {
|
|
231
|
+
// Rollback so we don't leave orphan task records when the post fails.
|
|
232
|
+
try { tasksMod.removeTask(seeded.id, cfgDir); } catch { /* best-effort */ }
|
|
233
|
+
console.error(`task start: ${err?.message || err}`);
|
|
234
|
+
process.exit(2);
|
|
235
|
+
}
|
|
236
|
+
const turns = ts ? [{ agent: 'system', text: `Task opened by user. Lead: ${leadName}.`, ts }] : [];
|
|
237
|
+
const finalTask = tasksMod.patchTask(seeded.id, {
|
|
238
|
+
slackThreadTs: ts,
|
|
239
|
+
status: ts ? 'running' : 'pending',
|
|
240
|
+
turns,
|
|
241
|
+
}, cfgDir);
|
|
242
|
+
emitJson(finalTask);
|
|
243
|
+
} catch (err) {
|
|
244
|
+
console.error(`task start: ${err?.message || err}`);
|
|
245
|
+
process.exit(2);
|
|
246
|
+
}
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
case 'show': {
|
|
250
|
+
if (!idOrFirst) { console.error('Usage: lazyclaw task show <id>'); process.exit(2); }
|
|
251
|
+
const t = tasksMod.getTask(idOrFirst, cfgDir);
|
|
252
|
+
if (!t) { console.error(`task show: no task "${idOrFirst}"`); process.exit(2); }
|
|
253
|
+
emitJson(t);
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
case 'tick': {
|
|
257
|
+
const id = idOrFirst;
|
|
258
|
+
const userMsg = positional.slice(1).join(' ').trim() || flags.message || '';
|
|
259
|
+
if (!id) { console.error('Usage: lazyclaw task tick <id> [<user message>]'); process.exit(2); }
|
|
260
|
+
const task = tasksMod.getTask(id, cfgDir);
|
|
261
|
+
if (!task) { console.error(`task tick: no task "${id}"`); process.exit(2); }
|
|
262
|
+
const team = teamsMod.getTeam(task.team, cfgDir);
|
|
263
|
+
if (!team) { console.error(`task tick: team "${task.team}" disappeared`); process.exit(2); }
|
|
264
|
+
// Load all team agents in one shot — the router needs to dispatch
|
|
265
|
+
// tool-use turns through each speaker's record.
|
|
266
|
+
const agentsById = {};
|
|
267
|
+
for (const name of team.agents) {
|
|
268
|
+
const rec = agentsMod.getAgent(name, cfgDir);
|
|
269
|
+
if (!rec) { console.error(`task tick: agent "${name}" disappeared`); process.exit(2); }
|
|
270
|
+
agentsById[name] = rec;
|
|
271
|
+
}
|
|
272
|
+
try { _loadDotenvIfAny(cfgDir); } catch { /* best-effort */ }
|
|
273
|
+
const router = await import('../mas/mention_router.mjs');
|
|
274
|
+
// The runner needs a real api key for the agent's provider. We
|
|
275
|
+
// resolve the LEAD's key here on the assumption that all team
|
|
276
|
+
// members share a provider (Phase 13 simplification); future
|
|
277
|
+
// phases will resolve per-agent.
|
|
278
|
+
const cfg = readConfig();
|
|
279
|
+
const leadAgent = agentsById[team.lead];
|
|
280
|
+
const apiKey = _resolveAuthKey(cfg, leadAgent.provider);
|
|
281
|
+
// Per-provider base-url override (tests point this at a local mock;
|
|
282
|
+
// production leaves it unset for the built-in default).
|
|
283
|
+
const baseUrl = _resolveBaseUrl(leadAgent.provider);
|
|
284
|
+
// --approve-url turns on remote human-in-the-loop approval for the
|
|
285
|
+
// sensitive tools (bash/write): each such call long-polls a running
|
|
286
|
+
// daemon's `POST /exec/request`, which broadcasts to paired devices
|
|
287
|
+
// over the gateway SSE and resolves when one approves. Fail-closed —
|
|
288
|
+
// any endpoint error denies the call. Omit the flag → ungated (the
|
|
289
|
+
// historical behavior).
|
|
290
|
+
let approve;
|
|
291
|
+
if (flags['approve-url']) {
|
|
292
|
+
const approveUrl = String(flags['approve-url']).replace(/\/$/, '');
|
|
293
|
+
const approveToken = flags['approve-token'] ? String(flags['approve-token']) : '';
|
|
294
|
+
const approveTimeoutMs = flags['approve-timeout'] ? parseInt(flags['approve-timeout'], 10) : 120000;
|
|
295
|
+
approve = async ({ tool, args, agent }) => {
|
|
296
|
+
const summary = `${tool}: ${typeof args === 'object' ? JSON.stringify(args) : String(args)}`.slice(0, 400);
|
|
297
|
+
try {
|
|
298
|
+
const r = await fetch(`${approveUrl}/exec/request`, {
|
|
299
|
+
method: 'POST',
|
|
300
|
+
headers: { 'content-type': 'application/json', ...(approveToken ? { authorization: `Bearer ${approveToken}` } : {}) },
|
|
301
|
+
body: JSON.stringify({ tool, agentId: agent, summary, timeoutMs: approveTimeoutMs }),
|
|
302
|
+
});
|
|
303
|
+
if (!r.ok) return { approved: false, reason: `approval endpoint HTTP ${r.status}` };
|
|
304
|
+
const j = await r.json();
|
|
305
|
+
return { approved: !!j.approved, reason: j.reason || (j.approved ? 'approved' : 'denied') };
|
|
306
|
+
} catch (err) {
|
|
307
|
+
return { approved: false, reason: `approval request failed: ${err?.message || err}` };
|
|
308
|
+
}
|
|
309
|
+
};
|
|
310
|
+
} else if (process.stdin.isTTY) {
|
|
311
|
+
// No --approve-url: default to an interactive y/N approval on the
|
|
312
|
+
// controlling terminal so sensitive tools are confirmed rather than
|
|
313
|
+
// run ungated. Non-TTY leaves approve undefined → the tool runner
|
|
314
|
+
// fails closed (deny) unless security.allowUnattendedSensitive is set.
|
|
315
|
+
const { makeReadlineApprove } = await import('../tui/terminal_approve.mjs');
|
|
316
|
+
approve = makeReadlineApprove();
|
|
317
|
+
}
|
|
318
|
+
try {
|
|
319
|
+
const result = await router.runTaskTurn({
|
|
320
|
+
task, team, agentsById,
|
|
321
|
+
userMessage: userMsg || undefined,
|
|
322
|
+
configDir: cfgDir,
|
|
323
|
+
apiKey,
|
|
324
|
+
baseUrl,
|
|
325
|
+
logger: (line) => process.stderr.write(line),
|
|
326
|
+
maxAgentTurns: flags['max-turns'] ? parseInt(flags['max-turns'], 10) : undefined,
|
|
327
|
+
approve,
|
|
328
|
+
security: cfg.security,
|
|
329
|
+
});
|
|
330
|
+
emitJson({ id: result.task.id, status: result.task.status, iterations: result.iterations, stoppedBy: result.stoppedBy });
|
|
331
|
+
} catch (err) {
|
|
332
|
+
console.error(`task tick: ${err?.message || err}`);
|
|
333
|
+
process.exit(2);
|
|
334
|
+
}
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
case 'abandon':
|
|
338
|
+
case 'done': {
|
|
339
|
+
if (!idOrFirst) { console.error(`Usage: lazyclaw task ${sub} <id>`); process.exit(2); }
|
|
340
|
+
const target = sub === 'done' ? 'done' : 'abandoned';
|
|
341
|
+
try {
|
|
342
|
+
const next = tasksMod.patchTask(idOrFirst, { status: target }, cfgDir);
|
|
343
|
+
// Best-effort closing post in the original thread so anyone in
|
|
344
|
+
// the channel sees the resolution. Errors are surfaced via stderr
|
|
345
|
+
// but do NOT roll back the status change.
|
|
346
|
+
if (next.slackChannel && next.slackThreadTs) {
|
|
347
|
+
try {
|
|
348
|
+
_loadDotenvIfAny(cfgDir);
|
|
349
|
+
const { SlackChannel } = await import('../channels/slack.mjs');
|
|
350
|
+
const slack = new SlackChannel({ requireInbound: false });
|
|
351
|
+
await slack.start(async () => '', {});
|
|
352
|
+
const threadId = `${next.slackChannel}:${next.slackThreadTs}`;
|
|
353
|
+
const msg = target === 'done'
|
|
354
|
+
? `:white_check_mark: Task *${next.title}* marked done.`
|
|
355
|
+
: `:no_entry: Task *${next.title}* abandoned.`;
|
|
356
|
+
await slack.send(threadId, msg);
|
|
357
|
+
await slack.stop().catch(() => {});
|
|
358
|
+
} catch (err) {
|
|
359
|
+
process.stderr.write(`[task] closing post failed: ${err?.message || err}\n`);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
emitJson(next);
|
|
363
|
+
} catch (err) {
|
|
364
|
+
console.error(`task ${sub}: ${err?.message || err}`);
|
|
365
|
+
process.exit(2);
|
|
366
|
+
}
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
case 'transcript': {
|
|
370
|
+
if (!idOrFirst) { console.error('Usage: lazyclaw task transcript <id> [--format text|md|json]'); process.exit(2); }
|
|
371
|
+
const t = tasksMod.getTask(idOrFirst, cfgDir);
|
|
372
|
+
if (!t) { console.error(`task transcript: no task "${idOrFirst}"`); process.exit(2); }
|
|
373
|
+
const fmt = String(flags.format || 'text');
|
|
374
|
+
if (fmt === 'json') { emitJson(t); return; }
|
|
375
|
+
process.stdout.write(tasksMod.formatTranscript(t, fmt));
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
case 'remove':
|
|
379
|
+
case 'rm':
|
|
380
|
+
case 'delete': {
|
|
381
|
+
if (!idOrFirst) { console.error('Usage: lazyclaw task remove <id>'); process.exit(2); }
|
|
382
|
+
try { emitJson(tasksMod.removeTask(idOrFirst, cfgDir)); }
|
|
383
|
+
catch (err) { console.error(`task remove: ${err?.message || err}`); process.exit(2); }
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
default:
|
|
387
|
+
console.error('Usage: lazyclaw task <start|tick|list|show|transcript|abandon|done|remove> ...');
|
|
388
|
+
process.exit(2);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
export async function cmdTeam(sub, positional, flags = {}) {
|
|
395
|
+
const teamsMod = await import('../teams.mjs');
|
|
396
|
+
const cfgDir = path.dirname(configPath());
|
|
397
|
+
const name = positional[0];
|
|
398
|
+
|
|
399
|
+
const emitJson = (obj) => process.stdout.write(JSON.stringify(obj, null, 2) + '\n');
|
|
400
|
+
const resolveChannel = async (raw) => {
|
|
401
|
+
if (!raw) return '';
|
|
402
|
+
// .env may have a SLACK_BOT_TOKEN we can use; otherwise pass through.
|
|
403
|
+
try { _loadDotenvIfAny(cfgDir); } catch { /* best-effort */ }
|
|
404
|
+
return await teamsMod.resolveSlackChannel(raw, {
|
|
405
|
+
botToken: process.env.SLACK_BOT_TOKEN || null,
|
|
406
|
+
apiBase: process.env.SLACK_API_BASE || 'https://slack.com/api',
|
|
407
|
+
logger: (line) => process.stderr.write(line),
|
|
408
|
+
});
|
|
409
|
+
};
|
|
410
|
+
|
|
411
|
+
switch (sub) {
|
|
412
|
+
case undefined:
|
|
413
|
+
case 'list': {
|
|
414
|
+
emitJson(teamsMod.listTeams(cfgDir));
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
case 'add': {
|
|
418
|
+
if (!name) { console.error('Usage: lazyclaw team add <name> --agents a,b,c [--lead X] [--channel #shop|Cxxx] [--display "..."]'); process.exit(2); }
|
|
419
|
+
const agents = teamsMod.parseListFlag(flags.agents) || [];
|
|
420
|
+
try {
|
|
421
|
+
const channel = await resolveChannel(flags.channel || '');
|
|
422
|
+
const t = teamsMod.registerTeam({
|
|
423
|
+
name,
|
|
424
|
+
displayName: flags.display || flags['display-name'],
|
|
425
|
+
agents,
|
|
426
|
+
lead: flags.lead || null,
|
|
427
|
+
slackChannel: channel,
|
|
428
|
+
}, cfgDir);
|
|
429
|
+
emitJson(t);
|
|
430
|
+
} catch (err) {
|
|
431
|
+
console.error(`team add: ${err?.message || err}`);
|
|
432
|
+
process.exit(2);
|
|
433
|
+
}
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
case 'show': {
|
|
437
|
+
if (!name) { console.error('Usage: lazyclaw team show <name>'); process.exit(2); }
|
|
438
|
+
const t = teamsMod.getTeam(name, cfgDir);
|
|
439
|
+
if (!t) { console.error(`team show: no team "${name}"`); process.exit(2); }
|
|
440
|
+
emitJson(t);
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
case 'edit': {
|
|
444
|
+
if (!name) { console.error('Usage: lazyclaw team edit <name> [--agents a,b,c] [--lead X] [--channel ...] [--display "..."]'); process.exit(2); }
|
|
445
|
+
const patch = {};
|
|
446
|
+
if (flags.display !== undefined) patch.displayName = String(flags.display);
|
|
447
|
+
if (flags['display-name'] !== undefined) patch.displayName = String(flags['display-name']);
|
|
448
|
+
if (flags.agents !== undefined) patch.agents = teamsMod.parseListFlag(flags.agents);
|
|
449
|
+
if (flags.lead !== undefined) patch.lead = String(flags.lead);
|
|
450
|
+
if (flags.channel !== undefined) patch.slackChannel = await resolveChannel(flags.channel);
|
|
451
|
+
if (Object.keys(patch).length === 0) {
|
|
452
|
+
console.error('team edit: no fields to update');
|
|
453
|
+
process.exit(2);
|
|
454
|
+
}
|
|
455
|
+
try { emitJson(teamsMod.patchTeam(name, patch, cfgDir)); }
|
|
456
|
+
catch (err) { console.error(`team edit: ${err?.message || err}`); process.exit(2); }
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
case 'remove':
|
|
460
|
+
case 'rm':
|
|
461
|
+
case 'delete': {
|
|
462
|
+
if (!name) { console.error('Usage: lazyclaw team remove <name>'); process.exit(2); }
|
|
463
|
+
try { emitJson(teamsMod.removeTeam(name, cfgDir)); }
|
|
464
|
+
catch (err) { console.error(`team remove: ${err?.message || err}`); process.exit(2); }
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
default:
|
|
468
|
+
console.error('Usage: lazyclaw team <add|list|show|edit|remove> ...');
|
|
469
|
+
process.exit(2);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
export async function cmdAgentRegistry(sub, positional, flags = {}) {
|
|
474
|
+
const agentsMod = await import('../agents.mjs');
|
|
475
|
+
const cfgDir = path.dirname(configPath());
|
|
476
|
+
const name = positional[0];
|
|
477
|
+
|
|
478
|
+
const emitJson = (obj) => process.stdout.write(JSON.stringify(obj, null, 2) + '\n');
|
|
479
|
+
|
|
480
|
+
switch (sub) {
|
|
481
|
+
case undefined:
|
|
482
|
+
case 'list': {
|
|
483
|
+
emitJson(agentsMod.listAgents(cfgDir));
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
case 'add': {
|
|
487
|
+
if (!name) { console.error('Usage: lazyclaw agent add <name> [--role "..."] [--provider X] [--model Y] [--display "..."] [--tools bash,read,write,grep,skill_view] [--tags a,b] [--skill-write auto|manual|off]'); process.exit(2); }
|
|
488
|
+
const tools = agentsMod.parseToolsFlag(flags.tools);
|
|
489
|
+
try {
|
|
490
|
+
const a = agentsMod.registerAgent({
|
|
491
|
+
name,
|
|
492
|
+
displayName: flags.display || flags['display-name'],
|
|
493
|
+
role: flags.role || '',
|
|
494
|
+
provider: flags.provider || 'claude-cli',
|
|
495
|
+
model: flags.model || '',
|
|
496
|
+
tools: tools === null ? undefined : tools,
|
|
497
|
+
tags: agentsMod.parseToolsFlag(flags.tags) || [],
|
|
498
|
+
skillWrite: flags['skill-write'],
|
|
499
|
+
}, cfgDir);
|
|
500
|
+
emitJson(a);
|
|
501
|
+
} catch (err) {
|
|
502
|
+
console.error(`agent add: ${err?.message || err}`);
|
|
503
|
+
process.exit(2);
|
|
504
|
+
}
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
case 'show': {
|
|
508
|
+
if (!name) { console.error('Usage: lazyclaw agent show <name>'); process.exit(2); }
|
|
509
|
+
const a = agentsMod.getAgent(name, cfgDir);
|
|
510
|
+
if (!a) { console.error(`agent show: no agent "${name}"`); process.exit(2); }
|
|
511
|
+
emitJson(a);
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
case 'edit': {
|
|
515
|
+
if (!name) { console.error('Usage: lazyclaw agent edit <name> [--role "..."] [--provider X] [--model Y] [--display "..."] [--tools ...] [--skill-write auto|manual|off] [--memory-write auto|manual|off]'); process.exit(2); }
|
|
516
|
+
const patch = {};
|
|
517
|
+
if (flags.role !== undefined) patch.role = String(flags.role);
|
|
518
|
+
if (flags.provider !== undefined) patch.provider = String(flags.provider);
|
|
519
|
+
if (flags.model !== undefined) patch.model = String(flags.model);
|
|
520
|
+
if (flags.display !== undefined) patch.displayName = String(flags.display);
|
|
521
|
+
if (flags['display-name'] !== undefined) patch.displayName = String(flags['display-name']);
|
|
522
|
+
if (flags.tools !== undefined) patch.tools = agentsMod.parseToolsFlag(flags.tools);
|
|
523
|
+
if (flags.tags !== undefined) patch.tags = agentsMod.parseToolsFlag(flags.tags);
|
|
524
|
+
if (flags['skill-write'] !== undefined) patch.skillWrite = String(flags['skill-write']);
|
|
525
|
+
if (flags['memory-write'] !== undefined) patch.memoryWrite = String(flags['memory-write']);
|
|
526
|
+
if (Object.keys(patch).length === 0) {
|
|
527
|
+
console.error('agent edit: no fields to update');
|
|
528
|
+
process.exit(2);
|
|
529
|
+
}
|
|
530
|
+
try { emitJson(agentsMod.patchAgent(name, patch, cfgDir)); }
|
|
531
|
+
catch (err) { console.error(`agent edit: ${err?.message || err}`); process.exit(2); }
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
case 'remove':
|
|
535
|
+
case 'rm':
|
|
536
|
+
case 'delete': {
|
|
537
|
+
if (!name) { console.error('Usage: lazyclaw agent remove <name>'); process.exit(2); }
|
|
538
|
+
try { emitJson(agentsMod.removeAgent(name, cfgDir)); }
|
|
539
|
+
catch (err) { console.error(`agent remove: ${err?.message || err}`); process.exit(2); }
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
case 'memory': {
|
|
543
|
+
// memory <show|edit|clear> <name>
|
|
544
|
+
const op = positional[0];
|
|
545
|
+
const memName = positional[1];
|
|
546
|
+
if (!op || !memName) {
|
|
547
|
+
console.error('Usage: lazyclaw agent memory <show|edit|clear> <name>');
|
|
548
|
+
process.exit(2);
|
|
549
|
+
}
|
|
550
|
+
const memMod = await import('../mas/agent_memory.mjs');
|
|
551
|
+
try {
|
|
552
|
+
if (op === 'show') {
|
|
553
|
+
const max = Number.isFinite(+flags['max-chars']) && +flags['max-chars'] > 0 ? +flags['max-chars'] : memMod.DEFAULT_MAX_CHARS;
|
|
554
|
+
const text = memMod.readMemory(memName, cfgDir, max);
|
|
555
|
+
if (!text) process.stderr.write(`(no memory for "${memName}")\n`);
|
|
556
|
+
else process.stdout.write(text + (text.endsWith('\n') ? '' : '\n'));
|
|
557
|
+
} else if (op === 'edit') {
|
|
558
|
+
const p = memMod.memoryPath(memName, cfgDir);
|
|
559
|
+
// Ensure file exists so $EDITOR doesn't start with a missing
|
|
560
|
+
// file warning.
|
|
561
|
+
if (!fs.existsSync(p)) {
|
|
562
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
563
|
+
fs.writeFileSync(p, `# ${memName} — memory\n\n`);
|
|
564
|
+
}
|
|
565
|
+
const editor = process.env.EDITOR || 'vi';
|
|
566
|
+
const { spawn } = await import('node:child_process');
|
|
567
|
+
await new Promise((resolve) => {
|
|
568
|
+
const ch = spawn(editor, [p], { stdio: 'inherit' });
|
|
569
|
+
ch.on('close', () => resolve());
|
|
570
|
+
});
|
|
571
|
+
process.stdout.write(`edited ${p}\n`);
|
|
572
|
+
} else if (op === 'clear') {
|
|
573
|
+
const removed = memMod.clear(memName, cfgDir);
|
|
574
|
+
process.stdout.write(removed ? `cleared memory for "${memName}"\n` : `(no memory for "${memName}")\n`);
|
|
575
|
+
} else {
|
|
576
|
+
console.error(`Usage: lazyclaw agent memory <show|edit|clear> <name>`);
|
|
577
|
+
process.exit(2);
|
|
578
|
+
}
|
|
579
|
+
} catch (err) {
|
|
580
|
+
console.error(`agent memory ${op}: ${err?.message || err}`);
|
|
581
|
+
process.exit(2);
|
|
582
|
+
}
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
case 'reflect': {
|
|
586
|
+
const aname = positional[0];
|
|
587
|
+
const taskId = flags.task || positional[1];
|
|
588
|
+
if (!aname || !taskId) {
|
|
589
|
+
console.error('Usage: lazyclaw agent reflect <name> --task <id>');
|
|
590
|
+
process.exit(2);
|
|
591
|
+
}
|
|
592
|
+
const tasksMod = await import('../tasks.mjs');
|
|
593
|
+
const memMod = await import('../mas/agent_memory.mjs');
|
|
594
|
+
const a = agentsMod.getAgent(aname, cfgDir);
|
|
595
|
+
if (!a) { console.error(`agent reflect: no agent "${aname}"`); process.exit(2); }
|
|
596
|
+
const task = tasksMod.getTask(taskId, cfgDir);
|
|
597
|
+
if (!task) { console.error(`agent reflect: no task "${taskId}"`); process.exit(2); }
|
|
598
|
+
try { _loadDotenvIfAny(cfgDir); } catch { /* best-effort */ }
|
|
599
|
+
const cfg = readConfig();
|
|
600
|
+
const apiKey = _resolveAuthKey(cfg, a.provider);
|
|
601
|
+
const baseUrl = _resolveBaseUrl(a.provider);
|
|
602
|
+
try {
|
|
603
|
+
const body = await memMod.reflectOnce({ agent: a, task, apiKey, baseUrl });
|
|
604
|
+
if (!body || !body.trim()) {
|
|
605
|
+
process.stderr.write('reflection returned empty body — nothing to write\n');
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
if (!flags['dry-run']) {
|
|
609
|
+
memMod.prependEntry(aname, { taskId: task.id, title: task.title, body }, cfgDir);
|
|
610
|
+
}
|
|
611
|
+
process.stdout.write(body + (body.endsWith('\n') ? '' : '\n'));
|
|
612
|
+
} catch (err) {
|
|
613
|
+
console.error(`agent reflect: ${err?.message || err}`);
|
|
614
|
+
process.exit(2);
|
|
615
|
+
}
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
case 'skill-synth': {
|
|
619
|
+
const aname = positional[0];
|
|
620
|
+
const taskId = flags.task || positional[1];
|
|
621
|
+
if (!aname || !taskId) {
|
|
622
|
+
console.error('Usage: lazyclaw agent skill-synth <name> --task <id> [--dry-run]');
|
|
623
|
+
process.exit(2);
|
|
624
|
+
}
|
|
625
|
+
const tasksMod = await import('../tasks.mjs');
|
|
626
|
+
const synthMod = await import('../mas/skill_synth.mjs');
|
|
627
|
+
const skillsMod = await import('../skills.mjs');
|
|
628
|
+
const a = agentsMod.getAgent(aname, cfgDir);
|
|
629
|
+
if (!a) { console.error(`agent skill-synth: no agent "${aname}"`); process.exit(2); }
|
|
630
|
+
const task = tasksMod.getTask(taskId, cfgDir);
|
|
631
|
+
if (!task) { console.error(`agent skill-synth: no task "${taskId}"`); process.exit(2); }
|
|
632
|
+
try { _loadDotenvIfAny(cfgDir); } catch { /* best-effort */ }
|
|
633
|
+
const cfg = readConfig();
|
|
634
|
+
const apiKey = _resolveAuthKey(cfg, a.provider);
|
|
635
|
+
const baseUrl = _resolveBaseUrl(a.provider);
|
|
636
|
+
try {
|
|
637
|
+
const result = await synthMod.synthesizeSkill({ agent: a, task, apiKey, baseUrl });
|
|
638
|
+
if (!result) {
|
|
639
|
+
process.stderr.write('skill synthesis produced nothing worth saving\n');
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
if (!flags['dry-run']) {
|
|
643
|
+
// installSynthesized reserves a collision-free name (never
|
|
644
|
+
// clobbers a human-authored skill) and version-bumps when it
|
|
645
|
+
// improves its own prior skill.
|
|
646
|
+
const installed = synthMod.installSynthesized(
|
|
647
|
+
{ name: result.name, description: result.description, body: result.body, sourceTask: task.id },
|
|
648
|
+
cfgDir,
|
|
649
|
+
);
|
|
650
|
+
emitJson({ skill: installed.skill, description: result.description, version: installed.version, path: installed.path });
|
|
651
|
+
} else {
|
|
652
|
+
process.stdout.write(result.doc + (result.doc.endsWith('\n') ? '' : '\n'));
|
|
653
|
+
}
|
|
654
|
+
} catch (err) {
|
|
655
|
+
console.error(`agent skill-synth: ${err?.message || err}`);
|
|
656
|
+
process.exit(2);
|
|
657
|
+
}
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
default:
|
|
661
|
+
console.error('Usage: lazyclaw agent <add|list|show|edit|remove|memory|reflect|skill-synth> ...');
|
|
662
|
+
process.exit(2);
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// Best-effort .env loader for ~/.lazyclaw/.env. Only sets keys that are
|
|
667
|
+
// not already present in process.env (so a shell-level export wins).
|
|
668
|
+
// Lines starting with '#' are comments; values are taken verbatim and
|
|
669
|
+
// stripped of surrounding double-quotes if present.
|