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.
@@ -0,0 +1,160 @@
1
+ #!/usr/bin/env node
2
+ // Detached worker for `lazyclaw loop --detach`.
3
+ //
4
+ // Invoked by the parent CLI with the same provider/model the parent
5
+ // resolved, plus a loop id pointing at the state directory the parent
6
+ // pre-created. Streams nothing to stdout — we are headless. Every
7
+ // iteration's outcome lands in iterations.log, and the final disposition
8
+ // lands in result.json + meta.status. SIGTERM flips status to `killed`
9
+ // and unwinds cleanly so a follow-up SIGKILL is rarely needed.
10
+ //
11
+ // Argv contract (all required except --until / --session-existing):
12
+ // --loop-id <id>
13
+ // --prompt <text>
14
+ // --max <N>
15
+ // --provider <name>
16
+ // --until <regex>
17
+ // --session-existing <id> reuse the named chat session
18
+ // --cfg-dir <path> override LAZYCLAW_CONFIG_DIR
19
+ // --model <name> provider-specific model name
20
+ //
21
+ // LC_FAIL_AT_ITER=<N> is honored as a test hook: exits with code 7 just
22
+ // before iteration N's provider call. Used by phase 2 spec to assert
23
+ // the `failed` meta status path.
24
+
25
+ import path from 'node:path';
26
+ import { fileURLToPath } from 'node:url';
27
+
28
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
29
+ const REPO_ROOT = path.resolve(HERE, '..');
30
+
31
+ function parseArgs(argv) {
32
+ const out = { _: [] };
33
+ for (let i = 0; i < argv.length; i++) {
34
+ const t = argv[i];
35
+ if (t.startsWith('--')) {
36
+ const key = t.slice(2);
37
+ const v = argv[i + 1];
38
+ if (v === undefined || v.startsWith('--')) {
39
+ out[key] = true;
40
+ } else {
41
+ out[key] = v;
42
+ i++;
43
+ }
44
+ } else {
45
+ out._.push(t);
46
+ }
47
+ }
48
+ return out;
49
+ }
50
+
51
+ const args = parseArgs(process.argv.slice(2));
52
+ const loopId = args['loop-id'];
53
+ if (!loopId) {
54
+ process.stderr.write('loop-worker: --loop-id required\n');
55
+ process.exit(2);
56
+ }
57
+
58
+ if (args['cfg-dir']) {
59
+ process.env.LAZYCLAW_CONFIG_DIR = args['cfg-dir'];
60
+ }
61
+
62
+ const loops = await import(path.join(REPO_ROOT, 'loops.mjs'));
63
+ const sessions = await import(path.join(REPO_ROOT, 'sessions.mjs'));
64
+ const loopEngine = await import(path.join(REPO_ROOT, 'loop-engine.mjs'));
65
+ const registryUrl = path.join(REPO_ROOT, 'providers', 'registry.mjs');
66
+ const { PROVIDERS } = await import(registryUrl);
67
+
68
+ const cfgDir = process.env.LAZYCLAW_CONFIG_DIR || loops.defaultConfigDir();
69
+ const sessionId = args['session-existing'] || `loop:${loopId}`;
70
+
71
+ const provName = args.provider || 'mock';
72
+ const prov = PROVIDERS[provName];
73
+ if (!prov) {
74
+ loops.patchMeta(loopId, { status: 'failed', finishedAt: new Date().toISOString() }, cfgDir);
75
+ loops.writeResult(loopId, { error: `unknown provider: ${provName}` }, cfgDir);
76
+ process.exit(2);
77
+ }
78
+
79
+ const until = args.until ? loopEngine.compileUntil(args.until) : null;
80
+ const max = Number(args.max) || loopEngine.LOOP_MAX_DEFAULT;
81
+
82
+ // Initial meta — pid was filled by parent. We update startedAt here so
83
+ // the timestamp reflects when the worker actually started executing, not
84
+ // when the parent forked us.
85
+ loops.patchMeta(loopId, { status: 'running', startedAt: new Date().toISOString() }, cfgDir);
86
+
87
+ const ac = new AbortController();
88
+ function onTerm(sig) {
89
+ ac.abort();
90
+ loops.patchMeta(loopId, { status: 'killed', finishedAt: new Date().toISOString(), signal: sig }, cfgDir);
91
+ loops.writeResult(loopId, { stoppedBy: 'kill', signal: sig }, cfgDir);
92
+ // Give the in-flight stream a moment to unwind before we exit.
93
+ setTimeout(() => process.exit(143), 50);
94
+ }
95
+ process.on('SIGTERM', () => onTerm('SIGTERM'));
96
+ process.on('SIGINT', () => onTerm('SIGINT'));
97
+
98
+ const failAtIter = Number(process.env.LC_FAIL_AT_ITER) || 0;
99
+ let iterCounter = 0;
100
+
101
+ async function sendOnce(messages, signal) {
102
+ iterCounter++;
103
+ if (failAtIter && iterCounter === failAtIter) {
104
+ process.exit(7);
105
+ }
106
+ let acc = '';
107
+ for await (const chunk of prov.sendMessage(messages, {
108
+ apiKey: process.env.LAZYCLAW_API_KEY || '',
109
+ model: args.model,
110
+ signal,
111
+ })) {
112
+ acc += chunk;
113
+ }
114
+ return acc;
115
+ }
116
+
117
+ const messages = [];
118
+ // Hydrate prior turns if reusing an existing session — the engine appends
119
+ // every successful pair, so resume semantics line up with `/loop` in REPL.
120
+ if (sessionId && sessions.sessionPath) {
121
+ try {
122
+ const prior = sessions.loadTurns(sessionId, cfgDir);
123
+ for (const t of prior) messages.push({ role: t.role, content: t.content });
124
+ } catch { /* fresh session */ }
125
+ }
126
+
127
+ const persist = (role, content) => {
128
+ try { sessions.appendTurn(sessionId, role, content, cfgDir); }
129
+ catch { /* surface via result.json on failure */ }
130
+ };
131
+
132
+ const onIteration = ({ i, max: m, reply }) => {
133
+ loops.appendIteration(loopId, {
134
+ iteration: i,
135
+ of: m,
136
+ bytes: reply.length,
137
+ preview: reply.slice(0, 200),
138
+ }, cfgDir);
139
+ };
140
+
141
+ try {
142
+ const result = await loopEngine.runLoop({
143
+ prompt: args.prompt || '',
144
+ max,
145
+ until,
146
+ messages,
147
+ sendOnce,
148
+ persist,
149
+ onIteration,
150
+ signal: ac.signal,
151
+ });
152
+ const finalStatus = result.stoppedBy === 'abort' ? 'killed' : 'completed';
153
+ loops.patchMeta(loopId, { status: finalStatus, finishedAt: new Date().toISOString() }, cfgDir);
154
+ loops.writeResult(loopId, result, cfgDir);
155
+ process.exit(0);
156
+ } catch (err) {
157
+ loops.patchMeta(loopId, { status: 'failed', finishedAt: new Date().toISOString() }, cfgDir);
158
+ loops.writeResult(loopId, { error: err?.message || String(err), stack: err?.stack }, cfgDir);
159
+ process.exit(1);
160
+ }
package/sessions.mjs CHANGED
@@ -17,6 +17,7 @@
17
17
  import fs from 'node:fs';
18
18
  import path from 'node:path';
19
19
  import os from 'node:os';
20
+ import { appendRecent as _memoryAppendRecent } from './memory.mjs';
20
21
 
21
22
  const SESSIONS_DIRNAME = 'sessions';
22
23
 
@@ -74,6 +75,10 @@ export function appendTurn(id, role, content, configDir = defaultConfigDir()) {
74
75
  fs.mkdirSync(path.dirname(p), { recursive: true });
75
76
  const line = JSON.stringify({ role, content: String(content ?? ''), ts: Date.now() }) + '\n';
76
77
  fs.appendFileSync(p, line);
78
+ // Write-through to the memory recency log. Best-effort; failures
79
+ // never propagate up — a missing or broken memory store must not
80
+ // break the session-write path.
81
+ _memoryAppendRecent(id, role, content, configDir);
77
82
  }
78
83
 
79
84
  export function clearSession(id, configDir = defaultConfigDir()) {
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
+ }