@hncode/hncode 0.5.1 → 0.5.4

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 (60) hide show
  1. package/bin/install.cjs +24 -44
  2. package/bin/preinstall.cjs +0 -14
  3. package/package.json +2 -2
  4. package/src/agent-task.js +12 -1
  5. package/src/agent.js +279 -48
  6. package/src/cache.js +175 -130
  7. package/src/cell-buffer.js +5 -0
  8. package/src/clipboard.js +111 -8
  9. package/src/context-bar.js +46 -5
  10. package/src/doctor.js +7 -2
  11. package/src/git.js +35 -4
  12. package/src/index.js +29 -5
  13. package/src/keybindings.js +25 -3
  14. package/src/llm.js +60 -6
  15. package/src/mcp.js +419 -384
  16. package/src/permissions.js +29 -7
  17. package/src/plugin.js +95 -10
  18. package/src/registry-browser.js +21 -6
  19. package/src/remote.js +307 -289
  20. package/src/session-core.js +203 -0
  21. package/src/session-host.js +213 -0
  22. package/src/session-view.js +110 -0
  23. package/src/settings-schema.js +12 -9
  24. package/src/shell-safety.js +610 -473
  25. package/src/swarm-progress.js +348 -293
  26. package/src/tasks-browser.js +4 -1
  27. package/src/team/constants.js +20 -0
  28. package/src/team/context.js +89 -0
  29. package/src/team/mailbox.js +216 -0
  30. package/src/team/permission.js +121 -0
  31. package/src/team/protocol.js +167 -0
  32. package/src/team/runner.js +462 -0
  33. package/src/team/spawn.js +305 -0
  34. package/src/team/store.js +172 -0
  35. package/src/team/tasks.js +177 -0
  36. package/src/team/team.js +346 -0
  37. package/src/team-panel.js +624 -0
  38. package/src/term-caps.js +457 -410
  39. package/src/term.js +162 -19
  40. package/src/tool-result.js +70 -7
  41. package/src/tools/agent-swarm.js +114 -11
  42. package/src/tools/agent.js +464 -386
  43. package/src/tools/bash.js +53 -14
  44. package/src/tools/diagnostics.js +13 -0
  45. package/src/tools/edit.js +428 -50
  46. package/src/tools/git.js +15 -3
  47. package/src/tools/glob.js +5 -3
  48. package/src/tools/grep.js +19 -5
  49. package/src/tools/ignore.js +17 -3
  50. package/src/tools/index.js +19 -3
  51. package/src/tools/read.js +2 -1
  52. package/src/tools/shell.js +145 -134
  53. package/src/tools/tasks.js +214 -141
  54. package/src/tools/team.js +411 -0
  55. package/src/tools/utils.js +12 -3
  56. package/src/tools/web-search.js +53 -40
  57. package/src/tui.js +7496 -5366
  58. package/src/update.js +320 -115
  59. package/src/web-daemon.js +27 -4
  60. package/src/web-live.js +250 -0
@@ -0,0 +1,305 @@
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, TEAM_LEAD_NAME } from './constants.js';
19
+ import { requestPermission } from './permission.js';
20
+ import { leadOf } from './team.js';
21
+
22
+ /**
23
+ * One line describing what a tool call would do, for the permission prompt.
24
+ *
25
+ * A tool NAME tells the person nothing — "Bash" could be `ls` or `rm -rf`. These are the fields Claude
26
+ * Code checks, in the same order, because they are what the common tools put their intent in.
27
+ */
28
+ function describeCall(toolName, args) {
29
+ const a = args && typeof args === 'object' ? args : {};
30
+ const desc = a.description || a.command || a.prompt || a.query || a.pattern || a.file_path || a.path;
31
+ if (desc) return String(desc).split('\n')[0].slice(0, 120);
32
+ return `${toolName} with no description`;
33
+ }
34
+
35
+ /** Live teammates, by agent id. The abort handle and the running promise. */
36
+ const running = new Map();
37
+
38
+ /** Every teammate currently running. */
39
+ export function liveTeammates() {
40
+ return [...running.values()].map((r) => ({ name: r.name, teamName: r.teamName, taskId: r.taskId }));
41
+ }
42
+
43
+ /** Is a teammate with this name already running on this team? */
44
+ export function isRunning(teamName, name) {
45
+ return [...running.values()].some((r) => r.teamName === teamName && r.name === name);
46
+ }
47
+
48
+ /**
49
+ * Spawn one teammate.
50
+ *
51
+ * Returns as soon as it is RUNNING — the promise it hands back resolves when the teammate
52
+ * finishes, and the caller is expected to not await it. Everything that needs to observe progress
53
+ * goes through the task registry.
54
+ */
55
+ export function spawnTeammate({
56
+ teamName,
57
+ name,
58
+ prompt = '',
59
+ agentType = 'general',
60
+ model = '',
61
+ cfg,
62
+ toolNote,
63
+ // 0 = the member waits for work indefinitely; see `waitForMail`. The old default made a
64
+ // teammate quit after two quiet minutes, so a later instruction had nobody to reach.
65
+ idleTimeoutMs = 0,
66
+ maxSteps = TEAMMATE_MAX_STEPS,
67
+ cwd,
68
+ onEvent,
69
+ // Called when this teammate sends a message to the lead — that is, to the person using hncode. The
70
+ // TUI passes one so the text lands in their transcript as it is sent; see `SendMessage` for what went
71
+ // wrong without it.
72
+ onLeadMail,
73
+ tasks,
74
+ // 'ask' installs the permission round trip (see below). Anything else leaves the member able to
75
+ // run tools unattended — what a headless spawn wants.
76
+ permissionMode = '',
77
+ }) {
78
+ const team = readTeamFile(teamName);
79
+ if (!team) return { ok: false, error: `no team "${teamName}"` };
80
+ if (isRunning(teamName, name)) return { ok: false, error: `"${name}" is already running` };
81
+
82
+ // Join the roster ONLY if this name is not on it yet.
83
+ //
84
+ // `/team add` calls `addMember` itself and THEN calls this spawner, so an unconditional second
85
+ // `addMember` here ran for a name the roster already held — and for the team's FIRST member
86
+ // that name IS `leadName`, which `addMember` refuses (`"…" is the lead of this team`). So the
87
+ // spawn failed and the member stayed a roster row with `taskId: null`: no agent, nothing that
88
+ // reads its inbox, and no reply to anything sent to it. That is the bug behind "I made a team
89
+ // and it never answers".
90
+ //
91
+ // A name that is already there is REUSED rather than re-added. `addMember` is still the way in
92
+ // for a caller that has not joined the member yet (the model's TeamCreate tool spawns names
93
+ // that are already on the roster too, and a direct API use may not be).
94
+ const existing = team.members.find((m) => m.name === name);
95
+ let member;
96
+ if (existing) {
97
+ member = existing;
98
+ } else {
99
+ const added = addMember(teamName, { name, agentType, model, prompt, cwd });
100
+ if (!added.ok) return { ok: false, error: added.error };
101
+ member = added.member;
102
+ }
103
+
104
+ // Registered like any other background task, so `TaskList` finds it.
105
+ // Registered in the CALLER's registry, not a fresh object: a task nothing can see is a
106
+ // task that never appears in TaskList. `{ tasks: {} }` was that mistake.
107
+ const registry = tasks || {};
108
+ const task = createTask({ tasks: registry }, 'teammate', {
109
+ description: `${name}: ${String(prompt || 'waiting for work').slice(0, 60)}`,
110
+ teamName,
111
+ memberName: name,
112
+ agentId: member.agentId,
113
+ });
114
+
115
+ // The member must point at its task, because `runTeammate` looks the task up BY that id.
116
+ // Leaving it unset sent the teammate to the unclaimed pool — empty on a fresh team — and it
117
+ // opened by asking the lead for work.
118
+ updateMember(teamName, name, { taskId: task.taskId });
119
+ const identity = makeIdentity({
120
+ name,
121
+ teamName,
122
+ color: member.color,
123
+ agentType,
124
+ model,
125
+ });
126
+
127
+ const abort = { aborted: false, listeners: [] };
128
+ const controller = {
129
+ abort() { abort.aborted = true; for (const l of abort.listeners) { try { l(); } catch { /* best effort */ } } },
130
+ get signal() { return abort; },
131
+ };
132
+ // TaskStop reaches a teammate through this handle. Without it the task had
133
+ // neither `_abort` nor `pid`, so a stop settled the task as 'killed' while
134
+ // the member kept polling its inbox forever.
135
+ task._abort = () => controller.abort();
136
+
137
+ appendTaskOutput(task, `[${name}] started on team "${teamName}"\n`);
138
+ onEvent && onEvent({ type: 'start', name, teamName, taskId: task.taskId, color: member.color });
139
+
140
+ // The run, wrapped in its own async identity. NOT awaited: the lead continues its turn while
141
+ // the teammate works, and the result arrives as a message.
142
+ // What this teammate runs ON. The per-member choice wins, then the subagent model, then the
143
+ // session's — the same order the chooser describes, so the row the user picked is the model
144
+ // that answers. `cfg.model` is what `new Agent` reads; putting the name only on the identity
145
+ // made the chooser cosmetic.
146
+ const runCfg = {
147
+ ...cfg,
148
+ model: member.model || cfg.subagentModel || cfg.model,
149
+ // Carried on the config so the teammate's own `SendMessage` can reach the TUI. This is the
150
+ // channel that makes a reply appear in the user's transcript the moment it is sent, instead of
151
+ // waiting for their next turn (see the tool's handler for what went wrong without it).
152
+ onLeadMail,
153
+ };
154
+ let ok = false;
155
+ const promise = runAs(identity, async () => {
156
+ try {
157
+ const result = await runTeammate({
158
+ teamName,
159
+ memberName: name,
160
+ cfg: runCfg,
161
+ maxSteps,
162
+ idleTimeoutMs,
163
+ toolNote: (who, toolName, args) => {
164
+ appendTaskOutput(task, `[${who}] ${toolName}\n`);
165
+ toolNote && toolNote(who, toolName, args);
166
+ onEvent && onEvent({ type: 'tool', name: who, tool: toolName });
167
+ },
168
+ shouldStop: () => abort.aborted,
169
+ onStart: () => onEvent && onEvent({ type: 'working', name }),
170
+ onFinish: () => onEvent && onEvent({ type: 'idle', name }),
171
+ // Relay the member's live output upward. The UI decides what to do with it; spawning only
172
+ // carries it, which keeps the display policy in one place.
173
+ onStream: (s) => onEvent && onEvent({ type: 'stream', name, stream: s }),
174
+ // THE PERMISSION ROUND TRIP: the member blocks here until the person answers through the lead's
175
+ // mailbox, and `requestPermission` polls for the matching `request_id`.
176
+ //
177
+ // Only installed when someone can actually answer — a headless spawn has no panel, and blocking
178
+ // for ten minutes on a question nobody can see would look like a hang.
179
+ askPermission: permissionMode === 'ask'
180
+ ? async (toolName, args) => {
181
+ const lead = leadOf(teamName) || TEAM_LEAD_NAME;
182
+ const verdict = await requestPermission({
183
+ teamName,
184
+ memberName: name,
185
+ leadName: lead,
186
+ toolName,
187
+ description: describeCall(toolName, args),
188
+ input: args && typeof args === 'object' ? args : {},
189
+ shouldStop: () => abort.aborted,
190
+ });
191
+ onEvent && onEvent({ type: 'permission', name, toolName, approved: verdict.approved });
192
+ return verdict;
193
+ }
194
+ : undefined,
195
+ });
196
+ ok = true;
197
+ appendTaskOutput(task, `[${name}] finished${result.idle ? ' (idle timeout)' : ''}\n`);
198
+ onEvent && onEvent({ type: 'finish', name, result });
199
+ return result;
200
+ } catch (e) {
201
+ const message = (e && e.message) || String(e);
202
+ appendTaskOutput(task, `[${name}] failed: ${message}\n`);
203
+ onEvent && onEvent({ type: 'error', name, error: message });
204
+ return { ok: false, error: message };
205
+ } finally {
206
+ // Settled with the OUTCOME. `settleTask` only touches a task that is still
207
+ // running, so a stop that already claimed it keeps its own status — but a
208
+ // teammate that threw was settled as 'completed', which put a green check on
209
+ // the panel next to a failure log line.
210
+ settleTask(task, ok ? 'completed' : 'failed');
211
+ running.delete(member.agentId);
212
+ setMemberActive(teamName, name, false);
213
+
214
+ // The member STAYS on the roster, and its inbox stays on disk.
215
+ //
216
+ // This used to call `removeTeammate`, on the reasoning that a finished teammate's name
217
+ // would make a later SendMessage look deliverable. That got it backwards: the name IS
218
+ // deliverable, because the member is idle and waiting — `runTeammate` ends its loop only
219
+ // when the team is stopping it. Removing it instead destroyed the roster entry AND the
220
+ // inbox (`removeTeammate` clears the mailbox), so an agent that had reported in and was
221
+ // waiting for an assignment disappeared along with anything it had said. That is why a
222
+ // team of four came back as one lead with no members and no mail.
223
+ //
224
+ // A member is taken off the roster by an explicit act: `TeamDelete`, `disbandTeam`, or
225
+ // the user's `/team remove`.
226
+ }
227
+ });
228
+
229
+ running.set(member.agentId, {
230
+ name,
231
+ teamName,
232
+ taskId: task.taskId,
233
+ controller,
234
+ promise,
235
+ color: member.color,
236
+ });
237
+
238
+ return { ok: true, member, taskId: task.taskId, promise, controller };
239
+ }
240
+
241
+ /**
242
+ * Spawn several, tolerating individual failures.
243
+ *
244
+ * One bad name must not stop the rest: a lead that asked for four members and got one is worse
245
+ * off than one that got three and was told which failed.
246
+ */
247
+ export function spawnMany(teamName, members, opts) {
248
+ const started = [];
249
+ const failed = [];
250
+ for (const m of members) {
251
+ const r = spawnTeammate({
252
+ teamName, cwd: opts.cwd, cfg: opts.cfg, toolNote: opts.toolNote, onEvent: opts.onEvent,
253
+ onLeadMail: opts.onLeadMail,
254
+ // Carried through from the caller. `ask` installs the permission round trip; anything else leaves
255
+ // the member able to run tools unattended, which is what a headless spawn wants.
256
+ permissionMode: opts.permissionMode,
257
+ ...m,
258
+ });
259
+ if (r.ok) started.push({ name: m.name, taskId: r.taskId });
260
+ else failed.push({ name: m.name, error: r.error });
261
+ }
262
+ return { started, failed };
263
+ }
264
+
265
+ /** Stop a teammate by name. Returns false when nothing was running under it. */
266
+ export function stopTeammate(teamName, name) {
267
+ for (const [id, r] of running) {
268
+ if (r.teamName === teamName && r.name === name) {
269
+ r.controller.abort();
270
+ running.delete(id);
271
+ return true;
272
+ }
273
+ }
274
+ return false;
275
+ }
276
+
277
+ /** Stop every teammate on a team. Used when the team is dissolved. */
278
+ export function stopTeam(teamName) {
279
+ let n = 0;
280
+ for (const [id, r] of running) {
281
+ if (r.teamName !== teamName) continue;
282
+ r.controller.abort();
283
+ running.delete(id);
284
+ n++;
285
+ }
286
+ return n;
287
+ }
288
+
289
+ /**
290
+ * Wait for every teammate on a team to finish.
291
+ *
292
+ * Bounded, because a teammate that is idle-waiting will not finish on its own — the lead asking
293
+ * "are they done" must get an answer even if the answer is "no".
294
+ */
295
+ export async function waitForTeam(teamName, timeoutMs = 60_000) {
296
+ const mine = [...running.values()].filter((r) => r.teamName === teamName);
297
+ if (!mine.length) return { done: 0, pending: 0 };
298
+ const all = Promise.allSettled(mine.map((r) => r.promise));
299
+ const raced = await Promise.race([
300
+ all.then(() => true),
301
+ new Promise((r) => setTimeout(() => r(false), timeoutMs)),
302
+ ]);
303
+ const pending = [...running.values()].filter((r) => r.teamName === teamName).length;
304
+ return { done: raced ? mine.length : mine.length - pending, pending, timedOut: !raced };
305
+ }
@@ -0,0 +1,172 @@
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
+ *
81
+ * Cached by (mtimeMs, size): the team panel renders this on EVERY frame, and a
82
+ * readFileSync + JSON.parse per frame sat in the hottest path there is. A stat
83
+ * per frame replaces the full read; any write (tmp+rename) changes the mtime,
84
+ * so nothing stale survives. */
85
+ let _teamCache = { key: '', team: null };
86
+ export function readTeamFile(teamName) {
87
+ const file = teamFilePath(teamName);
88
+ let st = null;
89
+ try { st = fs.statSync(file); } catch { /* absent */ }
90
+ if (!st) return null;
91
+ const key = `${file}:${st.mtimeMs}:${st.size}`;
92
+ if (_teamCache.key === key) return _teamCache.team;
93
+ try {
94
+ const team = JSON.parse(fs.readFileSync(file, 'utf8'));
95
+ _teamCache = { key, team };
96
+ return team;
97
+ } catch {
98
+ _teamCache = { key: '', team: null };
99
+ return null;
100
+ }
101
+ }
102
+
103
+ /** Write the roster, creating the directory. Throws only on a real IO failure.
104
+ *
105
+ * Atomic (tmp + rename), like every other team write: a plain writeFileSync
106
+ * dying mid-write left a truncated config.json, and readTeamFile then returned
107
+ * null — the whole team silently disappeared. */
108
+ export function writeTeamFile(teamName, teamFile) {
109
+ fs.mkdirSync(teamDir(teamName), { recursive: true });
110
+ const target = teamFilePath(teamName);
111
+ const tmp = `${target}.${process.pid}.${Date.now()}.tmp`;
112
+ fs.writeFileSync(tmp, JSON.stringify(teamFile, null, 2), 'utf8');
113
+ try {
114
+ fs.renameSync(tmp, target);
115
+ } catch (e) {
116
+ try { fs.unlinkSync(tmp); } catch { /* nothing more to do */ }
117
+ throw e;
118
+ }
119
+ return teamFile;
120
+ }
121
+
122
+ /**
123
+ * Update one member, read-modify-write.
124
+ *
125
+ * Not a cached object: two members can be updated in the same tick (one goes idle while another
126
+ * starts), and a write from a borrowed copy would lose whichever change came first.
127
+ */
128
+ export function updateMember(teamName, memberName, patch) {
129
+ const team = readTeamFile(teamName);
130
+ if (!team) return false;
131
+ const i = team.members.findIndex((m) => m.name === memberName);
132
+ if (i < 0) return false;
133
+ team.members[i] = { ...team.members[i], ...patch };
134
+ writeTeamFile(teamName, team);
135
+ return true;
136
+ }
137
+
138
+ /** Remove a member. Returns false when the team or the member is unknown. */
139
+ export function removeMember(teamName, memberName) {
140
+ const team = readTeamFile(teamName);
141
+ if (!team) return false;
142
+ const before = team.members.length;
143
+ team.members = team.members.filter((m) => m.name !== memberName);
144
+ if (team.members.length === before) return false;
145
+ writeTeamFile(teamName, team);
146
+ return true;
147
+ }
148
+
149
+ /** Delete a team and its inboxes. The shared task list is left alone unless asked. */
150
+ export function deleteTeam(teamName, { tasks = true } = {}) {
151
+ const remove = (p) => { try { fs.rmSync(p, { recursive: true, force: true }); return true; } catch { return false; } };
152
+ const ok = remove(teamDir(teamName));
153
+ if (tasks) remove(teamTasksDir(teamName));
154
+ return ok;
155
+ }
156
+
157
+ /**
158
+ * The id every teammate is addressed by: `<name>@<team>`.
159
+ *
160
+ * Deterministic on purpose — `team-lead@my-team` is derivable without asking, so a member that
161
+ * only knows the team name can still address the lead, and a teammate spawned twice under the
162
+ * same name does not accumulate two identities.
163
+ */
164
+ export function formatAgentId(name, teamName) {
165
+ return `${sanitizeName(name)}@${sanitizeName(teamName)}`;
166
+ }
167
+
168
+ /** The name half of an agent id, or the id itself when it has no team. */
169
+ export function agentNameOf(agentId) {
170
+ const at = String(agentId || '').indexOf('@');
171
+ return at < 0 ? String(agentId || '') : String(agentId).slice(0, at);
172
+ }
@@ -0,0 +1,177 @@
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
+ // Cached by (mtimeMs, size) — the team panel reads the task list every frame,
41
+ // and a stat per read replaces the full readFileSync + JSON.parse. writeTasks
42
+ // goes through writeAtomic (rename), which always changes the mtime.
43
+ let _tasksCache = { key: '', tasks: null };
44
+ export function readTasks(teamName) {
45
+ const file = tasksFile(teamName);
46
+ let st = null;
47
+ try { st = fs.statSync(file); } catch { /* absent */ }
48
+ if (!st) return [];
49
+ const key = `${st.mtimeMs}:${st.size}`;
50
+ if (_tasksCache.key === key) return _tasksCache.tasks.slice();
51
+ try {
52
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
53
+ const tasks = Array.isArray(parsed) ? parsed : [];
54
+ _tasksCache = { key, tasks };
55
+ return tasks.slice();
56
+ } catch {
57
+ _tasksCache = { key: '', tasks: null };
58
+ return [];
59
+ }
60
+ }
61
+
62
+ function writeTasks(teamName, tasks) {
63
+ writeAtomic(tasksFile(teamName), JSON.stringify(tasks, null, 2));
64
+ return tasks;
65
+ }
66
+
67
+ /** The next id, derived from what is already there so a restart cannot reuse one. */
68
+ function nextId(tasks) {
69
+ let max = 0;
70
+ for (const t of tasks) {
71
+ const n = Number(String(t.id || '').replace(/^#/, ''));
72
+ if (Number.isFinite(n) && n > max) max = n;
73
+ }
74
+ return `#${max + 1}`;
75
+ }
76
+
77
+ /**
78
+ * Add a task and return it.
79
+ *
80
+ * `blockedBy` names other task ids. Kept as plain ids rather than resolved objects so a task can
81
+ * reference one that is not created yet — which is how a plan is written down in order.
82
+ */
83
+ export function createTask(teamName, { subject, description = '', owner = null, blockedBy = [], createdBy = '' }) {
84
+ const tasks = readTasks(teamName);
85
+ const task = {
86
+ id: nextId(tasks),
87
+ subject: String(subject || '').trim() || '(untitled)',
88
+ description: String(description || ''),
89
+ status: 'pending',
90
+ owner: owner || null,
91
+ blockedBy: Array.isArray(blockedBy) ? blockedBy.map(String) : [],
92
+ createdBy: String(createdBy || ''),
93
+ createdAt: Date.now(),
94
+ updatedAt: Date.now(),
95
+ };
96
+ tasks.push(task);
97
+ writeTasks(teamName, tasks);
98
+ return task;
99
+ }
100
+
101
+ /** Update fields on a task. Returns the updated task, or null when the id is unknown. */
102
+ export function updateTask(teamName, id, patch) {
103
+ const tasks = readTasks(teamName);
104
+ const i = tasks.findIndex((t) => t.id === id);
105
+ if (i < 0) return null;
106
+ const next = { ...tasks[i], ...patch, updatedAt: Date.now() };
107
+ if (patch.status && !TASK_STATES.includes(patch.status)) return null;
108
+ tasks[i] = next;
109
+ writeTasks(teamName, tasks);
110
+ return next;
111
+ }
112
+
113
+ /**
114
+ * Take a task, but only if nobody else has.
115
+ *
116
+ * Returns `{ ok: true, task }` on success, or `{ ok: false, reason }` naming what stopped it —
117
+ * a caller that cannot say WHY a claim failed leaves the model guessing between "someone beat
118
+ * me to it" and "that task does not exist", and the two need different next actions.
119
+ */
120
+ export function claimTask(teamName, id, agentName) {
121
+ const tasks = readTasks(teamName);
122
+ const i = tasks.findIndex((t) => t.id === id);
123
+ if (i < 0) return { ok: false, reason: `no task ${id}` };
124
+
125
+ const t = tasks[i];
126
+ if (t.status === 'completed') return { ok: false, reason: `${id} is already completed` };
127
+ if (t.status === 'cancelled') return { ok: false, reason: `${id} was cancelled` };
128
+ if (t.owner && t.owner !== agentName) return { ok: false, reason: `${id} is already owned by ${t.owner}` };
129
+
130
+ // Blocked by another task that is not done. The dependency is the point of writing it down.
131
+ const blocking = (t.blockedBy || []).filter((dep) => {
132
+ const d = tasks.find((x) => x.id === dep);
133
+ return d && d.status !== 'completed' && d.status !== 'cancelled';
134
+ });
135
+ if (blocking.length) return { ok: false, reason: `${id} is blocked by ${blocking.join(', ')}` };
136
+
137
+ tasks[i] = { ...t, owner: agentName, status: 'in_progress', updatedAt: Date.now() };
138
+ writeTasks(teamName, tasks);
139
+ return { ok: true, task: tasks[i] };
140
+ }
141
+
142
+ /** Release a task back to the pool, keeping its history. */
143
+ export function releaseTask(teamName, id, agentName) {
144
+ const t = readTasks(teamName).find((x) => x.id === id);
145
+ if (!t) return { ok: false, reason: `no task ${id}` };
146
+ if (t.owner && t.owner !== agentName) return { ok: false, reason: `${id} is owned by ${t.owner}, not ${agentName}` };
147
+ return { ok: true, task: updateTask(teamName, id, { owner: null, status: 'pending' }) };
148
+ }
149
+
150
+ /** The tasks nobody has taken, in order. */
151
+ export function unclaimedTasks(teamName) {
152
+ return readTasks(teamName).filter((t) => !t.owner && t.status === 'pending');
153
+ }
154
+
155
+ /**
156
+ * The list as a model should read it.
157
+ *
158
+ * Compact and aligned rather than JSON: this goes into a prompt on every step, and the model
159
+ * needs the id, the state and the subject — not the timestamps or the creator.
160
+ */
161
+ export function formatTasks(tasks) {
162
+ if (!tasks.length) return 'No tasks.';
163
+ const w = Math.max(...tasks.map((t) => t.id.length));
164
+ return tasks.map((t) => {
165
+ const owner = t.owner ? ` @${t.owner}` : '';
166
+ const blocked = (t.blockedBy || []).length ? ` (blocked by ${t.blockedBy.join(', ')})` : '';
167
+ return `${t.id.padEnd(w)} ${t.status.padEnd(11)} ${t.subject}${owner}${blocked}`;
168
+ }).join('\n');
169
+ }
170
+
171
+ /** Counts per state, for a one-line summary. */
172
+ export function taskSummary(tasks) {
173
+ const out = {};
174
+ for (const s of TASK_STATES) out[s] = 0;
175
+ for (const t of tasks) if (t.status in out) out[t.status]++;
176
+ return out;
177
+ }