lazyclaw 3.99.27 → 4.2.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 +128 -2
- package/agents.mjs +179 -0
- package/channels/base.mjs +120 -0
- package/channels/http.mjs +54 -0
- package/channels/slack.mjs +386 -0
- package/channels/stub.mjs +52 -0
- package/cli.mjs +1636 -77
- package/daemon.mjs +171 -0
- package/docs/multi-agent.md +256 -0
- package/goals.mjs +128 -0
- package/loop-engine.mjs +182 -0
- package/loops.mjs +135 -0
- package/mas/agent_memory.mjs +188 -0
- package/mas/agent_turn.mjs +141 -0
- package/mas/audit.mjs +62 -0
- package/mas/mention_router.mjs +360 -0
- package/mas/tool_runner.mjs +87 -0
- package/mas/tools/bash.mjs +78 -0
- package/mas/tools/grep.mjs +91 -0
- package/mas/tools/read.mjs +45 -0
- package/mas/tools/write.mjs +42 -0
- package/memory.mjs +193 -0
- package/package.json +26 -6
- package/providers/registry.mjs +8 -1
- package/providers/tool_use/anthropic.mjs +151 -0
- package/providers/tool_use/gemini.mjs +189 -0
- package/providers/tool_use/openai.mjs +140 -0
- package/scripts/loop-worker.mjs +160 -0
- package/sessions.mjs +5 -0
- package/tasks.mjs +220 -0
- package/teams.mjs +199 -0
- package/web/dashboard.html +166 -0
package/README.md
CHANGED
|
@@ -8,7 +8,11 @@
|
|
|
8
8
|
|
|
9
9
|
One Node CLI that talks to every major LLM provider, runs multi-step workflows as a DAG, exposes a local HTTP gateway, and ships with the niceties you actually want at the prompt: an ASCII banner on launch, Cursor-style slash-command ghost autocomplete (right-arrow accepts), persistent chat sessions, and cost rate cards.
|
|
10
10
|
|
|
11
|
-
>
|
|
11
|
+
> Standalone CLI. A companion dashboard, [LazyClaude](https://github.com/cmblir/LazyClaude), wraps the same providers in a web UI — but `lazyclaw` needs nothing from it: `npm i -g lazyclaw` and go.
|
|
12
|
+
|
|
13
|
+
Every subcommand at a glance — `lazyclaw --help`:
|
|
14
|
+
|
|
15
|
+
<img src="docs/screenshots/help.png" alt="lazyclaw --help — full subcommand reference" width="760">
|
|
12
16
|
|
|
13
17
|
---
|
|
14
18
|
|
|
@@ -31,6 +35,10 @@ lazyclaw doctor # validate config + provider registry
|
|
|
31
35
|
|
|
32
36
|
`onboard` writes `~/.lazyclaw/config.json`. Move it with `LAZYCLAW_CONFIG_DIR=/elsewhere`. For automation: `--non-interactive --provider X --model Y [--api-key Z]`.
|
|
33
37
|
|
|
38
|
+
<img src="docs/screenshots/onboard.png" alt="lazyclaw onboard --non-interactive — writes config.json, prints JSON result" width="760">
|
|
39
|
+
|
|
40
|
+
<img src="docs/screenshots/doctor.png" alt="lazyclaw doctor — config + provider registry health check" width="760">
|
|
41
|
+
|
|
34
42
|
### Subscription mode (no API key)
|
|
35
43
|
|
|
36
44
|
If you already have **Claude Code** installed and signed in (Pro / Max / Team subscription), pick the **`claude-cli`** provider during onboard. lazyclaw shells out to the local `claude` binary, so requests bill against your existing subscription quota instead of pay-per-token API credit. No `sk-ant-` key needed.
|
|
@@ -184,6 +192,21 @@ Slash commands inside the REPL:
|
|
|
184
192
|
| `/model` | Open the per-provider model picker (type-filter + live `/v1/models` fetch) |
|
|
185
193
|
| `/model X` | Switch model directly. Accepts unified `provider/model` form |
|
|
186
194
|
| `/skill a,b` | Replace the system prompt with a composition of named skills |
|
|
195
|
+
| `/loop "<prompt>" [--max N] [--until "<regex>"]` | Repeat one prompt N times (default 3, cap 50). `--until` short-circuits when the regex matches. Ctrl-C aborts the loop. |
|
|
196
|
+
| `/loop "..." --use-memory --recall "<query>"` | Inject `~/.lazyclaw/memory/core.md` and the top-3 matching episodic/recent fragments into the system slot per iteration |
|
|
197
|
+
| `/goal` | List active goals |
|
|
198
|
+
| `/goal <name>` | Switch the chat to the goal's session (subsequent turns persist to `goal:<name>.jsonl`) |
|
|
199
|
+
| `/goal add <name> [--desc "..."] [--cron "<spec>"]` | Register a persistent goal; `--cron` schedules `lazyclaw goal tick <name>` |
|
|
200
|
+
| `/goal close <name> [done\|abandoned]` | Close the goal and uninstall its cron entry |
|
|
201
|
+
| `/memory [core\|recent\|episodic [topic]]` | Show layered memory contents |
|
|
202
|
+
| `/dream` | Consolidate `recent.jsonl` into per-topic `episodic/<topic>.md` files |
|
|
203
|
+
| `/agent` / `/agent list` | List registered multi-agent agents |
|
|
204
|
+
| `/agent show <name>` | Print the agent's JSON record |
|
|
205
|
+
| `/agent add <name> [role text…]` | Register an agent with the default tool whitelist `[bash, read, write, grep]` |
|
|
206
|
+
| `/agent remove <name>` | Delete the agent's record |
|
|
207
|
+
| `/team` / `/team list` | List teams + lead + members + Slack channel |
|
|
208
|
+
| `/team add <name> --agents a,b,c [--lead a] [--channel #x]` | Create a team |
|
|
209
|
+
| `/team remove <name>` | Delete the team |
|
|
187
210
|
| `/usage` | Message count + chars + cumulative token totals |
|
|
188
211
|
| `/new` / `/reset` | Wipe history and start over |
|
|
189
212
|
| `/exit` | Leave the chat REPL (returns to the launcher when chat was opened from it) |
|
|
@@ -201,6 +224,107 @@ lazyclaw agent "..." --usage # token counts on stderr
|
|
|
201
224
|
lazyclaw agent "..." --cost # USD when rates configured
|
|
202
225
|
```
|
|
203
226
|
|
|
227
|
+
## Loops and goals (durable agents)
|
|
228
|
+
|
|
229
|
+
```bash
|
|
230
|
+
# Repeat one prompt N times against the active provider. Foreground
|
|
231
|
+
# blocks the terminal; --detach forks a worker and prints {loopId,
|
|
232
|
+
# pid, statePath}. Worker state under ~/.lazyclaw/loops/<id>/.
|
|
233
|
+
lazyclaw loop "fix the failing tests" --max 5 --until "DONE"
|
|
234
|
+
lazyclaw loop "ship checklist" --max 10 --detach --session daily
|
|
235
|
+
lazyclaw loops list
|
|
236
|
+
lazyclaw loops show <loopId>
|
|
237
|
+
lazyclaw loops tail <loopId>
|
|
238
|
+
lazyclaw loops kill <loopId> # SIGTERM; repeat within 5s for SIGKILL
|
|
239
|
+
|
|
240
|
+
# Goals: persistent objectives with optional cron schedule + channel fan-out.
|
|
241
|
+
lazyclaw goal add ship-v4 --desc "Ship v4" --cron "0 9 * * 1-5"
|
|
242
|
+
lazyclaw goal list
|
|
243
|
+
lazyclaw goal tick ship-v4 --force
|
|
244
|
+
lazyclaw goal channel add ship-v4 slack:#deploys
|
|
245
|
+
lazyclaw goal close ship-v4 done # also uninstalls the cron entry
|
|
246
|
+
|
|
247
|
+
# Memory: ~/.lazyclaw/memory/{core.md,recent.jsonl,episodic/*.md}
|
|
248
|
+
lazyclaw memory show core
|
|
249
|
+
lazyclaw memory show recent
|
|
250
|
+
lazyclaw memory dream # consolidate recent → episodic files
|
|
251
|
+
lazyclaw memory edit core # open $EDITOR
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
For Slack fan-out, set `SLACK_BOT_TOKEN` (xoxb-...) in `~/.lazyclaw/.env`.
|
|
255
|
+
Tokens never appear in goal records or logs. Socket Mode inbound also
|
|
256
|
+
needs `SLACK_APP_TOKEN` (xapp-...) and `SLACK_SIGNING_SECRET`.
|
|
257
|
+
|
|
258
|
+
## Multi-agent Slack teams (v4.1)
|
|
259
|
+
|
|
260
|
+
Drive a small team of named agents through a single Slack thread. The
|
|
261
|
+
lead agent receives the user's request, decides who else on the team
|
|
262
|
+
should weigh in, `@mentions` them, and the router runs each mentioned
|
|
263
|
+
agent in turn through the full tool-use loop (bash / read / write /
|
|
264
|
+
grep) before handing control back. The thread terminates when the lead
|
|
265
|
+
emits the literal marker `[[TASK_DONE]]` or the per-task iteration
|
|
266
|
+
budget runs out. Each agent's reply is mirrored into the Slack thread
|
|
267
|
+
under its own persona (`chat:write.customize` makes the username + icon
|
|
268
|
+
match the agent in Slack's UI).
|
|
269
|
+
|
|
270
|
+
```bash
|
|
271
|
+
# 1) Register agents — system prompt + provider + per-agent tool whitelist
|
|
272
|
+
lazyclaw agent add planner --role "Project planner" --provider anthropic --model claude-opus-4-7
|
|
273
|
+
lazyclaw agent add backend --role "Backend engineer" --provider anthropic --model claude-opus-4-7
|
|
274
|
+
lazyclaw agent add frontend --role "Frontend engineer" --provider openai --model gpt-4.1
|
|
275
|
+
lazyclaw agent list
|
|
276
|
+
|
|
277
|
+
# 2) Group them into a team that talks in a specific Slack channel
|
|
278
|
+
lazyclaw team add shop --agents planner,backend,frontend --lead planner --channel '#shop'
|
|
279
|
+
|
|
280
|
+
# 3) Open a task — posts a root message into the team's channel, returns
|
|
281
|
+
# the task id and the Slack thread_ts.
|
|
282
|
+
lazyclaw task start --team shop --title "ship checkout flow" --description "MVP scope"
|
|
283
|
+
|
|
284
|
+
# 4) Drive one user turn through the mention router. The lead replies,
|
|
285
|
+
# @mentions teammates, they run tool-use loops, hand back to the lead.
|
|
286
|
+
lazyclaw task tick t_20260518_xxxxxx "go" --max-turns 12
|
|
287
|
+
|
|
288
|
+
# 5) Inspect the conversation (text, markdown, or raw JSON)
|
|
289
|
+
lazyclaw task transcript t_20260518_xxxxxx --format md > thread.md
|
|
290
|
+
lazyclaw task show t_20260518_xxxxxx
|
|
291
|
+
lazyclaw task done t_20260518_xxxxxx # or `abandon` — also posts a closing message
|
|
292
|
+
|
|
293
|
+
# 6) Agent memory carries lessons forward across tasks (v4.2). The
|
|
294
|
+
# router auto-fires a ≤6-bullet reflection per participating agent
|
|
295
|
+
# when a task transitions to done. Manual control:
|
|
296
|
+
lazyclaw agent memory show planner
|
|
297
|
+
lazyclaw agent memory edit planner # opens $EDITOR
|
|
298
|
+
lazyclaw agent memory clear planner
|
|
299
|
+
lazyclaw agent reflect planner --task t_20260518_xxxxxx
|
|
300
|
+
# Flip auto reflection off per agent: edit ~/.lazyclaw/agents/<name>.json
|
|
301
|
+
# and set "memoryWrite": "off" (other values: "auto" default, "manual").
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
Slack inbound (a user pings `@lazyclaw` in a channel, the bot replies)
|
|
305
|
+
runs through the Socket Mode listener:
|
|
306
|
+
|
|
307
|
+
```bash
|
|
308
|
+
lazyclaw slack listen # foreground; connects, reacts with :eyes:, replies in thread
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
The CLI is mirrored by daemon HTTP routes (`GET/POST/PATCH/DELETE
|
|
312
|
+
/agents|teams|tasks`, `GET /tasks/<id>/transcript`) and by the
|
|
313
|
+
browser dashboard's Agents / Teams / Tasks tabs:
|
|
314
|
+
|
|
315
|
+
```bash
|
|
316
|
+
lazyclaw dashboard # opens http://127.0.0.1:<port>/ in the default browser
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
Slack app prerequisites — bot token scopes `app_mentions:read`,
|
|
320
|
+
`chat:write`, `chat:write.customize`, `im:history`, `im:read`,
|
|
321
|
+
`im:write`, `channels:history`, `reactions:write`; Socket Mode enabled;
|
|
322
|
+
app token scope `connections:write`; invite the bot into every team
|
|
323
|
+
channel. Tokens live exclusively in `~/.lazyclaw/.env` and never appear
|
|
324
|
+
in agent/team/task records or logs.
|
|
325
|
+
|
|
326
|
+
For the full data model + phase plan, see `docs/multi-agent.md`.
|
|
327
|
+
|
|
204
328
|
## Providers / sessions / skills
|
|
205
329
|
|
|
206
330
|
```bash
|
|
@@ -220,6 +344,8 @@ lazyclaw skills install ./my-skill.md
|
|
|
220
344
|
lazyclaw skills remove review
|
|
221
345
|
```
|
|
222
346
|
|
|
347
|
+
<img src="docs/screenshots/providers.png" alt="lazyclaw providers info anthropic — model list + capabilities" width="760">
|
|
348
|
+
|
|
223
349
|
## Workflows (DAG / sequential / persistent)
|
|
224
350
|
|
|
225
351
|
```bash
|
|
@@ -295,7 +421,7 @@ lazyclaw completion zsh >> ~/.zshrc
|
|
|
295
421
|
|
|
296
422
|
## Issues / contributing
|
|
297
423
|
|
|
298
|
-
Source lives in [cmblir/
|
|
424
|
+
Source lives in [cmblir/lazyclaw](https://github.com/cmblir/lazyclaw). Issues and PRs welcome.
|
|
299
425
|
|
|
300
426
|
## License
|
|
301
427
|
|
package/agents.mjs
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// Persistent agent registry for `/agent` REPL command and `lazyclaw agent`
|
|
2
|
+
// subcommand. Backs the Phase 9 piece of docs/multi-agent.md.
|
|
3
|
+
//
|
|
4
|
+
// Storage layout under <configDir>/agents/<name>.json. One file per
|
|
5
|
+
// agent so concurrent edit / remove writes don't race over a global
|
|
6
|
+
// index. Schema field set lives in `defaultShape()` below; new fields
|
|
7
|
+
// default to null/empty so reading an older record stays
|
|
8
|
+
// forward-compatible.
|
|
9
|
+
//
|
|
10
|
+
// Defaults reflect §10 of the multi-agent spec: a freshly created
|
|
11
|
+
// agent gets the full tool whitelist (bash + read + write + grep)
|
|
12
|
+
// because the user opted for "lazyclaw 모든 권한". Callers that want a
|
|
13
|
+
// stricter posture can pass an explicit `tools` array.
|
|
14
|
+
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import os from 'node:os';
|
|
18
|
+
import { ensureValidName as cronEnsureValidName } from './cron.mjs';
|
|
19
|
+
|
|
20
|
+
const AGENTS_DIRNAME = 'agents';
|
|
21
|
+
|
|
22
|
+
export const DEFAULT_TOOLS = ['bash', 'read', 'write', 'grep'];
|
|
23
|
+
export const ALL_TOOLS = ['bash', 'read', 'write', 'grep', 'web_search', 'web_fetch', 'slack_post'];
|
|
24
|
+
|
|
25
|
+
export class AgentError extends Error {
|
|
26
|
+
constructor(message, code) {
|
|
27
|
+
super(message);
|
|
28
|
+
this.name = 'AgentError';
|
|
29
|
+
this.code = code || 'AGENT_ERR';
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function defaultConfigDir() {
|
|
34
|
+
return process.env.LAZYCLAW_CONFIG_DIR || path.join(os.homedir(), '.lazyclaw');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function agentsDir(configDir = defaultConfigDir()) {
|
|
38
|
+
return path.join(configDir, AGENTS_DIRNAME);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function agentPath(name, configDir = defaultConfigDir()) {
|
|
42
|
+
ensureValidName(name);
|
|
43
|
+
return path.join(agentsDir(configDir), `${name}.json`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function ensureValidName(name) {
|
|
47
|
+
try { cronEnsureValidName(name); }
|
|
48
|
+
catch (e) { throw new AgentError(e.message, 'AGENT_BAD_NAME'); }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function validateTools(tools) {
|
|
52
|
+
if (!Array.isArray(tools)) {
|
|
53
|
+
throw new AgentError('tools must be an array', 'AGENT_BAD_TOOLS');
|
|
54
|
+
}
|
|
55
|
+
const bad = tools.filter(t => !ALL_TOOLS.includes(t));
|
|
56
|
+
if (bad.length) {
|
|
57
|
+
throw new AgentError(`unknown tool(s): ${bad.join(', ')} — known: ${ALL_TOOLS.join(', ')}`, 'AGENT_BAD_TOOLS');
|
|
58
|
+
}
|
|
59
|
+
// Dedupe while preserving order.
|
|
60
|
+
return [...new Set(tools)];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function titleCase(s) {
|
|
64
|
+
return String(s).split(/[-_]/).map(p => p.charAt(0).toUpperCase() + p.slice(1)).join(' ');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function defaultShape(name) {
|
|
68
|
+
return {
|
|
69
|
+
version: 1,
|
|
70
|
+
name,
|
|
71
|
+
displayName: titleCase(name),
|
|
72
|
+
role: '',
|
|
73
|
+
provider: 'claude-cli',
|
|
74
|
+
model: '',
|
|
75
|
+
tools: [...DEFAULT_TOOLS],
|
|
76
|
+
tags: [],
|
|
77
|
+
iconEmoji: '',
|
|
78
|
+
// Phase 18 — agent memory write trigger. 'auto' means the router
|
|
79
|
+
// fires a reflection LLM call on terminal `done`; 'manual' waits
|
|
80
|
+
// for `lazyclaw agent reflect`; 'off' disables writes entirely.
|
|
81
|
+
memoryWrite: 'auto',
|
|
82
|
+
memoryMaxChars: 12 * 1024,
|
|
83
|
+
createdAt: new Date().toISOString(),
|
|
84
|
+
updatedAt: new Date().toISOString(),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function writeAtomic(filePath, obj) {
|
|
89
|
+
const dir = path.dirname(filePath);
|
|
90
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
91
|
+
const tmp = filePath + '.tmp';
|
|
92
|
+
fs.writeFileSync(tmp, JSON.stringify(obj, null, 2));
|
|
93
|
+
fs.renameSync(tmp, filePath);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const VALID_MEMORY_WRITE = ['auto', 'manual', 'off'];
|
|
97
|
+
|
|
98
|
+
export function registerAgent({ name, displayName, role = '', provider = 'claude-cli', model = '', tools, tags = [], iconEmoji = '', memoryWrite, memoryMaxChars } = {}, configDir = defaultConfigDir()) {
|
|
99
|
+
ensureValidName(name);
|
|
100
|
+
const p = agentPath(name, configDir);
|
|
101
|
+
if (fs.existsSync(p)) {
|
|
102
|
+
throw new AgentError(`agent "${name}" already exists`, 'AGENT_EXISTS');
|
|
103
|
+
}
|
|
104
|
+
const toolsClean = validateTools(tools ?? DEFAULT_TOOLS);
|
|
105
|
+
const mw = memoryWrite ?? 'auto';
|
|
106
|
+
if (!VALID_MEMORY_WRITE.includes(mw)) {
|
|
107
|
+
throw new AgentError(`memoryWrite must be one of ${VALID_MEMORY_WRITE.join(', ')}`, 'AGENT_BAD_MEMORY_WRITE');
|
|
108
|
+
}
|
|
109
|
+
const data = {
|
|
110
|
+
...defaultShape(name),
|
|
111
|
+
displayName: displayName || titleCase(name),
|
|
112
|
+
role: String(role || ''),
|
|
113
|
+
provider: String(provider || 'claude-cli'),
|
|
114
|
+
model: String(model || ''),
|
|
115
|
+
tools: toolsClean,
|
|
116
|
+
tags: Array.isArray(tags) ? tags : [],
|
|
117
|
+
iconEmoji: String(iconEmoji || ''),
|
|
118
|
+
memoryWrite: mw,
|
|
119
|
+
memoryMaxChars: Number.isFinite(+memoryMaxChars) && +memoryMaxChars > 0 ? +memoryMaxChars : 12 * 1024,
|
|
120
|
+
};
|
|
121
|
+
writeAtomic(p, data);
|
|
122
|
+
return data;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function getAgent(name, configDir = defaultConfigDir()) {
|
|
126
|
+
let p;
|
|
127
|
+
try { p = agentPath(name, configDir); }
|
|
128
|
+
catch { return null; }
|
|
129
|
+
if (!fs.existsSync(p)) return null;
|
|
130
|
+
try { return JSON.parse(fs.readFileSync(p, 'utf8')); }
|
|
131
|
+
catch { return null; }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function listAgents(configDir = defaultConfigDir()) {
|
|
135
|
+
const dir = agentsDir(configDir);
|
|
136
|
+
if (!fs.existsSync(dir)) return [];
|
|
137
|
+
const out = [];
|
|
138
|
+
for (const f of fs.readdirSync(dir)) {
|
|
139
|
+
if (!f.endsWith('.json')) continue;
|
|
140
|
+
const name = f.slice(0, -5);
|
|
141
|
+
const a = getAgent(name, configDir);
|
|
142
|
+
if (a) out.push(a);
|
|
143
|
+
}
|
|
144
|
+
out.sort((a, b) => String(a.name).localeCompare(String(b.name)));
|
|
145
|
+
return out;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function patchAgent(name, patch, configDir = defaultConfigDir()) {
|
|
149
|
+
const a = getAgent(name, configDir);
|
|
150
|
+
if (!a) throw new AgentError(`no agent "${name}"`, 'AGENT_NO_AGENT');
|
|
151
|
+
const next = { ...a, ...patch, updatedAt: new Date().toISOString() };
|
|
152
|
+
if (patch.tools !== undefined) {
|
|
153
|
+
next.tools = validateTools(patch.tools);
|
|
154
|
+
}
|
|
155
|
+
if (patch.memoryWrite !== undefined && !VALID_MEMORY_WRITE.includes(patch.memoryWrite)) {
|
|
156
|
+
throw new AgentError(`memoryWrite must be one of ${VALID_MEMORY_WRITE.join(', ')}`, 'AGENT_BAD_MEMORY_WRITE');
|
|
157
|
+
}
|
|
158
|
+
writeAtomic(agentPath(name, configDir), next);
|
|
159
|
+
return next;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function removeAgent(name, configDir = defaultConfigDir()) {
|
|
163
|
+
const p = agentPath(name, configDir);
|
|
164
|
+
if (!fs.existsSync(p)) {
|
|
165
|
+
throw new AgentError(`no agent "${name}"`, 'AGENT_NO_AGENT');
|
|
166
|
+
}
|
|
167
|
+
fs.unlinkSync(p);
|
|
168
|
+
return { name, removed: true };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Parse a comma-separated tool list from CLI flag form. Empty / null
|
|
172
|
+
// input returns null so the caller can decide between "user didn't say"
|
|
173
|
+
// (→ keep existing or default) and "user said []" (→ disallow all).
|
|
174
|
+
export function parseToolsFlag(raw) {
|
|
175
|
+
if (raw === undefined || raw === null) return null;
|
|
176
|
+
const s = String(raw).trim();
|
|
177
|
+
if (s === '') return [];
|
|
178
|
+
return s.split(',').map(t => t.trim()).filter(Boolean);
|
|
179
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// Abstract base for a daemon channel.
|
|
2
|
+
//
|
|
3
|
+
// A Channel owns a transport (HTTP, Slack Socket Mode, in-memory stub, …).
|
|
4
|
+
// It calls `handler({ channel, threadId, text })` once per inbound message
|
|
5
|
+
// and emits the resolved reply through `send(threadId, text)`.
|
|
6
|
+
//
|
|
7
|
+
// Cross-cutting concerns (auth, rate-limit, allowed-origin, audit) live
|
|
8
|
+
// outside the channel itself: when a Channel wants to apply them, it
|
|
9
|
+
// calls `applyGate(req)` first. Concrete subclasses pass the gate object
|
|
10
|
+
// down at start time so every channel runs the same middleware chain
|
|
11
|
+
// that today's HTTP daemon enforces on `POST /agent`.
|
|
12
|
+
|
|
13
|
+
export class Channel {
|
|
14
|
+
constructor(name) {
|
|
15
|
+
this.name = String(name || 'unnamed');
|
|
16
|
+
/** @type {((evt: { channel: string, threadId: string, text: string }) => Promise<string>) | null} */
|
|
17
|
+
this._handler = null;
|
|
18
|
+
/** @type {{ check: (req: { token?: string|null, key?: string|null }) => { ok: boolean, reason?: string } } | null} */
|
|
19
|
+
this._gate = null;
|
|
20
|
+
this._started = false;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Begin accepting messages. The concrete subclass starts its transport
|
|
25
|
+
* here (e.g. createServer + listen for HTTP, Socket Mode for Slack,
|
|
26
|
+
* nothing for the stub). Must be safe to call once per instance.
|
|
27
|
+
*
|
|
28
|
+
* @param {(evt: { channel: string, threadId: string, text: string }) => Promise<string>} handler
|
|
29
|
+
* @param {{ gate?: { check: (req: any) => { ok: boolean, reason?: string } } }} [opts]
|
|
30
|
+
*/
|
|
31
|
+
async start(handler, opts = {}) {
|
|
32
|
+
if (this._started) throw new Error(`channel "${this.name}" already started`);
|
|
33
|
+
this._handler = handler;
|
|
34
|
+
this._gate = opts.gate || null;
|
|
35
|
+
this._started = true;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Deliver a reply to the caller identified by threadId. The shape of
|
|
40
|
+
* threadId is channel-specific (a Slack channel/thread, a stub inbox
|
|
41
|
+
* key, an HTTP request id). Subclasses override.
|
|
42
|
+
*
|
|
43
|
+
* @param {string} threadId
|
|
44
|
+
* @param {string} text
|
|
45
|
+
*/
|
|
46
|
+
async send(_threadId, _text) {
|
|
47
|
+
throw new Error(`${this.constructor.name}.send() not implemented`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Tear down the transport. Must be idempotent — channel managers call
|
|
52
|
+
* this on every channel during shutdown without first checking whether
|
|
53
|
+
* the channel ever started.
|
|
54
|
+
*/
|
|
55
|
+
async stop() {
|
|
56
|
+
this._started = false;
|
|
57
|
+
this._handler = null;
|
|
58
|
+
this._gate = null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Subclasses call this from their inbound paths before invoking the
|
|
63
|
+
* handler. Returns the handler's reply on success, throws ChannelGated
|
|
64
|
+
* on auth/rate-limit denial.
|
|
65
|
+
*/
|
|
66
|
+
async _processInbound({ threadId, text, gateInput }) {
|
|
67
|
+
if (this._gate) {
|
|
68
|
+
const verdict = this._gate.check(gateInput || {});
|
|
69
|
+
if (!verdict.ok) {
|
|
70
|
+
const err = new Error(verdict.reason || 'denied');
|
|
71
|
+
err.code = 'CHANNEL_GATED';
|
|
72
|
+
throw err;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (!this._handler) throw new Error(`channel "${this.name}" has no handler`);
|
|
76
|
+
return await this._handler({ channel: this.name, threadId, text });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export class ChannelGated extends Error {
|
|
81
|
+
constructor(message, code) {
|
|
82
|
+
super(message);
|
|
83
|
+
this.name = 'ChannelGated';
|
|
84
|
+
this.code = code || 'CHANNEL_GATED';
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Tiny token-bucket gate used by stub + slack channels. The HTTP channel
|
|
89
|
+
// continues to use daemon.mjs's in-tree limiter so the regression path
|
|
90
|
+
// is byte-identical.
|
|
91
|
+
export function makeBucketGate({ authToken = null, rateLimit = null } = {}) {
|
|
92
|
+
const limiter = rateLimit
|
|
93
|
+
? makeTokenBucket(rateLimit.capacity ?? 20, rateLimit.refillPerSec ?? 1)
|
|
94
|
+
: null;
|
|
95
|
+
return {
|
|
96
|
+
check(req) {
|
|
97
|
+
if (authToken) {
|
|
98
|
+
const presented = req.token || req.key || null;
|
|
99
|
+
if (!presented || presented !== authToken) return { ok: false, reason: 'unauthorized' };
|
|
100
|
+
}
|
|
101
|
+
if (limiter && !limiter.take(1)) return { ok: false, reason: 'rate_limited' };
|
|
102
|
+
return { ok: true };
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function makeTokenBucket(capacity, refillPerSec) {
|
|
108
|
+
let tokens = capacity;
|
|
109
|
+
let last = Date.now();
|
|
110
|
+
return {
|
|
111
|
+
take(n) {
|
|
112
|
+
const now = Date.now();
|
|
113
|
+
const elapsed = (now - last) / 1000;
|
|
114
|
+
tokens = Math.min(capacity, tokens + elapsed * refillPerSec);
|
|
115
|
+
last = now;
|
|
116
|
+
if (tokens >= n) { tokens -= n; return true; }
|
|
117
|
+
return false;
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// HTTP channel adapter.
|
|
2
|
+
//
|
|
3
|
+
// Phase 7 introduces a Channel abstraction but does NOT relocate the
|
|
4
|
+
// existing daemon's HTTP routing — that surface is large (POST /agent,
|
|
5
|
+
// /chat, /sessions, /workflows, /skills, the dashboard SPA …) and a
|
|
6
|
+
// byte-identical refactor is high-risk. Instead, this adapter wraps
|
|
7
|
+
// daemon.mjs.startDaemon so callers that want a uniform Channel handle
|
|
8
|
+
// (start/send/stop) get one, while the regression path (`lazyclaw
|
|
9
|
+
// daemon` with no --channels flag) stays untouched.
|
|
10
|
+
//
|
|
11
|
+
// `send(threadId, text)` is a no-op for HTTP: the daemon's response is
|
|
12
|
+
// streamed back synchronously via SSE on the original request, not via
|
|
13
|
+
// a separate push. The method exists for interface conformance.
|
|
14
|
+
|
|
15
|
+
import { Channel } from './base.mjs';
|
|
16
|
+
|
|
17
|
+
export class HttpChannel extends Channel {
|
|
18
|
+
constructor(opts = {}) {
|
|
19
|
+
super('http');
|
|
20
|
+
this._opts = opts;
|
|
21
|
+
this._daemon = null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async start(handler, opts = {}) {
|
|
25
|
+
await super.start(handler, opts);
|
|
26
|
+
// Lazy import so a `--channels stub` setup doesn't pay the cost of
|
|
27
|
+
// pulling in the full daemon module.
|
|
28
|
+
const { startDaemon } = await import('../daemon.mjs');
|
|
29
|
+
this._daemon = await startDaemon({
|
|
30
|
+
...this._opts,
|
|
31
|
+
// The handler the daemon owns is unchanged — it talks to providers
|
|
32
|
+
// through ctx.readConfig/providersMod. We're just standing the
|
|
33
|
+
// existing daemon up under the channel interface so a future
|
|
34
|
+
// unified runtime can speak HTTP through the same `Channel` shape.
|
|
35
|
+
});
|
|
36
|
+
return this._daemon;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async send(_threadId, _text) {
|
|
40
|
+
// HTTP replies are streamed in-line on the original request handler
|
|
41
|
+
// (SSE chunks for POST /agent). Nothing to push.
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
get port() { return this._daemon?.port ?? this._opts.port ?? null; }
|
|
45
|
+
get url() { return this._daemon?.url ?? null; }
|
|
46
|
+
|
|
47
|
+
async stop() {
|
|
48
|
+
if (this._daemon && typeof this._daemon.close === 'function') {
|
|
49
|
+
await this._daemon.close();
|
|
50
|
+
}
|
|
51
|
+
this._daemon = null;
|
|
52
|
+
await super.stop();
|
|
53
|
+
}
|
|
54
|
+
}
|