lazyclaw 3.99.28 → 4.2.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/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
- > Part of the [LazyClaude](https://github.com/cmblir/LazyClaude) projectpublished standalone so you can `npm i -g lazyclaw` without cloning the dashboard.
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/LazyClaude](https://github.com/cmblir/LazyClaude) under [`src/lazyclaw/`](https://github.com/cmblir/LazyClaude/tree/main/src/lazyclaw). Issues and PRs welcome on the parent repo.
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
+ }