@hncode/hncode 0.5.0 → 0.5.2

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 (48) hide show
  1. package/bin/install.cjs +24 -44
  2. package/bin/preinstall.cjs +0 -14
  3. package/package.json +1 -1
  4. package/src/agent-task.js +12 -1
  5. package/src/agent.js +115 -35
  6. package/src/cell-buffer.js +5 -0
  7. package/src/clipboard.js +42 -6
  8. package/src/colors.js +478 -387
  9. package/src/config.js +38 -11
  10. package/src/context-bar.js +46 -5
  11. package/src/doctor.js +214 -11
  12. package/src/git.js +35 -4
  13. package/src/llm.js +6 -0
  14. package/src/memory-profile.js +247 -0
  15. package/src/permissions.js +29 -7
  16. package/src/registry-browser.js +21 -6
  17. package/src/row-cache.js +147 -142
  18. package/src/session-core.js +203 -0
  19. package/src/session-host.js +213 -0
  20. package/src/session-hub.js +1 -0
  21. package/src/session-view.js +110 -0
  22. package/src/settings-schema.js +304 -0
  23. package/src/shell-safety.js +95 -6
  24. package/src/swarm-progress.js +348 -293
  25. package/src/tasks-browser.js +4 -1
  26. package/src/team/constants.js +20 -0
  27. package/src/team/context.js +89 -0
  28. package/src/team/mailbox.js +139 -0
  29. package/src/team/runner.js +347 -0
  30. package/src/team/spawn.js +247 -0
  31. package/src/team/store.js +145 -0
  32. package/src/team/tasks.js +164 -0
  33. package/src/team/team.js +311 -0
  34. package/src/team-panel.js +226 -0
  35. package/src/term.js +27 -0
  36. package/src/tool-result.js +279 -236
  37. package/src/tools/agent-swarm.js +114 -11
  38. package/src/tools/agent.js +53 -3
  39. package/src/tools/edit.js +591 -436
  40. package/src/tools/git.js +15 -3
  41. package/src/tools/index.js +2 -0
  42. package/src/tools/team.js +398 -0
  43. package/src/tools/web-search.js +88 -75
  44. package/src/tui.js +6187 -4780
  45. package/src/update.js +285 -115
  46. package/src/web-daemon.js +27 -4
  47. package/src/web-live.js +250 -0
  48. package/src/web.js +3 -1
@@ -0,0 +1,247 @@
1
+ // Spawning a teammate and registering it as a background task.
2
+ //
3
+ // This is the equivalent of Claude Code's `spawnInProcessTeammate`: it creates the identity,
4
+ // registers a task so the roster and `TaskList` can see it, and starts the run — without
5
+ // awaiting it, because a teammate that blocks its lead's turn is not a teammate.
6
+ //
7
+ // THE TASK IS THE HANDLE
8
+ // ----------------------
9
+ // The registry is how everything else reaches a running teammate: `TaskList` lists them,
10
+ // `TaskOutput` reads what they have said, `TaskStop` aborts one. Reusing it rather than adding a
11
+ // parallel registry means a teammate shows up wherever background work already does.
12
+ import { createTask, settleTask, appendTaskOutput } from '../agent-task.js';
13
+ import { runAs, makeIdentity } from './context.js';
14
+ import { runTeammate } from './runner.js';
15
+ import { addMember, setMemberActive } from './team.js';
16
+ import { updateMember } from './store.js';
17
+ import { readTeamFile } from './store.js';
18
+ import { TEAMMATE_MAX_STEPS } from './constants.js';
19
+
20
+ /** Live teammates, by agent id. The abort handle and the running promise. */
21
+ const running = new Map();
22
+
23
+ /** Every teammate currently running. */
24
+ export function liveTeammates() {
25
+ return [...running.values()].map((r) => ({ name: r.name, teamName: r.teamName, taskId: r.taskId }));
26
+ }
27
+
28
+ /** Is a teammate with this name already running on this team? */
29
+ export function isRunning(teamName, name) {
30
+ return [...running.values()].some((r) => r.teamName === teamName && r.name === name);
31
+ }
32
+
33
+ /**
34
+ * Spawn one teammate.
35
+ *
36
+ * Returns as soon as it is RUNNING — the promise it hands back resolves when the teammate
37
+ * finishes, and the caller is expected to not await it. Everything that needs to observe progress
38
+ * goes through the task registry.
39
+ */
40
+ export function spawnTeammate({
41
+ teamName,
42
+ name,
43
+ prompt = '',
44
+ agentType = 'general',
45
+ model = '',
46
+ cfg,
47
+ toolNote,
48
+ // 0 = the member waits for work indefinitely; see `waitForMail`. The old default made a
49
+ // teammate quit after two quiet minutes, so a later instruction had nobody to reach.
50
+ idleTimeoutMs = 0,
51
+ maxSteps = TEAMMATE_MAX_STEPS,
52
+ cwd,
53
+ onEvent,
54
+ tasks,
55
+ }) {
56
+ const team = readTeamFile(teamName);
57
+ if (!team) return { ok: false, error: `no team "${teamName}"` };
58
+ if (isRunning(teamName, name)) return { ok: false, error: `"${name}" is already running` };
59
+
60
+ // Join the roster ONLY if this name is not on it yet.
61
+ //
62
+ // `/team add` calls `addMember` itself and THEN calls this spawner, so an unconditional second
63
+ // `addMember` here ran for a name the roster already held — and for the team's FIRST member
64
+ // that name IS `leadName`, which `addMember` refuses (`"…" is the lead of this team`). So the
65
+ // spawn failed and the member stayed a roster row with `taskId: null`: no agent, nothing that
66
+ // reads its inbox, and no reply to anything sent to it. That is the bug behind "I made a team
67
+ // and it never answers".
68
+ //
69
+ // A name that is already there is REUSED rather than re-added. `addMember` is still the way in
70
+ // for a caller that has not joined the member yet (the model's TeamCreate tool spawns names
71
+ // that are already on the roster too, and a direct API use may not be).
72
+ const existing = team.members.find((m) => m.name === name);
73
+ let member;
74
+ if (existing) {
75
+ member = existing;
76
+ } else {
77
+ const added = addMember(teamName, { name, agentType, model, prompt, cwd });
78
+ if (!added.ok) return { ok: false, error: added.error };
79
+ member = added.member;
80
+ }
81
+
82
+ // Registered like any other background task, so `TaskList` finds it.
83
+ // Registered in the CALLER's registry, not a fresh object: a task nothing can see is a
84
+ // task that never appears in TaskList. `{ tasks: {} }` was that mistake.
85
+ const registry = tasks || {};
86
+ const task = createTask({ tasks: registry }, 'teammate', {
87
+ description: `${name}: ${String(prompt || 'waiting for work').slice(0, 60)}`,
88
+ teamName,
89
+ memberName: name,
90
+ agentId: member.agentId,
91
+ });
92
+
93
+ // The member must point at its task, because `runTeammate` looks the task up BY that id.
94
+ // Leaving it unset sent the teammate to the unclaimed pool — empty on a fresh team — and it
95
+ // opened by asking the lead for work.
96
+ updateMember(teamName, name, { taskId: task.taskId });
97
+ const identity = makeIdentity({
98
+ name,
99
+ teamName,
100
+ color: member.color,
101
+ agentType,
102
+ model,
103
+ });
104
+
105
+ const abort = { aborted: false, listeners: [] };
106
+ const controller = {
107
+ abort() { abort.aborted = true; for (const l of abort.listeners) { try { l(); } catch { /* best effort */ } } },
108
+ get signal() { return abort; },
109
+ };
110
+
111
+ appendTaskOutput(task, `[${name}] started on team "${teamName}"\n`);
112
+ onEvent && onEvent({ type: 'start', name, teamName, taskId: task.taskId, color: member.color });
113
+
114
+ // The run, wrapped in its own async identity. NOT awaited: the lead continues its turn while
115
+ // the teammate works, and the result arrives as a message.
116
+ // What this teammate runs ON. The per-member choice wins, then the subagent model, then the
117
+ // session's — the same order the chooser describes, so the row the user picked is the model
118
+ // that answers. `cfg.model` is what `new Agent` reads; putting the name only on the identity
119
+ // made the chooser cosmetic.
120
+ const runCfg = {
121
+ ...cfg,
122
+ model: member.model || cfg.subagentModel || cfg.model,
123
+ };
124
+ let ok = false;
125
+ const promise = runAs(identity, async () => {
126
+ try {
127
+ const result = await runTeammate({
128
+ teamName,
129
+ memberName: name,
130
+ cfg: runCfg,
131
+ maxSteps,
132
+ idleTimeoutMs,
133
+ toolNote: (who, toolName, args) => {
134
+ appendTaskOutput(task, `[${who}] ${toolName}\n`);
135
+ toolNote && toolNote(who, toolName, args);
136
+ onEvent && onEvent({ type: 'tool', name: who, tool: toolName });
137
+ },
138
+ shouldStop: () => abort.aborted,
139
+ onStart: () => onEvent && onEvent({ type: 'working', name }),
140
+ onFinish: () => onEvent && onEvent({ type: 'idle', name }),
141
+ });
142
+ ok = true;
143
+ appendTaskOutput(task, `[${name}] finished${result.idle ? ' (idle timeout)' : ''}\n`);
144
+ onEvent && onEvent({ type: 'finish', name, result });
145
+ return result;
146
+ } catch (e) {
147
+ const message = (e && e.message) || String(e);
148
+ appendTaskOutput(task, `[${name}] failed: ${message}\n`);
149
+ onEvent && onEvent({ type: 'error', name, error: message });
150
+ return { ok: false, error: message };
151
+ } finally {
152
+ // Settled with the OUTCOME. `settleTask` only touches a task that is still
153
+ // running, so a stop that already claimed it keeps its own status — but a
154
+ // teammate that threw was settled as 'completed', which put a green check on
155
+ // the panel next to a failure log line.
156
+ settleTask(task, ok ? 'completed' : 'failed');
157
+ running.delete(member.agentId);
158
+ setMemberActive(teamName, name, false);
159
+
160
+ // The member STAYS on the roster, and its inbox stays on disk.
161
+ //
162
+ // This used to call `removeTeammate`, on the reasoning that a finished teammate's name
163
+ // would make a later SendMessage look deliverable. That got it backwards: the name IS
164
+ // deliverable, because the member is idle and waiting — `runTeammate` ends its loop only
165
+ // when the team is stopping it. Removing it instead destroyed the roster entry AND the
166
+ // inbox (`removeTeammate` clears the mailbox), so an agent that had reported in and was
167
+ // waiting for an assignment disappeared along with anything it had said. That is why a
168
+ // team of four came back as one lead with no members and no mail.
169
+ //
170
+ // A member is taken off the roster by an explicit act: `TeamDelete`, `disbandTeam`, or
171
+ // the user's `/team remove`.
172
+ }
173
+ });
174
+
175
+ running.set(member.agentId, {
176
+ name,
177
+ teamName,
178
+ taskId: task.taskId,
179
+ controller,
180
+ teamName,
181
+ taskId: task.taskId,
182
+ controller,
183
+ promise,
184
+ color: member.color,
185
+ });
186
+
187
+ return { ok: true, member, taskId: task.taskId, promise, controller };
188
+ }
189
+
190
+ /**
191
+ * Spawn several, tolerating individual failures.
192
+ *
193
+ * One bad name must not stop the rest: a lead that asked for four members and got one is worse
194
+ * off than one that got three and was told which failed.
195
+ */
196
+ export function spawnMany(teamName, members, opts) {
197
+ const started = [];
198
+ const failed = [];
199
+ for (const m of members) {
200
+ const r = spawnTeammate({ teamName, cwd: opts.cwd, cfg: opts.cfg, toolNote: opts.toolNote, onEvent: opts.onEvent, ...m });
201
+ if (r.ok) started.push({ name: m.name, taskId: r.taskId });
202
+ else failed.push({ name: m.name, error: r.error });
203
+ }
204
+ return { started, failed };
205
+ }
206
+
207
+ /** Stop a teammate by name. Returns false when nothing was running under it. */
208
+ export function stopTeammate(teamName, name) {
209
+ for (const [id, r] of running) {
210
+ if (r.teamName === teamName && r.name === name) {
211
+ r.controller.abort();
212
+ running.delete(id);
213
+ return true;
214
+ }
215
+ }
216
+ return false;
217
+ }
218
+
219
+ /** Stop every teammate on a team. Used when the team is dissolved. */
220
+ export function stopTeam(teamName) {
221
+ let n = 0;
222
+ for (const [id, r] of running) {
223
+ if (r.teamName !== teamName) continue;
224
+ r.controller.abort();
225
+ running.delete(id);
226
+ n++;
227
+ }
228
+ return n;
229
+ }
230
+
231
+ /**
232
+ * Wait for every teammate on a team to finish.
233
+ *
234
+ * Bounded, because a teammate that is idle-waiting will not finish on its own — the lead asking
235
+ * "are they done" must get an answer even if the answer is "no".
236
+ */
237
+ export async function waitForTeam(teamName, timeoutMs = 60_000) {
238
+ const mine = [...running.values()].filter((r) => r.teamName === teamName);
239
+ if (!mine.length) return { done: 0, pending: 0 };
240
+ const all = Promise.allSettled(mine.map((r) => r.promise));
241
+ const raced = await Promise.race([
242
+ all.then(() => true),
243
+ new Promise((r) => setTimeout(() => r(false), timeoutMs)),
244
+ ]);
245
+ const pending = [...running.values()].filter((r) => r.teamName === teamName).length;
246
+ return { done: raced ? mine.length : mine.length - pending, pending, timedOut: !raced };
247
+ }
@@ -0,0 +1,145 @@
1
+ // Where a team lives on disk.
2
+ //
3
+ // ~/.hncode/teams/<team>/config.json the roster (TeamFile)
4
+ // ~/.hncode/teams/<team>/inboxes/<name>.json one inbox per member
5
+ // ~/.hncode/tasks/<team>/ the shared task list
6
+ //
7
+ // WHY FILES RATHER THAN MEMORY
8
+ // ----------------------------
9
+ // Every member runs in the same process, so a Map would be simpler — and would be wrong. The
10
+ // roster has to survive the session that created it (a teammate outlives the turn that spawned
11
+ // it, and `--continue` must find the team again), and the mailbox is the ONE channel members
12
+ // share. Keeping both on disk means a member written to by another is never holding a stale
13
+ // copy, and a crashed session leaves something a person can read.
14
+ //
15
+ // The layout mirrors Claude Code's (`~/.claude/teams/...`), so the mental model transfers and a
16
+ // reader of either project recognises the other.
17
+ import fs from 'node:fs';
18
+ import os from 'node:os';
19
+ import path from 'node:path';
20
+
21
+ /** The per-user directory. Resolved per call so a test can redirect it after import. */
22
+ function homeDir() {
23
+ return process.env.HNCODE_HOME || os.homedir();
24
+ }
25
+
26
+ export function teamsRoot() {
27
+ return path.join(homeDir(), '.hncode', 'teams');
28
+ }
29
+
30
+ export function tasksRoot() {
31
+ return path.join(homeDir(), '.hncode', 'tasks');
32
+ }
33
+
34
+ // A team name becomes a directory name, so it cannot be trusted as-is: `../` would escape the
35
+ // teams directory, and a name with a slash would place the files somewhere else entirely.
36
+ export function sanitizeName(name) {
37
+ return String(name == null ? '' : name).replace(/[^a-zA-Z0-9]/g, '-').toLowerCase();
38
+ }
39
+
40
+ export function teamDir(teamName) {
41
+ return path.join(teamsRoot(), sanitizeName(teamName));
42
+ }
43
+
44
+ export function teamFilePath(teamName) {
45
+ return path.join(teamDir(teamName), 'config.json');
46
+ }
47
+
48
+ export function inboxDir(teamName) {
49
+ return path.join(teamDir(teamName), 'inboxes');
50
+ }
51
+
52
+ export function inboxPath(teamName, agentName) {
53
+ return path.join(inboxDir(teamName), `${sanitizeName(agentName)}.json`);
54
+ }
55
+
56
+ export function teamTasksDir(teamName) {
57
+ return path.join(tasksRoot(), sanitizeName(teamName));
58
+ }
59
+
60
+ /** Every team on disk, newest first. Never throws. */
61
+ export function listTeams() {
62
+ let entries;
63
+ try {
64
+ entries = fs.readdirSync(teamsRoot(), { withFileTypes: true });
65
+ } catch {
66
+ return [];
67
+ }
68
+ const out = [];
69
+ for (const e of entries) {
70
+ if (!e.isDirectory()) continue;
71
+ const file = path.join(teamsRoot(), e.name, 'config.json');
72
+ try {
73
+ out.push(JSON.parse(fs.readFileSync(file, 'utf8')));
74
+ } catch { /* a half-written or removed team is skipped, not fatal */ }
75
+ }
76
+ return out.sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
77
+ }
78
+
79
+ /** The roster, or null. Never throws. */
80
+ export function readTeamFile(teamName) {
81
+ try {
82
+ return JSON.parse(fs.readFileSync(teamFilePath(teamName), 'utf8'));
83
+ } catch {
84
+ return null;
85
+ }
86
+ }
87
+
88
+ /** Write the roster, creating the directory. Throws only on a real IO failure. */
89
+ export function writeTeamFile(teamName, teamFile) {
90
+ fs.mkdirSync(teamDir(teamName), { recursive: true });
91
+ fs.writeFileSync(teamFilePath(teamName), JSON.stringify(teamFile, null, 2), 'utf8');
92
+ return teamFile;
93
+ }
94
+
95
+ /**
96
+ * Update one member, read-modify-write.
97
+ *
98
+ * Not a cached object: two members can be updated in the same tick (one goes idle while another
99
+ * starts), and a write from a borrowed copy would lose whichever change came first.
100
+ */
101
+ export function updateMember(teamName, memberName, patch) {
102
+ const team = readTeamFile(teamName);
103
+ if (!team) return false;
104
+ const i = team.members.findIndex((m) => m.name === memberName);
105
+ if (i < 0) return false;
106
+ team.members[i] = { ...team.members[i], ...patch };
107
+ writeTeamFile(teamName, team);
108
+ return true;
109
+ }
110
+
111
+ /** Remove a member. Returns false when the team or the member is unknown. */
112
+ export function removeMember(teamName, memberName) {
113
+ const team = readTeamFile(teamName);
114
+ if (!team) return false;
115
+ const before = team.members.length;
116
+ team.members = team.members.filter((m) => m.name !== memberName);
117
+ if (team.members.length === before) return false;
118
+ writeTeamFile(teamName, team);
119
+ return true;
120
+ }
121
+
122
+ /** Delete a team and its inboxes. The shared task list is left alone unless asked. */
123
+ export function deleteTeam(teamName, { tasks = true } = {}) {
124
+ const remove = (p) => { try { fs.rmSync(p, { recursive: true, force: true }); return true; } catch { return false; } };
125
+ const ok = remove(teamDir(teamName));
126
+ if (tasks) remove(teamTasksDir(teamName));
127
+ return ok;
128
+ }
129
+
130
+ /**
131
+ * The id every teammate is addressed by: `<name>@<team>`.
132
+ *
133
+ * Deterministic on purpose — `team-lead@my-team` is derivable without asking, so a member that
134
+ * only knows the team name can still address the lead, and a teammate spawned twice under the
135
+ * same name does not accumulate two identities.
136
+ */
137
+ export function formatAgentId(name, teamName) {
138
+ return `${sanitizeName(name)}@${sanitizeName(teamName)}`;
139
+ }
140
+
141
+ /** The name half of an agent id, or the id itself when it has no team. */
142
+ export function agentNameOf(agentId) {
143
+ const at = String(agentId || '').indexOf('@');
144
+ return at < 0 ? String(agentId || '') : String(agentId).slice(0, at);
145
+ }
@@ -0,0 +1,164 @@
1
+ // The shared task list. One file per team, read and written by every member.
2
+ //
3
+ // ~/.hncode/tasks/<team>/tasks.json -> Task[]
4
+ //
5
+ // WHY A SINGLE FILE RATHER THAN ONE PER TASK
6
+ // ------------------------------------------
7
+ // The list is what members read to decide what to do next: "is anything unclaimed", "am I
8
+ // blocking someone". That question spans the whole list, so keeping it in one file makes the
9
+ // common read a single parse instead of a directory walk with a parse per entry. A team's list
10
+ // is small (tens of entries); it is not a work queue with thousands of items.
11
+ //
12
+ // CLAIMING IS A COMPARE-AND-SWAP
13
+ // ------------------------------
14
+ // Two members polling at the same moment must not both take the same task. The claim re-reads
15
+ // the file, checks the owner is still unset, and only then writes — and because the write is a
16
+ // temp-file rename (see mailbox.js), the loser's write is the one that sees the winner's.
17
+ import fs from 'node:fs';
18
+ import path from 'node:path';
19
+ import { teamTasksDir } from './store.js';
20
+
21
+ export const TASK_STATES = ['pending', 'in_progress', 'completed', 'blocked', 'cancelled'];
22
+
23
+ function tasksFile(teamName) {
24
+ return path.join(teamTasksDir(teamName), 'tasks.json');
25
+ }
26
+
27
+ function writeAtomic(file, text) {
28
+ fs.mkdirSync(path.dirname(file), { recursive: true });
29
+ const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
30
+ fs.writeFileSync(tmp, text, 'utf8');
31
+ try {
32
+ fs.renameSync(tmp, file);
33
+ } catch (e) {
34
+ try { fs.unlinkSync(tmp); } catch { /* nothing to salvage */ }
35
+ throw e;
36
+ }
37
+ }
38
+
39
+ /** Every task, oldest first. Never throws. */
40
+ export function readTasks(teamName) {
41
+ try {
42
+ const parsed = JSON.parse(fs.readFileSync(tasksFile(teamName), 'utf8'));
43
+ return Array.isArray(parsed) ? parsed : [];
44
+ } catch {
45
+ return [];
46
+ }
47
+ }
48
+
49
+ function writeTasks(teamName, tasks) {
50
+ writeAtomic(tasksFile(teamName), JSON.stringify(tasks, null, 2));
51
+ return tasks;
52
+ }
53
+
54
+ /** The next id, derived from what is already there so a restart cannot reuse one. */
55
+ function nextId(tasks) {
56
+ let max = 0;
57
+ for (const t of tasks) {
58
+ const n = Number(String(t.id || '').replace(/^#/, ''));
59
+ if (Number.isFinite(n) && n > max) max = n;
60
+ }
61
+ return `#${max + 1}`;
62
+ }
63
+
64
+ /**
65
+ * Add a task and return it.
66
+ *
67
+ * `blockedBy` names other task ids. Kept as plain ids rather than resolved objects so a task can
68
+ * reference one that is not created yet — which is how a plan is written down in order.
69
+ */
70
+ export function createTask(teamName, { subject, description = '', owner = null, blockedBy = [], createdBy = '' }) {
71
+ const tasks = readTasks(teamName);
72
+ const task = {
73
+ id: nextId(tasks),
74
+ subject: String(subject || '').trim() || '(untitled)',
75
+ description: String(description || ''),
76
+ status: 'pending',
77
+ owner: owner || null,
78
+ blockedBy: Array.isArray(blockedBy) ? blockedBy.map(String) : [],
79
+ createdBy: String(createdBy || ''),
80
+ createdAt: Date.now(),
81
+ updatedAt: Date.now(),
82
+ };
83
+ tasks.push(task);
84
+ writeTasks(teamName, tasks);
85
+ return task;
86
+ }
87
+
88
+ /** Update fields on a task. Returns the updated task, or null when the id is unknown. */
89
+ export function updateTask(teamName, id, patch) {
90
+ const tasks = readTasks(teamName);
91
+ const i = tasks.findIndex((t) => t.id === id);
92
+ if (i < 0) return null;
93
+ const next = { ...tasks[i], ...patch, updatedAt: Date.now() };
94
+ if (patch.status && !TASK_STATES.includes(patch.status)) return null;
95
+ tasks[i] = next;
96
+ writeTasks(teamName, tasks);
97
+ return next;
98
+ }
99
+
100
+ /**
101
+ * Take a task, but only if nobody else has.
102
+ *
103
+ * Returns `{ ok: true, task }` on success, or `{ ok: false, reason }` naming what stopped it —
104
+ * a caller that cannot say WHY a claim failed leaves the model guessing between "someone beat
105
+ * me to it" and "that task does not exist", and the two need different next actions.
106
+ */
107
+ export function claimTask(teamName, id, agentName) {
108
+ const tasks = readTasks(teamName);
109
+ const i = tasks.findIndex((t) => t.id === id);
110
+ if (i < 0) return { ok: false, reason: `no task ${id}` };
111
+
112
+ const t = tasks[i];
113
+ if (t.status === 'completed') return { ok: false, reason: `${id} is already completed` };
114
+ if (t.status === 'cancelled') return { ok: false, reason: `${id} was cancelled` };
115
+ if (t.owner && t.owner !== agentName) return { ok: false, reason: `${id} is already owned by ${t.owner}` };
116
+
117
+ // Blocked by another task that is not done. The dependency is the point of writing it down.
118
+ const blocking = (t.blockedBy || []).filter((dep) => {
119
+ const d = tasks.find((x) => x.id === dep);
120
+ return d && d.status !== 'completed' && d.status !== 'cancelled';
121
+ });
122
+ if (blocking.length) return { ok: false, reason: `${id} is blocked by ${blocking.join(', ')}` };
123
+
124
+ tasks[i] = { ...t, owner: agentName, status: 'in_progress', updatedAt: Date.now() };
125
+ writeTasks(teamName, tasks);
126
+ return { ok: true, task: tasks[i] };
127
+ }
128
+
129
+ /** Release a task back to the pool, keeping its history. */
130
+ export function releaseTask(teamName, id, agentName) {
131
+ const t = readTasks(teamName).find((x) => x.id === id);
132
+ if (!t) return { ok: false, reason: `no task ${id}` };
133
+ if (t.owner && t.owner !== agentName) return { ok: false, reason: `${id} is owned by ${t.owner}, not ${agentName}` };
134
+ return { ok: true, task: updateTask(teamName, id, { owner: null, status: 'pending' }) };
135
+ }
136
+
137
+ /** The tasks nobody has taken, in order. */
138
+ export function unclaimedTasks(teamName) {
139
+ return readTasks(teamName).filter((t) => !t.owner && t.status === 'pending');
140
+ }
141
+
142
+ /**
143
+ * The list as a model should read it.
144
+ *
145
+ * Compact and aligned rather than JSON: this goes into a prompt on every step, and the model
146
+ * needs the id, the state and the subject — not the timestamps or the creator.
147
+ */
148
+ export function formatTasks(tasks) {
149
+ if (!tasks.length) return 'No tasks.';
150
+ const w = Math.max(...tasks.map((t) => t.id.length));
151
+ return tasks.map((t) => {
152
+ const owner = t.owner ? ` @${t.owner}` : '';
153
+ const blocked = (t.blockedBy || []).length ? ` (blocked by ${t.blockedBy.join(', ')})` : '';
154
+ return `${t.id.padEnd(w)} ${t.status.padEnd(11)} ${t.subject}${owner}${blocked}`;
155
+ }).join('\n');
156
+ }
157
+
158
+ /** Counts per state, for a one-line summary. */
159
+ export function taskSummary(tasks) {
160
+ const out = {};
161
+ for (const s of TASK_STATES) out[s] = 0;
162
+ for (const t of tasks) if (t.status in out) out[t.status]++;
163
+ return out;
164
+ }