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/tasks.mjs ADDED
@@ -0,0 +1,220 @@
1
+ // Persistent task registry for `/task` REPL command and `lazyclaw task`
2
+ // subcommand. Backs the Phase 11 piece of docs/multi-agent.md.
3
+ //
4
+ // One file per task under <configDir>/tasks/<id>.json. Tasks are the
5
+ // unit of work: a title, a description, an owning team, a lead, and a
6
+ // (channel, threadTs) pair pointing at the Slack thread that hosts the
7
+ // conversation. The `turns` array grows over time as agents take turns
8
+ // in the thread; Phase 11 only seeds it with the kickoff turn, Phases
9
+ // 13+ extend it.
10
+
11
+ import fs from 'node:fs';
12
+ import path from 'node:path';
13
+ import os from 'node:os';
14
+ import crypto from 'node:crypto';
15
+ import { getTeam } from './teams.mjs';
16
+
17
+ const TASKS_DIRNAME = 'tasks';
18
+ export const VALID_STATUSES = ['pending', 'running', 'done', 'failed', 'abandoned'];
19
+
20
+ export class TaskError extends Error {
21
+ constructor(message, code) {
22
+ super(message);
23
+ this.name = 'TaskError';
24
+ this.code = code || 'TASK_ERR';
25
+ }
26
+ }
27
+
28
+ export function defaultConfigDir() {
29
+ return process.env.LAZYCLAW_CONFIG_DIR || path.join(os.homedir(), '.lazyclaw');
30
+ }
31
+
32
+ export function tasksDir(configDir = defaultConfigDir()) {
33
+ return path.join(configDir, TASKS_DIRNAME);
34
+ }
35
+
36
+ export function taskPath(id, configDir = defaultConfigDir()) {
37
+ if (!isValidTaskId(id)) throw new TaskError(`bad task id "${id}"`, 'TASK_BAD_ID');
38
+ return path.join(tasksDir(configDir), `${id}.json`);
39
+ }
40
+
41
+ // Task IDs are short, sortable, and filename-safe: t_<yyyymmdd>_<rand6>.
42
+ // Time-prefix makes a `ls`-sorted directory chronologically ordered,
43
+ // which is the natural order for a "recent tasks" dashboard view.
44
+ const ID_RE = /^t_\d{8}_[a-z0-9]{6}$/;
45
+
46
+ export function isValidTaskId(id) {
47
+ return typeof id === 'string' && ID_RE.test(id);
48
+ }
49
+
50
+ export function newTaskId(now = new Date()) {
51
+ const yyyy = now.getUTCFullYear();
52
+ const mm = String(now.getUTCMonth() + 1).padStart(2, '0');
53
+ const dd = String(now.getUTCDate()).padStart(2, '0');
54
+ const rand = crypto.randomBytes(4).toString('hex').slice(0, 6);
55
+ return `t_${yyyy}${mm}${dd}_${rand}`;
56
+ }
57
+
58
+ function defaultShape(id, now) {
59
+ return {
60
+ version: 1,
61
+ id,
62
+ title: '',
63
+ description: '',
64
+ team: '',
65
+ lead: '',
66
+ status: 'pending',
67
+ slackChannel: '',
68
+ slackThreadTs: '',
69
+ createdAt: now.toISOString(),
70
+ updatedAt: now.toISOString(),
71
+ turns: [],
72
+ };
73
+ }
74
+
75
+ function writeAtomic(filePath, obj) {
76
+ const dir = path.dirname(filePath);
77
+ fs.mkdirSync(dir, { recursive: true });
78
+ const tmp = filePath + '.tmp';
79
+ fs.writeFileSync(tmp, JSON.stringify(obj, null, 2));
80
+ fs.renameSync(tmp, filePath);
81
+ }
82
+
83
+ // Register a task. `team` (name) must already exist in the registry,
84
+ // and `lead` (agent name) must belong to that team. We do not check
85
+ // slackChannel here — the CLI does that at start time so we can fail
86
+ // fast before posting to Slack.
87
+ export function registerTask({ id, title, description = '', team, lead, slackChannel = '', slackThreadTs = '', status = 'pending', turns = [] } = {}, configDir = defaultConfigDir()) {
88
+ if (!id) id = newTaskId();
89
+ if (!isValidTaskId(id)) throw new TaskError(`bad task id "${id}"`, 'TASK_BAD_ID');
90
+ if (!title || !String(title).trim()) {
91
+ throw new TaskError('title is required', 'TASK_NO_TITLE');
92
+ }
93
+ const t = getTeam(team, configDir);
94
+ if (!t) throw new TaskError(`team "${team}" is not registered`, 'TASK_NO_TEAM');
95
+ const chosenLead = lead || t.lead;
96
+ if (!t.agents.includes(chosenLead)) {
97
+ throw new TaskError(`lead "${chosenLead}" is not in team "${team}" (agents=[${t.agents.join(', ')}])`, 'TASK_BAD_LEAD');
98
+ }
99
+ if (!VALID_STATUSES.includes(status)) {
100
+ throw new TaskError(`bad status "${status}" — one of ${VALID_STATUSES.join(', ')}`, 'TASK_BAD_STATUS');
101
+ }
102
+ const p = taskPath(id, configDir);
103
+ if (fs.existsSync(p)) {
104
+ throw new TaskError(`task "${id}" already exists`, 'TASK_EXISTS');
105
+ }
106
+ const now = new Date();
107
+ const data = {
108
+ ...defaultShape(id, now),
109
+ title: String(title),
110
+ description: String(description || ''),
111
+ team,
112
+ lead: chosenLead,
113
+ slackChannel: String(slackChannel || ''),
114
+ slackThreadTs: String(slackThreadTs || ''),
115
+ status,
116
+ turns: Array.isArray(turns) ? turns : [],
117
+ };
118
+ writeAtomic(p, data);
119
+ return data;
120
+ }
121
+
122
+ export function getTask(id, configDir = defaultConfigDir()) {
123
+ let p;
124
+ try { p = taskPath(id, configDir); }
125
+ catch { return null; }
126
+ if (!fs.existsSync(p)) return null;
127
+ try { return JSON.parse(fs.readFileSync(p, 'utf8')); }
128
+ catch { return null; }
129
+ }
130
+
131
+ export function listTasks(configDir = defaultConfigDir()) {
132
+ const dir = tasksDir(configDir);
133
+ if (!fs.existsSync(dir)) return [];
134
+ const out = [];
135
+ for (const f of fs.readdirSync(dir)) {
136
+ if (!f.endsWith('.json')) continue;
137
+ const id = f.slice(0, -5);
138
+ const t = getTask(id, configDir);
139
+ if (t) out.push(t);
140
+ }
141
+ out.sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt)));
142
+ return out;
143
+ }
144
+
145
+ export function patchTask(id, patch, configDir = defaultConfigDir()) {
146
+ const t = getTask(id, configDir);
147
+ if (!t) throw new TaskError(`no task "${id}"`, 'TASK_NO_TASK');
148
+ const next = { ...t, ...patch, updatedAt: new Date().toISOString() };
149
+ if (patch.status !== undefined && !VALID_STATUSES.includes(patch.status)) {
150
+ throw new TaskError(`bad status "${patch.status}" — one of ${VALID_STATUSES.join(', ')}`, 'TASK_BAD_STATUS');
151
+ }
152
+ writeAtomic(taskPath(id, configDir), next);
153
+ return next;
154
+ }
155
+
156
+ export function appendTurn(id, turn, configDir = defaultConfigDir()) {
157
+ const t = getTask(id, configDir);
158
+ if (!t) throw new TaskError(`no task "${id}"`, 'TASK_NO_TASK');
159
+ const turns = Array.isArray(t.turns) ? [...t.turns, turn] : [turn];
160
+ return patchTask(id, { turns }, configDir);
161
+ }
162
+
163
+ export function removeTask(id, configDir = defaultConfigDir()) {
164
+ const p = taskPath(id, configDir);
165
+ if (!fs.existsSync(p)) throw new TaskError(`no task "${id}"`, 'TASK_NO_TASK');
166
+ fs.unlinkSync(p);
167
+ return { id, removed: true };
168
+ }
169
+
170
+ // Render the task's turns into a single string suitable for handing
171
+ // to a human reader. Three formats:
172
+ // 'text' (default) — "[Who]\ntext\n\n[Who]\ntext\n..." plain
173
+ // 'md' — markdown with H3 per turn, fenced code blocks
174
+ // for tool calls when present
175
+ // 'json' — the raw task record (no projection)
176
+ export function formatTranscript(task, format = 'text') {
177
+ if (!task || typeof task !== 'object') return '';
178
+ if (format === 'json') return JSON.stringify(task, null, 2);
179
+ const head = (format === 'md')
180
+ ? [
181
+ `# Task \`${task.id}\` — ${task.title || '(untitled)'}`,
182
+ task.description ? `\n${task.description}\n` : '',
183
+ `**Team**: ${task.team} · **Lead**: ${task.lead} · **Status**: ${task.status}`,
184
+ '',
185
+ '---',
186
+ '',
187
+ ].join('\n')
188
+ : `Task ${task.id}: ${task.title || '(untitled)'}\n` +
189
+ `Team: ${task.team} · Lead: ${task.lead} · Status: ${task.status}\n` +
190
+ '-'.repeat(60) + '\n';
191
+ const body = (Array.isArray(task.turns) ? task.turns : []).map((t) => {
192
+ const who = t.agent === 'user' ? 'User' : t.agent === 'system' ? 'System' : t.agent;
193
+ if (format === 'md') {
194
+ const parts = [`### ${who}`, ''];
195
+ if (t.text) parts.push(t.text, '');
196
+ if (Array.isArray(t.toolCalls) && t.toolCalls.length) {
197
+ for (const tc of t.toolCalls) {
198
+ parts.push('```json');
199
+ parts.push(JSON.stringify({ tool: tc.name, input: tc.input, ok: tc.ok }, null, 2));
200
+ parts.push('```');
201
+ }
202
+ parts.push('');
203
+ }
204
+ return parts.join('\n');
205
+ }
206
+ return `[${who}]\n${t.text || ''}`;
207
+ }).join(format === 'md' ? '\n' : '\n\n');
208
+ return head + body + '\n';
209
+ }
210
+
211
+ // Build the kickoff message Slack will see as the thread root. Stays
212
+ // template-based for Phase 11 — Phase 13 will replace this with the
213
+ // lead agent's actual first LLM turn.
214
+ export function buildKickoffMessage({ id, title, description, leadDisplayName, teamDisplayName }) {
215
+ const parts = [];
216
+ parts.push(`*Task* \`${id}\`: ${title}`);
217
+ if (description && description.trim()) parts.push(description.trim());
218
+ parts.push(`assigned to *${leadDisplayName}* (team: ${teamDisplayName})`);
219
+ return parts.join('\n');
220
+ }
package/teams.mjs ADDED
@@ -0,0 +1,199 @@
1
+ // Persistent team registry for `/team` REPL command and `lazyclaw team`
2
+ // subcommand. Backs the Phase 10 piece of docs/multi-agent.md.
3
+ //
4
+ // Storage under <configDir>/teams/<name>.json. A team is a named set of
5
+ // agents that share a Slack channel and a default lead. Both `agents`
6
+ // and `lead` are validated against the agent registry at write time so
7
+ // the on-disk record is always consistent (no dangling refs).
8
+
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+ import os from 'node:os';
12
+ import { ensureValidName as cronEnsureValidName } from './cron.mjs';
13
+ import { getAgent } from './agents.mjs';
14
+
15
+ const TEAMS_DIRNAME = 'teams';
16
+
17
+ export class TeamError extends Error {
18
+ constructor(message, code) {
19
+ super(message);
20
+ this.name = 'TeamError';
21
+ this.code = code || 'TEAM_ERR';
22
+ }
23
+ }
24
+
25
+ export function defaultConfigDir() {
26
+ return process.env.LAZYCLAW_CONFIG_DIR || path.join(os.homedir(), '.lazyclaw');
27
+ }
28
+
29
+ export function teamsDir(configDir = defaultConfigDir()) {
30
+ return path.join(configDir, TEAMS_DIRNAME);
31
+ }
32
+
33
+ export function teamPath(name, configDir = defaultConfigDir()) {
34
+ ensureValidName(name);
35
+ return path.join(teamsDir(configDir), `${name}.json`);
36
+ }
37
+
38
+ export function ensureValidName(name) {
39
+ try { cronEnsureValidName(name); }
40
+ catch (e) { throw new TeamError(e.message, 'TEAM_BAD_NAME'); }
41
+ }
42
+
43
+ function validateAgentRefs(agents, lead, configDir) {
44
+ if (!Array.isArray(agents) || agents.length === 0) {
45
+ throw new TeamError('agents must be a non-empty array', 'TEAM_NO_AGENTS');
46
+ }
47
+ for (const a of agents) {
48
+ if (!getAgent(a, configDir)) {
49
+ throw new TeamError(`agent "${a}" is not registered — run 'lazyclaw agent add ${a}' first`, 'TEAM_BAD_AGENT');
50
+ }
51
+ }
52
+ if (lead && !agents.includes(lead)) {
53
+ throw new TeamError(`lead "${lead}" must be one of the team's agents [${agents.join(', ')}]`, 'TEAM_BAD_LEAD');
54
+ }
55
+ }
56
+
57
+ function titleCase(s) {
58
+ return String(s).split(/[-_]/).map(p => p.charAt(0).toUpperCase() + p.slice(1)).join(' ');
59
+ }
60
+
61
+ function defaultShape(name) {
62
+ return {
63
+ version: 1,
64
+ name,
65
+ displayName: titleCase(name),
66
+ agents: [],
67
+ lead: null,
68
+ slackChannel: '',
69
+ createdAt: new Date().toISOString(),
70
+ updatedAt: new Date().toISOString(),
71
+ };
72
+ }
73
+
74
+ function writeAtomic(filePath, obj) {
75
+ const dir = path.dirname(filePath);
76
+ fs.mkdirSync(dir, { recursive: true });
77
+ const tmp = filePath + '.tmp';
78
+ fs.writeFileSync(tmp, JSON.stringify(obj, null, 2));
79
+ fs.renameSync(tmp, filePath);
80
+ }
81
+
82
+ export function registerTeam({ name, displayName, agents = [], lead = null, slackChannel = '' } = {}, configDir = defaultConfigDir()) {
83
+ ensureValidName(name);
84
+ const p = teamPath(name, configDir);
85
+ if (fs.existsSync(p)) {
86
+ throw new TeamError(`team "${name}" already exists`, 'TEAM_EXISTS');
87
+ }
88
+ const cleanAgents = [...new Set(agents)];
89
+ // lead defaults to the first agent if the caller didn't pick one — spec §3.2
90
+ // says "default lead", so we materialise it on write rather than leaving null.
91
+ const cleanLead = lead || cleanAgents[0] || null;
92
+ validateAgentRefs(cleanAgents, cleanLead, configDir);
93
+ const data = {
94
+ ...defaultShape(name),
95
+ displayName: displayName || titleCase(name),
96
+ agents: cleanAgents,
97
+ lead: cleanLead,
98
+ slackChannel: String(slackChannel || ''),
99
+ };
100
+ writeAtomic(p, data);
101
+ return data;
102
+ }
103
+
104
+ export function getTeam(name, configDir = defaultConfigDir()) {
105
+ let p;
106
+ try { p = teamPath(name, configDir); }
107
+ catch { return null; }
108
+ if (!fs.existsSync(p)) return null;
109
+ try { return JSON.parse(fs.readFileSync(p, 'utf8')); }
110
+ catch { return null; }
111
+ }
112
+
113
+ export function listTeams(configDir = defaultConfigDir()) {
114
+ const dir = teamsDir(configDir);
115
+ if (!fs.existsSync(dir)) return [];
116
+ const out = [];
117
+ for (const f of fs.readdirSync(dir)) {
118
+ if (!f.endsWith('.json')) continue;
119
+ const name = f.slice(0, -5);
120
+ const t = getTeam(name, configDir);
121
+ if (t) out.push(t);
122
+ }
123
+ out.sort((a, b) => String(a.name).localeCompare(String(b.name)));
124
+ return out;
125
+ }
126
+
127
+ export function patchTeam(name, patch, configDir = defaultConfigDir()) {
128
+ const t = getTeam(name, configDir);
129
+ if (!t) throw new TeamError(`no team "${name}"`, 'TEAM_NO_TEAM');
130
+ const next = { ...t, ...patch, updatedAt: new Date().toISOString() };
131
+ // Renormalise agents/lead pair when either changes so we never persist
132
+ // an inconsistent (lead not in agents) record.
133
+ if (patch.agents !== undefined) next.agents = [...new Set(patch.agents)];
134
+ validateAgentRefs(next.agents, next.lead, configDir);
135
+ writeAtomic(teamPath(name, configDir), next);
136
+ return next;
137
+ }
138
+
139
+ export function removeTeam(name, configDir = defaultConfigDir()) {
140
+ const p = teamPath(name, configDir);
141
+ if (!fs.existsSync(p)) {
142
+ throw new TeamError(`no team "${name}"`, 'TEAM_NO_TEAM');
143
+ }
144
+ fs.unlinkSync(p);
145
+ return { name, removed: true };
146
+ }
147
+
148
+ // Resolve a user-supplied channel string into a Slack channel id by
149
+ // calling conversations.list. Strategy:
150
+ // - Already-looks-like-an-id ("C…" or "G…", uppercase + digits): pass through
151
+ // - "#name" or bare name: best-effort lookup; on failure, return the
152
+ // input unchanged so the team record still saves (the user can fix
153
+ // later from the dashboard, and chat.postMessage tolerates "#name").
154
+ //
155
+ // `botToken` and `apiBase` are read from the caller — env access stays
156
+ // out of this module so it's testable.
157
+ export async function resolveSlackChannel(input, { botToken, apiBase = 'https://slack.com/api', logger = () => {} } = {}) {
158
+ if (!input) return '';
159
+ const raw = String(input).trim();
160
+ if (!raw) return '';
161
+ // ID heuristic: starts with uppercase letter, only alphanumerics, ≥9 chars.
162
+ if (/^[CGD][A-Z0-9]{8,}$/.test(raw)) return raw;
163
+ if (!botToken) {
164
+ logger(`[team] no SLACK_BOT_TOKEN — keeping channel literal "${raw}"\n`);
165
+ return raw;
166
+ }
167
+ const target = raw.startsWith('#') ? raw.slice(1) : raw;
168
+ const url = `${apiBase.replace(/\/$/, '')}/conversations.list?limit=1000&types=public_channel,private_channel`;
169
+ try {
170
+ const res = await fetch(url, {
171
+ headers: { 'Authorization': `Bearer ${botToken}` },
172
+ });
173
+ if (!res.ok) {
174
+ logger(`[team] conversations.list HTTP ${res.status} — keeping "${raw}"\n`);
175
+ return raw;
176
+ }
177
+ const json = await res.json().catch(() => ({}));
178
+ if (!json.ok) {
179
+ logger(`[team] conversations.list error "${json.error}" — keeping "${raw}"\n`);
180
+ return raw;
181
+ }
182
+ const hit = (json.channels || []).find((c) => c && c.name === target);
183
+ if (!hit) {
184
+ logger(`[team] no channel "#${target}" in workspace — keeping literal\n`);
185
+ return raw;
186
+ }
187
+ return hit.id;
188
+ } catch (err) {
189
+ logger(`[team] conversations.list failed: ${err?.message || err} — keeping "${raw}"\n`);
190
+ return raw;
191
+ }
192
+ }
193
+
194
+ export function parseListFlag(raw) {
195
+ if (raw === undefined || raw === null) return null;
196
+ const s = String(raw).trim();
197
+ if (s === '') return [];
198
+ return s.split(',').map(x => x.trim()).filter(Boolean);
199
+ }
@@ -324,6 +324,9 @@
324
324
  <button data-tab="doctor">Doctor</button>
325
325
  <button data-tab="config">Config</button>
326
326
  <button data-tab="status">Status</button>
327
+ <button data-tab="agents">Agents</button>
328
+ <button data-tab="teams">Teams</button>
329
+ <button data-tab="tasks">Tasks</button>
327
330
  </nav>
328
331
 
329
332
  <main>
@@ -429,6 +432,35 @@
429
432
  <pre id="config-raw"></pre>
430
433
  </details>
431
434
  </section>
435
+
436
+ <section id="tab-agents">
437
+ <h2>Agents</h2>
438
+ <div class="toolbar">
439
+ <button class="btn" onclick="openAgentModal()">+ New agent</button>
440
+ <button class="btn btn-secondary" onclick="LOADERS.agents()">Refresh</button>
441
+ <span class="dim" id="agents-meta"></span>
442
+ </div>
443
+ <div id="agents-list"><div class="empty">Loading…</div></div>
444
+ </section>
445
+
446
+ <section id="tab-teams">
447
+ <h2>Teams</h2>
448
+ <div class="toolbar">
449
+ <button class="btn" onclick="openTeamModal()">+ New team</button>
450
+ <button class="btn btn-secondary" onclick="LOADERS.teams()">Refresh</button>
451
+ <span class="dim" id="teams-meta"></span>
452
+ </div>
453
+ <div id="teams-list"><div class="empty">Loading…</div></div>
454
+ </section>
455
+
456
+ <section id="tab-tasks">
457
+ <h2>Tasks</h2>
458
+ <div class="toolbar">
459
+ <button class="btn btn-secondary" onclick="LOADERS.tasks()">Refresh</button>
460
+ <span class="dim" id="tasks-meta">Tasks are created via <code>lazyclaw task start</code>.</span>
461
+ </div>
462
+ <div id="tasks-list"><div class="empty">Loading…</div></div>
463
+ </section>
432
464
  </main>
433
465
 
434
466
  <footer>
@@ -1351,6 +1383,140 @@
1351
1383
  }
1352
1384
  }
1353
1385
 
1386
+ // ── Multi-agent loaders (Phase 15) ────────────────────────────
1387
+ // Keep these minimal — list view + prompt-driven create. Phase
1388
+ // 15.1+ can swap the prompts for inline forms once the data model
1389
+ // settles. The point of v0.1 is parity with the CLI, not polish.
1390
+
1391
+ function escapeHtml(s) {
1392
+ return String(s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
1393
+ }
1394
+
1395
+ LOADERS.agents = async function loadAgents() {
1396
+ const root = document.getElementById('agents-list');
1397
+ try {
1398
+ const arr = await api('/agents');
1399
+ document.getElementById('agents-meta').textContent = `${arr.length} agent(s)`;
1400
+ if (arr.length === 0) { root.innerHTML = '<div class="empty">No agents yet — click + New agent to create one.</div>'; return; }
1401
+ root.innerHTML = '<table><thead><tr><th>name</th><th>provider/model</th><th>tools</th><th>role (excerpt)</th><th></th></tr></thead><tbody>'
1402
+ + arr.map((a) => {
1403
+ const provLine = a.model ? `${escapeHtml(a.provider)}/${escapeHtml(a.model)}` : escapeHtml(a.provider);
1404
+ const role = a.role ? (a.role.slice(0, 60) + (a.role.length > 60 ? '…' : '')) : '<span class="dim">(none)</span>';
1405
+ return `<tr>
1406
+ <td><strong>${escapeHtml(a.name)}</strong><br><span class="dim">${escapeHtml(a.displayName || '')}</span></td>
1407
+ <td>${provLine}</td>
1408
+ <td>${(a.tools || []).map((t) => `<code>${escapeHtml(t)}</code>`).join(' ')}</td>
1409
+ <td>${escapeHtml(role)}</td>
1410
+ <td><button class="btn btn-secondary" onclick="deleteAgent('${encodeURIComponent(a.name)}')">Delete</button></td>
1411
+ </tr>`;
1412
+ }).join('')
1413
+ + '</tbody></table>';
1414
+ } catch (e) {
1415
+ root.innerHTML = `<div class="empty">Error: ${escapeHtml(e.message)}</div>`;
1416
+ }
1417
+ };
1418
+
1419
+ async function openAgentModal() {
1420
+ const name = (prompt('Agent name (e.g. planner, backend, frontend):') || '').trim();
1421
+ if (!name) return;
1422
+ const role = prompt('Role / system prompt (optional):') || '';
1423
+ const provider = (prompt('Provider (anthropic / openai / gemini / claude-cli):', 'anthropic') || 'anthropic').trim();
1424
+ const model = (prompt('Model id (blank = provider default):') || '').trim();
1425
+ const toolsRaw = (prompt('Tools (comma-separated):', 'bash,read,write,grep') || '').trim();
1426
+ const tools = toolsRaw ? toolsRaw.split(',').map((s) => s.trim()).filter(Boolean) : undefined;
1427
+ try {
1428
+ await api('/agents', { method: 'POST', body: JSON.stringify({ name, role, provider, model, tools }) });
1429
+ LOADERS.agents();
1430
+ } catch (e) {
1431
+ alert('Create failed: ' + e.message);
1432
+ }
1433
+ }
1434
+
1435
+ async function deleteAgent(encName) {
1436
+ const name = decodeURIComponent(encName);
1437
+ if (!confirm(`Delete agent "${name}"?`)) return;
1438
+ try { await api(`/agents/${encName}`, { method: 'DELETE' }); LOADERS.agents(); }
1439
+ catch (e) { alert('Delete failed: ' + e.message); }
1440
+ }
1441
+
1442
+ LOADERS.teams = async function loadTeams() {
1443
+ const root = document.getElementById('teams-list');
1444
+ try {
1445
+ const arr = await api('/teams');
1446
+ document.getElementById('teams-meta').textContent = `${arr.length} team(s)`;
1447
+ if (arr.length === 0) { root.innerHTML = '<div class="empty">No teams yet — click + New team to create one.</div>'; return; }
1448
+ root.innerHTML = '<table><thead><tr><th>name</th><th>lead</th><th>agents</th><th>slack channel</th><th></th></tr></thead><tbody>'
1449
+ + arr.map((t) => `<tr>
1450
+ <td><strong>${escapeHtml(t.name)}</strong><br><span class="dim">${escapeHtml(t.displayName || '')}</span></td>
1451
+ <td>${escapeHtml(t.lead || '')}</td>
1452
+ <td>${(t.agents || []).map((a) => escapeHtml(a)).join(', ')}</td>
1453
+ <td>${t.slackChannel ? `<code>${escapeHtml(t.slackChannel)}</code>` : '<span class="dim">(none)</span>'}</td>
1454
+ <td><button class="btn btn-secondary" onclick="deleteTeam('${encodeURIComponent(t.name)}')">Delete</button></td>
1455
+ </tr>`).join('')
1456
+ + '</tbody></table>';
1457
+ } catch (e) {
1458
+ root.innerHTML = `<div class="empty">Error: ${escapeHtml(e.message)}</div>`;
1459
+ }
1460
+ };
1461
+
1462
+ async function openTeamModal() {
1463
+ const name = (prompt('Team name (e.g. shop, growth):') || '').trim();
1464
+ if (!name) return;
1465
+ const agentsRaw = (prompt('Agents (comma-separated names):') || '').trim();
1466
+ if (!agentsRaw) return;
1467
+ const agents = agentsRaw.split(',').map((s) => s.trim()).filter(Boolean);
1468
+ const lead = (prompt(`Lead (one of ${agents.join(', ')}):`, agents[0]) || agents[0]).trim();
1469
+ const slackChannel = (prompt('Slack channel (C… id or #name, optional):') || '').trim();
1470
+ try {
1471
+ await api('/teams', { method: 'POST', body: JSON.stringify({ name, agents, lead, slackChannel }) });
1472
+ LOADERS.teams();
1473
+ } catch (e) {
1474
+ alert('Create failed: ' + e.message);
1475
+ }
1476
+ }
1477
+
1478
+ async function deleteTeam(encName) {
1479
+ const name = decodeURIComponent(encName);
1480
+ if (!confirm(`Delete team "${name}"?`)) return;
1481
+ try { await api(`/teams/${encName}`, { method: 'DELETE' }); LOADERS.teams(); }
1482
+ catch (e) { alert('Delete failed: ' + e.message); }
1483
+ }
1484
+
1485
+ LOADERS.tasks = async function loadTasks() {
1486
+ const root = document.getElementById('tasks-list');
1487
+ try {
1488
+ const arr = await api('/tasks');
1489
+ document.getElementById('tasks-meta').textContent = `${arr.length} task(s) (newest first)`;
1490
+ if (arr.length === 0) { root.innerHTML = '<div class="empty">No tasks yet. Run <code>lazyclaw task start --team X --title "..."</code>.</div>'; return; }
1491
+ root.innerHTML = '<table><thead><tr><th>id</th><th>title</th><th>team</th><th>lead</th><th>status</th><th>turns</th><th>opened</th><th></th></tr></thead><tbody>'
1492
+ + arr.map((t) => `<tr>
1493
+ <td><code>${escapeHtml(t.id)}</code></td>
1494
+ <td>${escapeHtml(t.title)}</td>
1495
+ <td>${escapeHtml(t.team)}</td>
1496
+ <td>${escapeHtml(t.lead)}</td>
1497
+ <td><span class="status status-${escapeHtml(t.status)}">${escapeHtml(t.status)}</span></td>
1498
+ <td>${Array.isArray(t.turns) ? t.turns.length : 0}</td>
1499
+ <td><span class="dim">${escapeHtml((t.createdAt || '').slice(0, 19))}</span></td>
1500
+ <td>
1501
+ ${t.status === 'running' || t.status === 'pending'
1502
+ ? `<button class="btn btn-secondary" onclick="closeTask('${encodeURIComponent(t.id)}','done')">Mark done</button>
1503
+ <button class="btn btn-secondary" onclick="closeTask('${encodeURIComponent(t.id)}','abandon')">Abandon</button>`
1504
+ : ''}
1505
+ </td>
1506
+ </tr>`).join('')
1507
+ + '</tbody></table>';
1508
+ } catch (e) {
1509
+ root.innerHTML = `<div class="empty">Error: ${escapeHtml(e.message)}</div>`;
1510
+ }
1511
+ };
1512
+
1513
+ async function closeTask(encId, action) {
1514
+ const id = decodeURIComponent(encId);
1515
+ if (!confirm(`${action === 'done' ? 'Mark done' : 'Abandon'} task ${id}?`)) return;
1516
+ try { await api(`/tasks/${encId}/${action}`, { method: 'POST' }); LOADERS.tasks(); }
1517
+ catch (e) { alert(`${action} failed: ` + e.message); }
1518
+ }
1519
+
1354
1520
  // First load = chat tab.
1355
1521
  LOADERS.chat();
1356
1522