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.
Files changed (47) hide show
  1. package/channels-discord/index.mjs +76 -0
  2. package/channels-discord/package.json +14 -0
  3. package/channels-email/index.mjs +109 -0
  4. package/channels-email/package.json +14 -0
  5. package/channels-signal/index.mjs +69 -0
  6. package/channels-signal/package.json +9 -0
  7. package/channels-voice/index.mjs +81 -0
  8. package/channels-voice/package.json +9 -0
  9. package/channels-whatsapp/index.mjs +70 -0
  10. package/channels-whatsapp/package.json +13 -0
  11. package/commands/agents.mjs +669 -0
  12. package/commands/auth_nodes.mjs +323 -0
  13. package/commands/automation.mjs +582 -0
  14. package/commands/channels.mjs +255 -0
  15. package/commands/chat.mjs +1217 -0
  16. package/commands/config.mjs +315 -0
  17. package/commands/daemon.mjs +260 -0
  18. package/commands/misc.mjs +128 -0
  19. package/commands/providers.mjs +511 -0
  20. package/commands/sessions.mjs +343 -0
  21. package/commands/setup.mjs +741 -0
  22. package/commands/skills.mjs +218 -0
  23. package/commands/workflow.mjs +661 -0
  24. package/daemon/lib/auth.mjs +58 -0
  25. package/daemon/lib/cost.mjs +30 -0
  26. package/daemon/lib/provider.mjs +69 -0
  27. package/daemon/lib/respond.mjs +83 -0
  28. package/daemon/route_table.mjs +96 -0
  29. package/daemon/routes/_deps.mjs +36 -0
  30. package/daemon/routes/config.mjs +99 -0
  31. package/daemon/routes/conversation.mjs +371 -0
  32. package/daemon/routes/meta.mjs +239 -0
  33. package/daemon/routes/ops.mjs +185 -0
  34. package/daemon/routes/providers.mjs +211 -0
  35. package/daemon/routes/rates.mjs +90 -0
  36. package/daemon/routes/registry.mjs +223 -0
  37. package/daemon/routes/sessions.mjs +213 -0
  38. package/daemon/routes/skills.mjs +260 -0
  39. package/daemon/routes/workflows.mjs +224 -0
  40. package/dotenv_min.mjs +23 -0
  41. package/first_run.mjs +15 -0
  42. package/goals_cron.mjs +37 -0
  43. package/lib/args.mjs +216 -0
  44. package/lib/config.mjs +113 -0
  45. package/lib/registry_boot.mjs +55 -0
  46. package/package.json +14 -1
  47. package/secure_write.mjs +46 -0
@@ -0,0 +1,260 @@
1
+ // Daemon route handlers (skills), extracted verbatim from makeHandler (D5).
2
+ // Each handler takes the per-request dispatch context `c` and returns the
3
+ // HTTP response. Bodies are unchanged; only the dispatch wrapper is new.
4
+ import { fs, nodePath, PROVIDERS, PROVIDER_INFO, maskApiKey, costFromUsage, RATE_CARD_SHAPE, composeSystemPrompt, listSkills, loadSkill, skillPath, installSkill, removeSkill, parseFrontmatter, skillsDefaultConfigDir, indexDb, skillSynth, sandboxListBackends, summarizeState, listWorkflowSessions, loadWorkflowState, aggregateNodeStats, validateConfig, validateRates, fileExists, readJson, readTextBody, writeJson, writeSseHead, writeSse, statusForProviderError, checkCostCap, accumulateMetricsFromCost, resolveProvider } from './_deps.mjs';
5
+
6
+ export async function skillsList(c) {
7
+ const { ctx, logger, metrics, gateway, costCap, cachedByName, gwConfigDir, nudgeSuggestionsRing, workflowStateDir, req, res, method, path, route, url, sessionMatch, providerMatch, providerTestMatch, sessionExportMatch, skillMatch, workflowMatch, configKeyMatch, ratesKeyMatch } = c;
8
+ // List installed skills with their first-line summary so a UI
9
+ // can render them without a follow-up read for each one.
10
+ // ?filter=<substr>&limit=<N> mirror the v3.33 CLI flags.
11
+ const cfgDir = ctx.sessionsDirGetter();
12
+ let items = listSkills(cfgDir);
13
+ const filter = url.searchParams.get('filter');
14
+ if (filter) {
15
+ const f = filter.toLowerCase();
16
+ items = items.filter(s => s.name.toLowerCase().includes(f));
17
+ }
18
+ const limitStr = url.searchParams.get('limit');
19
+ if (limitStr) {
20
+ const n = parseInt(limitStr, 10);
21
+ if (Number.isFinite(n) && n > 0) items = items.slice(0, n);
22
+ }
23
+ // v5: include frontmatter fields the dashboard renders as badges.
24
+ // listSkills() currently only surfaces name/summary/etc., so we
25
+ // re-parse the body once per skill to extract trained_by /
26
+ // confidence / cross_cli_tested / group. Cheap (markdown files,
27
+ // already cached by the OS).
28
+ const out = items.map((s) => {
29
+ let meta = {};
30
+ try {
31
+ const body = loadSkill(s.name, cfgDir);
32
+ meta = parseFrontmatter(body).meta || {};
33
+ } catch { /* unreadable → empty meta */ }
34
+ const xcli = meta.cross_cli_tested;
35
+ return {
36
+ name: s.name,
37
+ bytes: s.bytes,
38
+ summary: s.summary,
39
+ description: meta.description || s.description || '',
40
+ group: meta.group || '',
41
+ trained_by: meta.trained_by || (meta.created_by === 'agent' ? 'agent' : ''),
42
+ confidence: meta.confidence !== undefined && meta.confidence !== ''
43
+ ? Number(meta.confidence)
44
+ : null,
45
+ cross_cli_tested: xcli === 'true' || xcli === true ? true
46
+ : (Array.isArray(xcli) ? xcli : null),
47
+ version: meta.version || s.version || '',
48
+ created_by: meta.created_by || '',
49
+ };
50
+ });
51
+ return writeJson(res, 200, out);
52
+ }
53
+
54
+ export async function skillsSuggestions(c) {
55
+ const { ctx, logger, metrics, gateway, costCap, cachedByName, gwConfigDir, nudgeSuggestionsRing, workflowStateDir, req, res, method, path, route, url, sessionMatch, providerMatch, providerTestMatch, sessionExportMatch, skillMatch, workflowMatch, configKeyMatch, ratesKeyMatch } = c;
56
+ // Ring buffer of nudge.suggest_skill events. Dashboard polls this
57
+ // since the SSE bus is deferred to v5.1.
58
+ return writeJson(res, 200, { suggestions: nudgeSuggestionsRing.slice(0, 20) });
59
+ }
60
+
61
+ export async function skillsSynth(c) {
62
+ const { ctx, logger, metrics, gateway, costCap, cachedByName, gwConfigDir, nudgeSuggestionsRing, workflowStateDir, req, res, method, path, route, url, sessionMatch, providerMatch, providerTestMatch, sessionExportMatch, skillMatch, workflowMatch, configKeyMatch, ratesKeyMatch } = c;
63
+ // Body: { sessionId: '<id>' [, outcome] [, trainedBy] [, model] }
64
+ // Runs mas/skill_synth.synthesizeSkill against the named session.
65
+ // We assemble a minimal agent stub from cfg.provider/model and an
66
+ // empty role — the synth pipeline expects an agent object, but
67
+ // most callers will want "use my default provider".
68
+ let body;
69
+ try { body = await readJson(req); }
70
+ catch (e) { return writeJson(res, 400, { error: e?.message || String(e) }); }
71
+ const sessionId = body && String(body.sessionId || '').trim();
72
+ if (!sessionId) return writeJson(res, 400, { error: 'sessionId is required' });
73
+ const cfg = ctx.readConfig();
74
+ const provider = body.provider || cfg.provider;
75
+ const model = body.model || cfg.model;
76
+ if (!provider) return writeJson(res, 400, { error: 'no provider configured — set cfg.provider or pass body.provider' });
77
+ // Pull the session turns and build a minimal task shape.
78
+ let turns;
79
+ try { turns = ctx.sessionsMod.loadTurns(sessionId, gwConfigDir); }
80
+ catch (e) { return writeJson(res, 404, { error: `session not found: ${sessionId}` }); }
81
+ if (!Array.isArray(turns) || turns.length === 0) {
82
+ return writeJson(res, 400, { error: `session "${sessionId}" has no turns` });
83
+ }
84
+ const task = {
85
+ id: sessionId,
86
+ title: turns[0]?.content?.slice(0, 80) || sessionId,
87
+ turns: turns.map((t) => ({
88
+ agent: t.role === 'user' ? 'user' : (t.role === 'system' ? 'system' : 'assistant'),
89
+ text: String(t.content || ''),
90
+ })),
91
+ };
92
+ const agent = { provider, model, role: '' };
93
+ try {
94
+ const apiKey = cfg['api-key'] || null;
95
+ const result = await skillSynth.synthesizeSkill({
96
+ agent, task, apiKey,
97
+ outcome: body.outcome || 'done',
98
+ trainedBy: body.trainedBy || null,
99
+ trainedOnModel: model || null,
100
+ });
101
+ if (!result) return writeJson(res, 200, { ok: false, message: 'synth produced no skill (model returned NONE)' });
102
+ // Mirror the CLI synth flow: installSynthesized() handles slug
103
+ // reservation, agent-overwrite protection, and FTS5 mirror.
104
+ const install = skillSynth.installSynthesized({
105
+ name: result.name,
106
+ description: result.description,
107
+ body: result.body,
108
+ sourceTask: sessionId,
109
+ createdBy: 'agent',
110
+ }, gwConfigDir);
111
+ return writeJson(res, 200, {
112
+ ok: true,
113
+ name: install?.skill || result.name,
114
+ description: result.description,
115
+ path: install?.path || null,
116
+ });
117
+ } catch (e) {
118
+ return writeJson(res, 500, { error: e?.message || String(e), code: e?.code });
119
+ }
120
+ }
121
+
122
+ export async function skillsSearch(c) {
123
+ const { ctx, logger, metrics, gateway, costCap, cachedByName, gwConfigDir, nudgeSuggestionsRing, workflowStateDir, req, res, method, path, route, url, sessionMatch, providerMatch, providerTestMatch, sessionExportMatch, skillMatch, workflowMatch, configKeyMatch, ratesKeyMatch } = c;
124
+ // Mirror of `lazyclaw skills search`. ?q=<query> required;
125
+ // ?regex=true switches to regex mode. Returns
126
+ // { query, regex, matches: [{ name, bytes, matchCount, excerpt }] }
127
+ // — same shape the CLI prints. A dashboard skill picker can
128
+ // hit this endpoint instead of pulling every skill body and
129
+ // searching client-side.
130
+ const q = url.searchParams.get('q');
131
+ if (!q) return writeJson(res, 400, { error: 'missing q query parameter' });
132
+ const useRegex = url.searchParams.get('regex') === 'true';
133
+ let matcher;
134
+ if (useRegex) {
135
+ try { matcher = new RegExp(q, 'gi'); }
136
+ catch (e) { return writeJson(res, 400, { error: `invalid regex: ${e.message}` }); }
137
+ }
138
+ const cfgDir = ctx.sessionsDirGetter();
139
+ const items = listSkills(cfgDir);
140
+ const matches = [];
141
+ for (const s of items) {
142
+ let body;
143
+ try { body = loadSkill(s.name, cfgDir); } catch { continue; }
144
+ let matchCount = 0;
145
+ let firstExcerpt = null;
146
+ if (useRegex) {
147
+ for (const m of body.matchAll(matcher)) {
148
+ matchCount++;
149
+ if (firstExcerpt === null) {
150
+ const pos = m.index ?? 0;
151
+ const start = Math.max(0, pos - 40);
152
+ const end = Math.min(body.length, pos + m[0].length + 40);
153
+ firstExcerpt = (start > 0 ? '…' : '') + body.slice(start, end) + (end < body.length ? '…' : '');
154
+ }
155
+ }
156
+ } else {
157
+ const lower = body.toLowerCase();
158
+ const ql = q.toLowerCase();
159
+ let pos = 0;
160
+ while (true) {
161
+ const i = lower.indexOf(ql, pos);
162
+ if (i < 0) break;
163
+ matchCount++;
164
+ if (firstExcerpt === null) {
165
+ const start = Math.max(0, i - 40);
166
+ const end = Math.min(body.length, i + ql.length + 40);
167
+ firstExcerpt = (start > 0 ? '…' : '') + body.slice(start, end) + (end < body.length ? '…' : '');
168
+ }
169
+ pos = i + ql.length;
170
+ }
171
+ }
172
+ if (matchCount > 0) {
173
+ matches.push({ name: s.name, bytes: s.bytes, matchCount, excerpt: firstExcerpt });
174
+ }
175
+ }
176
+ return writeJson(res, 200, { query: q, regex: useRegex, matches });
177
+ }
178
+
179
+ export async function skillGet(c) {
180
+ const { ctx, logger, metrics, gateway, costCap, cachedByName, gwConfigDir, nudgeSuggestionsRing, workflowStateDir, req, res, method, path, route, url, sessionMatch, providerMatch, providerTestMatch, sessionExportMatch, skillMatch, workflowMatch, configKeyMatch, ratesKeyMatch } = c;
181
+ // GET /skills/<name> — full markdown body as text/markdown.
182
+ // 404 when the file is missing so the caller can branch.
183
+ // 400 when the name fails skillPath validation (path traversal,
184
+ // dotfile, etc.) — same protections as the CLI.
185
+ // m13 — decodeURIComponent before validation (see PUT below).
186
+ let name;
187
+ try { name = decodeURIComponent(skillMatch[1]); }
188
+ catch { return writeJson(res, 400, { error: 'malformed skill name' }); }
189
+ try {
190
+ const cfgDir = ctx.sessionsDirGetter();
191
+ const file = skillPath(name, cfgDir);
192
+ if (!(await fileExists(file))) return writeJson(res, 404, { error: 'skill not found', name });
193
+ const body = loadSkill(name, cfgDir);
194
+ res.writeHead(200, {
195
+ 'content-type': 'text/markdown; charset=utf-8',
196
+ 'content-length': Buffer.byteLength(body),
197
+ });
198
+ return res.end(body);
199
+ } catch (err) {
200
+ return writeJson(res, 400, { error: err?.message || String(err) });
201
+ }
202
+ }
203
+
204
+ export async function skillPut(c) {
205
+ const { ctx, logger, metrics, gateway, costCap, cachedByName, gwConfigDir, nudgeSuggestionsRing, workflowStateDir, req, res, method, path, route, url, sessionMatch, providerMatch, providerTestMatch, sessionExportMatch, skillMatch, workflowMatch, configKeyMatch, ratesKeyMatch } = c;
206
+ // PUT /skills/<name> body = markdown text
207
+ // 201 on first write, 200 on overwrite (caller can branch on
208
+ // the status if they care about idempotency vs newness).
209
+ // 400 on invalid name (skillPath validation) or oversize body.
210
+ // m13 — decodeURIComponent the segment before validation so
211
+ // a request like `PUT /skills/foo%2Fbar` is rejected as a path-
212
+ // separator (slash) rather than letting the literal-percent
213
+ // filename slip through.
214
+ let name;
215
+ try { name = decodeURIComponent(skillMatch[1]); }
216
+ catch { return writeJson(res, 400, { error: 'malformed skill name' }); }
217
+ const cfgDir = ctx.sessionsDirGetter();
218
+ let priorExists = false;
219
+ try {
220
+ // Validate name before reading the body so a bogus name fails
221
+ // fast and we don't waste bandwidth.
222
+ const file = skillPath(name, cfgDir);
223
+ priorExists = await fileExists(file);
224
+ } catch (err) {
225
+ return writeJson(res, 400, { error: err?.message || String(err) });
226
+ }
227
+ let body;
228
+ try { body = await readTextBody(req); }
229
+ catch (err) { return writeJson(res, 400, { error: err?.message || String(err) }); }
230
+ try {
231
+ const written = installSkill(name, body, cfgDir);
232
+ return writeJson(res, priorExists ? 200 : 201, {
233
+ ok: true, name, path: written, bytes: body.length, replaced: priorExists,
234
+ });
235
+ } catch (err) {
236
+ return writeJson(res, 400, { error: err?.message || String(err) });
237
+ }
238
+ }
239
+
240
+ export async function skillDelete(c) {
241
+ const { ctx, logger, metrics, gateway, costCap, cachedByName, gwConfigDir, nudgeSuggestionsRing, workflowStateDir, req, res, method, path, route, url, sessionMatch, providerMatch, providerTestMatch, sessionExportMatch, skillMatch, workflowMatch, configKeyMatch, ratesKeyMatch } = c;
242
+ // DELETE /skills/<name> idempotent: 200 whether the file
243
+ // existed or not, mirroring DELETE /sessions/<id>. The body
244
+ // reports `removed: true|false` so callers can branch when
245
+ // they care.
246
+ // m13 — decodeURIComponent before validation (see PUT below).
247
+ let name;
248
+ try { name = decodeURIComponent(skillMatch[1]); }
249
+ catch { return writeJson(res, 400, { error: 'malformed skill name' }); }
250
+ const cfgDir = ctx.sessionsDirGetter();
251
+ try {
252
+ const file = skillPath(name, cfgDir);
253
+ const existed = await fileExists(file);
254
+ removeSkill(name, cfgDir);
255
+ return writeJson(res, 200, { ok: true, name, removed: existed });
256
+ } catch (err) {
257
+ return writeJson(res, 400, { error: err?.message || String(err) });
258
+ }
259
+ }
260
+
@@ -0,0 +1,224 @@
1
+ // Daemon route handlers (workflows), extracted verbatim from makeHandler (D5).
2
+ // Each handler takes the per-request dispatch context `c` and returns the
3
+ // HTTP response. Bodies are unchanged; only the dispatch wrapper is new.
4
+ import { fs, nodePath, PROVIDERS, PROVIDER_INFO, maskApiKey, costFromUsage, RATE_CARD_SHAPE, composeSystemPrompt, listSkills, loadSkill, skillPath, installSkill, removeSkill, parseFrontmatter, skillsDefaultConfigDir, indexDb, skillSynth, sandboxListBackends, summarizeState, listWorkflowSessions, loadWorkflowState, aggregateNodeStats, validateConfig, validateRates, fileExists, readJson, readTextBody, writeJson, writeSseHead, writeSse, statusForProviderError, checkCostCap, accumulateMetricsFromCost, resolveProvider } from './_deps.mjs';
5
+
6
+ export async function workflowsAggregate(c) {
7
+ const { ctx, logger, metrics, gateway, costCap, cachedByName, gwConfigDir, nudgeSuggestionsRing, workflowStateDir, req, res, method, path, route, url, sessionMatch, providerMatch, providerTestMatch, sessionExportMatch, skillMatch, workflowMatch, configKeyMatch, ratesKeyMatch } = c;
8
+ // Mirror of CLI v3.48 `lazyclaw inspect --aggregate`. Per-node
9
+ // statistics across every persisted session in the state
10
+ // directory. Answers "which node tends to be slow / fail
11
+ // across all my runs?" — needs no workflow file, just state.
12
+ // ?filter=<substr> applies before aggregation (v3.50).
13
+ const stateDir = ctx.workflowStateDir();
14
+ const qFilter = url.searchParams.get('filter');
15
+ const qNode = url.searchParams.get('node');
16
+ try {
17
+ const stats = aggregateNodeStats(stateDir, { filter: qFilter });
18
+ // ?node=<id>: drill into one node's cross-session stats
19
+ // (mirrors CLI v3.52 --aggregate --node). 404 when the
20
+ // node never appeared in any session.
21
+ if (qNode) {
22
+ const nodeStat = stats.nodeStats[qNode];
23
+ if (!nodeStat) {
24
+ return writeJson(res, 404, {
25
+ error: 'node not found across sessions',
26
+ nodeId: qNode,
27
+ knownNodes: Object.keys(stats.nodeStats),
28
+ });
29
+ }
30
+ return writeJson(res, 200, {
31
+ dir: stateDir,
32
+ filter: qFilter || null,
33
+ sessionCount: stats.sessionCount,
34
+ nodeId: qNode,
35
+ ...nodeStat,
36
+ });
37
+ }
38
+ return writeJson(res, 200, { dir: stateDir, filter: qFilter || null, ...stats });
39
+ } catch (err) {
40
+ if (err?.code === 'ENOENT') {
41
+ return writeJson(res, 200, { dir: stateDir, filter: qFilter || null, sessionCount: 0, nodeStats: {} });
42
+ }
43
+ return writeJson(res, 500, { error: err?.message || String(err) });
44
+ }
45
+ }
46
+
47
+ export async function workflowsList(c) {
48
+ const { ctx, logger, metrics, gateway, costCap, cachedByName, gwConfigDir, nudgeSuggestionsRing, workflowStateDir, req, res, method, path, route, url, sessionMatch, providerMatch, providerTestMatch, sessionExportMatch, skillMatch, workflowMatch, configKeyMatch, ratesKeyMatch } = c;
49
+ // List every persisted workflow session in the configured
50
+ // state dir, newest activity first. Mirrors `lazyclaw inspect`
51
+ // (no-arg) exactly so a dashboard can use the same renderer
52
+ // for CLI and HTTP outputs. Per-node `nodes` map is omitted —
53
+ // call /workflows/<sessionId> for full detail.
54
+ //
55
+ // ?status=done|resumable|failed|running mirrors the CLI's
56
+ // --status flag — one shared predicate so a UI can paginate
57
+ // by bucket without pulling the full list.
58
+ const stateDir = ctx.workflowStateDir();
59
+ const qStatus = url.searchParams.get('status');
60
+ if (qStatus) {
61
+ const valid = new Set(['done', 'resumable', 'failed', 'running']);
62
+ if (!valid.has(qStatus)) {
63
+ return writeJson(res, 400, {
64
+ error: `invalid status: ${qStatus}`,
65
+ expected: [...valid],
66
+ });
67
+ }
68
+ }
69
+ try {
70
+ let sessions = listWorkflowSessions(stateDir);
71
+ if (qStatus) {
72
+ sessions = sessions.filter(s => {
73
+ if (qStatus === 'done') return s.summary.done;
74
+ if (qStatus === 'resumable') return s.summary.resumable;
75
+ if (qStatus === 'failed') return s.summary.failed > 0;
76
+ if (qStatus === 'running') return s.summary.running > 0;
77
+ return true;
78
+ });
79
+ }
80
+ // ?filter=<substr>&limit=<N> mirror v3.33 sessions/skills list flags.
81
+ const qFilter = url.searchParams.get('filter');
82
+ if (qFilter) {
83
+ const f = qFilter.toLowerCase();
84
+ sessions = sessions.filter(s => s.sessionId.toLowerCase().includes(f));
85
+ }
86
+ const qLimit = url.searchParams.get('limit');
87
+ if (qLimit) {
88
+ const n = parseInt(qLimit, 10);
89
+ if (Number.isFinite(n) && n > 0) sessions = sessions.slice(0, n);
90
+ }
91
+ return writeJson(res, 200, { dir: stateDir, status: qStatus || null, sessions });
92
+ } catch (err) {
93
+ if (err?.code === 'ENOENT') {
94
+ // Empty dir is a valid state (no workflows ever ran). The
95
+ // CLI distinguishes "missing dir" from "empty dir" — the
96
+ // daemon collapses both to an empty array so a fresh
97
+ // process doesn't 404 a UI poll loop.
98
+ return writeJson(res, 200, { dir: stateDir, status: qStatus || null, sessions: [] });
99
+ }
100
+ return writeJson(res, 500, { error: err?.message || String(err) });
101
+ }
102
+ }
103
+
104
+ export async function workflowGet(c) {
105
+ const { ctx, logger, metrics, gateway, costCap, cachedByName, gwConfigDir, nudgeSuggestionsRing, workflowStateDir, req, res, method, path, route, url, sessionMatch, providerMatch, providerTestMatch, sessionExportMatch, skillMatch, workflowMatch, configKeyMatch, ratesKeyMatch } = c;
106
+ // GET /workflows/<sessionId> — full state of a single
107
+ // workflow run. Same shape as `lazyclaw inspect <id>` (the
108
+ // engine's persisted object plus a derived summary block).
109
+ // 404 when the state file is missing.
110
+ const sid = workflowMatch[1];
111
+ const stateDir = ctx.workflowStateDir();
112
+ let state;
113
+ try {
114
+ state = loadWorkflowState(sid, stateDir);
115
+ } catch (err) {
116
+ return writeJson(res, 500, { error: err?.message || String(err) });
117
+ }
118
+ if (!state) return writeJson(res, 404, { error: 'workflow not found', sessionId: sid });
119
+ // ?node=<id> drills into one node's state — same shape as
120
+ // `lazyclaw inspect <session> --node <id>` (v3.41). The
121
+ // HTTP status reflects the node's lifecycle (mirrors the
122
+ // CLI exit codes): 200 success/pending/running, 410 Gone
123
+ // for failed (request was valid, but the resource is in a
124
+ // failed state), 404 for unknown node id.
125
+ // ?slowest=<N>: top N nodes by durationMs. Same shape as
126
+ // CLI v3.44 — pure state-file analysis, no deps needed.
127
+ const qSlowest = url.searchParams.get('slowest');
128
+ if (qSlowest) {
129
+ const n = parseInt(qSlowest, 10);
130
+ if (!Number.isFinite(n) || n <= 0) {
131
+ return writeJson(res, 400, {
132
+ error: `slowest must be a positive integer (got ${JSON.stringify(qSlowest)})`,
133
+ });
134
+ }
135
+ const entries = Object.entries(state.nodes || {}).map(([id, ns]) => ({
136
+ id,
137
+ status: ns?.status || 'pending',
138
+ durationMs: Number.isFinite(ns?.durationMs) ? ns.durationMs : 0,
139
+ attempts: ns?.attempts ?? 0,
140
+ }));
141
+ entries.sort((a, b) => (b.durationMs - a.durationMs) || a.id.localeCompare(b.id));
142
+ return writeJson(res, 200, {
143
+ sessionId: state.sessionId,
144
+ top: entries.slice(0, n),
145
+ });
146
+ }
147
+ const qNode = url.searchParams.get('node');
148
+ if (qNode) {
149
+ const ns = state.nodes?.[qNode];
150
+ if (!ns) {
151
+ return writeJson(res, 404, {
152
+ error: 'node not found',
153
+ sessionId: sid,
154
+ nodeId: qNode,
155
+ knownNodes: Object.keys(state.nodes || {}),
156
+ });
157
+ }
158
+ return writeJson(res, ns.status === 'failed' ? 410 : 200, {
159
+ sessionId: state.sessionId,
160
+ nodeId: qNode,
161
+ ...ns,
162
+ });
163
+ }
164
+ const { summary, failedNodes } = summarizeState(state);
165
+ // ?summary=true trims the per-node `nodes` map and `order`
166
+ // array, matching v3.17's CLI `inspect --summary` shape and
167
+ // the per-session shape that list-mode produces. A UI fetching
168
+ // this endpoint to render a status badge doesn't want the
169
+ // full per-node payload — `?summary=true` keeps the wire
170
+ // small for high-frequency polls.
171
+ const compact = url.searchParams.get('summary') === 'true';
172
+ const body = compact
173
+ ? {
174
+ sessionId: state.sessionId,
175
+ dir: stateDir,
176
+ summary,
177
+ failedNodes,
178
+ startedAt: state.startedAt,
179
+ updatedAt: state.updatedAt,
180
+ }
181
+ : {
182
+ sessionId: state.sessionId,
183
+ dir: stateDir,
184
+ summary,
185
+ failedNodes,
186
+ order: state.order,
187
+ nodes: state.nodes,
188
+ startedAt: state.startedAt,
189
+ updatedAt: state.updatedAt,
190
+ };
191
+ return writeJson(res, 200, body);
192
+ }
193
+
194
+ export async function workflowDelete(c) {
195
+ const { ctx, logger, metrics, gateway, costCap, cachedByName, gwConfigDir, nudgeSuggestionsRing, workflowStateDir, req, res, method, path, route, url, sessionMatch, providerMatch, providerTestMatch, sessionExportMatch, skillMatch, workflowMatch, configKeyMatch, ratesKeyMatch } = c;
196
+ // DELETE /workflows/<sessionId> — idempotent: 200 with
197
+ // `removed: true|false`. Same protection as the rest of the
198
+ // delete routes — only files inside the configured state dir
199
+ // are touched. The path matcher already rejects `..` and `/`,
200
+ // and we re-resolve via path.join so a sessionId that resolves
201
+ // outside the dir is rejected with 400.
202
+ const sid = workflowMatch[1];
203
+ const stateDir = ctx.workflowStateDir();
204
+ // Note: `path` is shadowed inside this handler by the URL path
205
+ // variable above — use `nodePath` (aliased import) for fs ops.
206
+ const file = nodePath.join(stateDir, `${sid}.json`);
207
+ // Confined-path check: file must resolve under stateDir. fs.realpathSync
208
+ // would resolve symlinks too, but the dir may not exist yet — use
209
+ // the resolved string-prefix check, which is enough since stateDir
210
+ // is operator-controlled.
211
+ const resolvedDir = nodePath.resolve(stateDir);
212
+ const resolvedFile = nodePath.resolve(file);
213
+ if (!resolvedFile.startsWith(resolvedDir + nodePath.sep) && resolvedFile !== resolvedDir) {
214
+ return writeJson(res, 400, { error: 'invalid sessionId' });
215
+ }
216
+ try {
217
+ const existed = fs.existsSync(resolvedFile);
218
+ if (existed) fs.unlinkSync(resolvedFile);
219
+ return writeJson(res, 200, { ok: true, sessionId: sid, removed: existed });
220
+ } catch (err) {
221
+ return writeJson(res, 500, { error: err?.message || String(err) });
222
+ }
223
+ }
224
+
package/dotenv_min.mjs ADDED
@@ -0,0 +1,23 @@
1
+ // dotenv_min.mjs — minimal .env loader shared by the CLI and the Ink slash
2
+ // dispatcher. Loads <cfgDir>/.env into process.env (without overwriting
3
+ // already-set vars) so Slack / provider tokens are available. Extracted from
4
+ // cli.mjs `_loadDotenvIfAny` so the /task Slack-close flow can reuse it.
5
+
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+
9
+ export function loadDotenvIfAny(cfgDir) {
10
+ const p = path.join(cfgDir, '.env');
11
+ if (!fs.existsSync(p)) return { path: p, loaded: 0 };
12
+ let loaded = 0;
13
+ const raw = fs.readFileSync(p, 'utf8');
14
+ for (const line of raw.split(/\r?\n/)) {
15
+ if (!line || line.trimStart().startsWith('#')) continue;
16
+ const m = line.match(/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.*)$/);
17
+ if (!m) continue;
18
+ let val = m[2].trim();
19
+ if (val.length >= 2 && val.startsWith('"') && val.endsWith('"')) val = val.slice(1, -1);
20
+ if (process.env[m[1]] === undefined) { process.env[m[1]] = val; loaded++; }
21
+ }
22
+ return { path: p, loaded };
23
+ }
package/first_run.mjs ADDED
@@ -0,0 +1,15 @@
1
+ // first_run.mjs — decide what onboarding the chat entrypoint runs.
2
+ //
3
+ // 'setup' — genuine fresh install (no provider, interactive): run the full
4
+ // 5-step guided setup (provider+model, workspace, skills) instead
5
+ // of only the provider picker the Ink path used to run.
6
+ // 'pick' — explicit `chat --pick`: just re-pick provider/model.
7
+ // 'none' — provider already configured, or non-interactive (automation).
8
+ //
9
+ // Pure so it unit-tests without a TTY.
10
+ export function firstRunMode({ hasProvider, flagPick, isTTY }) {
11
+ if (!isTTY) return 'none';
12
+ if (flagPick) return 'pick';
13
+ if (!hasProvider) return 'setup';
14
+ return 'none';
15
+ }
package/goals_cron.mjs ADDED
@@ -0,0 +1,37 @@
1
+ // goals_cron.mjs — attach/detach a cron schedule to a goal.
2
+ //
3
+ // Extracted from cli.mjs (_attachGoalCron / _detachGoalCron) so the Ink
4
+ // /goal slash can actually schedule (and unschedule) a goal instead of just
5
+ // recording the cron string and telling the user to re-run from the CLI.
6
+ // Dependency-injected on readConfig / writeConfig and the cron module so the
7
+ // core unit-tests with a fake cron and no real launchd/crontab writes.
8
+ // `LAZYCLAW_SKIP_CRON_INSTALL` skips the backend install (config still written).
9
+
10
+ export async function attachGoalCron({ readConfig, writeConfig, cron, name, schedule }) {
11
+ cron.parseCronSpec(schedule); // validate before touching state
12
+ const cfg = readConfig();
13
+ const jobName = `goal-${name}`;
14
+ const cmd = ['lazyclaw', 'goal', 'tick', name];
15
+ cron.upsertJob(cfg, jobName, schedule, cmd);
16
+ writeConfig(cfg);
17
+ if (process.env.LAZYCLAW_SKIP_CRON_INSTALL) return { jobName, skipped: true };
18
+ const backend = cron.pickBackend();
19
+ if (backend === 'launchd') cron.installLaunchdJob(jobName, schedule, cmd);
20
+ else cron.installCrontabJob(jobName, schedule, cmd);
21
+ return { jobName, skipped: false };
22
+ }
23
+
24
+ export async function detachGoalCron({ readConfig, writeConfig, cron, name }) {
25
+ const cfg = readConfig();
26
+ const jobName = `goal-${name}`;
27
+ if (!cfg.cron || !cfg.cron[jobName]) return false;
28
+ cron.removeJob(cfg, jobName);
29
+ writeConfig(cfg);
30
+ if (process.env.LAZYCLAW_SKIP_CRON_INSTALL) return true;
31
+ const backend = cron.pickBackend();
32
+ try {
33
+ if (backend === 'launchd') cron.uninstallLaunchdJob(jobName);
34
+ else cron.uninstallCrontabJob(jobName);
35
+ } catch { /* best-effort — cron sync recovers */ }
36
+ return true;
37
+ }