@nanhara/hara 0.121.1 → 0.122.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.
Files changed (47) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/README.md +40 -6
  3. package/dist/agent/loop.js +136 -21
  4. package/dist/agent/reminders.js +22 -7
  5. package/dist/agent/repeat-guard.js +26 -7
  6. package/dist/agent/structured.js +231 -0
  7. package/dist/agent/touched.js +20 -6
  8. package/dist/config.js +33 -23
  9. package/dist/cron/deliver.js +37 -3
  10. package/dist/feedback.js +3 -2
  11. package/dist/fs-read.js +106 -12
  12. package/dist/fs-write.js +242 -16
  13. package/dist/gateway/dingtalk.js +4 -1
  14. package/dist/gateway/discord.js +53 -18
  15. package/dist/gateway/feishu.js +158 -57
  16. package/dist/gateway/flows-pending.js +720 -0
  17. package/dist/gateway/flows.js +391 -16
  18. package/dist/gateway/matrix.js +80 -15
  19. package/dist/gateway/mattermost.js +44 -32
  20. package/dist/gateway/media.js +659 -0
  21. package/dist/gateway/serve.js +657 -162
  22. package/dist/gateway/sessions.js +475 -78
  23. package/dist/gateway/signal.js +27 -22
  24. package/dist/gateway/slack.js +26 -17
  25. package/dist/gateway/telegram.js +32 -18
  26. package/dist/gateway/wecom.js +32 -24
  27. package/dist/gateway/weixin.js +127 -49
  28. package/dist/hooks.js +32 -20
  29. package/dist/index.js +631 -222
  30. package/dist/org/projects.js +347 -0
  31. package/dist/org/roles.js +38 -11
  32. package/dist/security/secrets.js +84 -9
  33. package/dist/serve/server.js +772 -317
  34. package/dist/serve/sessions.js +113 -33
  35. package/dist/session/store.js +298 -47
  36. package/dist/tools/all.js +1 -0
  37. package/dist/tools/builtin.js +30 -28
  38. package/dist/tools/codebase.js +3 -1
  39. package/dist/tools/computer.js +98 -92
  40. package/dist/tools/edit.js +11 -8
  41. package/dist/tools/patch.js +230 -31
  42. package/dist/tools/search.js +482 -72
  43. package/dist/tools/task.js +453 -0
  44. package/dist/tools/todo.js +67 -16
  45. package/dist/tools/web.js +168 -54
  46. package/dist/undo.js +83 -7
  47. package/package.json +1 -1
@@ -0,0 +1,453 @@
1
+ // task — a PROJECT-level, cross-session task pool (the durable big brother of todo_write's in-session
2
+ // checklist). The store is private, atomic, and protected by a per-project cross-process lock so TUI,
3
+ // headless, cron, gateway, and desktop writers cannot silently overwrite one another.
4
+ import { chmodSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync, } from "node:fs";
5
+ import { basename, join, resolve } from "node:path";
6
+ import { homedir } from "node:os";
7
+ import { createHash, randomUUID } from "node:crypto";
8
+ import { registerTool } from "./registry.js";
9
+ const TASK_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
10
+ const TASK_STATUSES = new Set(["pending", "in_progress", "done"]);
11
+ const sleepCell = new Int32Array(new SharedArrayBuffer(4));
12
+ const LOCK_ATTEMPTS = 500;
13
+ const LOCK_WAIT_MS = 10;
14
+ /** Resolve aliases/symlinks before deriving the key. A missing path still gets a stable absolute key so
15
+ * callers receive a deterministic error/store location rather than colliding on a lossy slug. */
16
+ export function canonicalTaskCwd(cwd) {
17
+ if (typeof cwd !== "string" || !cwd.trim())
18
+ throw new Error("task cwd must be a non-empty path");
19
+ const absolute = resolve(cwd);
20
+ try {
21
+ return realpathSync.native(absolute);
22
+ }
23
+ catch {
24
+ return absolute;
25
+ }
26
+ }
27
+ /** Human-readable basename + 96 bits of the canonical path. The old path-to-dashes format made
28
+ * `/a-b` and `/a/b` share one task file; the hash makes those registries independent. */
29
+ export function taskSlug(cwd) {
30
+ const canonical = canonicalTaskCwd(cwd);
31
+ const label = (basename(canonical) || "root")
32
+ .toLowerCase()
33
+ .replace(/[^a-z0-9._-]+/g, "-")
34
+ .replace(/^-+|-+$/g, "")
35
+ .slice(0, 48) || "root";
36
+ const hash = createHash("sha256").update(canonical).digest("hex").slice(0, 24);
37
+ return `${label}-${hash}`;
38
+ }
39
+ function tasksDir() {
40
+ const hara = join(homedir(), ".hara");
41
+ const tasks = join(hara, "tasks");
42
+ mkdirSync(tasks, { recursive: true, mode: 0o700 });
43
+ try {
44
+ chmodSync(hara, 0o700);
45
+ chmodSync(tasks, 0o700);
46
+ }
47
+ catch {
48
+ // Non-POSIX filesystems may not implement modes; O_EXCL/atomic rename still provide correctness.
49
+ }
50
+ return tasks;
51
+ }
52
+ function taskFile(cwd) {
53
+ return join(tasksDir(), `${taskSlug(cwd)}.json`);
54
+ }
55
+ function readLock(path) {
56
+ try {
57
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
58
+ return Number.isInteger(parsed?.pid) && parsed.pid > 0 && typeof parsed?.token === "string" && parsed.token
59
+ ? { pid: parsed.pid, token: parsed.token }
60
+ : null;
61
+ }
62
+ catch {
63
+ return null;
64
+ }
65
+ }
66
+ function pidAlive(pid) {
67
+ try {
68
+ process.kill(pid, 0);
69
+ return true;
70
+ }
71
+ catch (error) {
72
+ return error?.code === "EPERM";
73
+ }
74
+ }
75
+ function writeExclusive(path, record) {
76
+ let fd;
77
+ try {
78
+ fd = openSync(path, "wx", 0o600);
79
+ writeFileSync(fd, JSON.stringify(record), "utf8");
80
+ fsyncSync(fd);
81
+ }
82
+ catch (error) {
83
+ if (fd !== undefined) {
84
+ try {
85
+ closeSync(fd);
86
+ fd = undefined;
87
+ }
88
+ finally {
89
+ rmSync(path, { force: true });
90
+ }
91
+ }
92
+ throw error;
93
+ }
94
+ finally {
95
+ if (fd !== undefined)
96
+ closeSync(fd);
97
+ }
98
+ }
99
+ /** Bounded O_EXCL mutex. A well-formed dead owner can be reclaimed under a second guard; malformed locks
100
+ * fail closed instead of being guessed stale and risking two writers. */
101
+ function withTaskLock(cwd, fn) {
102
+ const file = taskFile(cwd);
103
+ const lock = `${file}.lock`;
104
+ const reclaim = `${lock}.reclaim`;
105
+ let claim;
106
+ for (let attempt = 0; attempt < LOCK_ATTEMPTS; attempt++) {
107
+ if (existsSync(reclaim)) {
108
+ const stale = readLock(reclaim);
109
+ if (stale && !pidAlive(stale.pid)) {
110
+ const current = readLock(reclaim);
111
+ if (current?.pid === stale.pid && current.token === stale.token && !pidAlive(current.pid)) {
112
+ rmSync(reclaim, { force: true });
113
+ continue;
114
+ }
115
+ }
116
+ Atomics.wait(sleepCell, 0, 0, LOCK_WAIT_MS);
117
+ continue;
118
+ }
119
+ const candidate = { pid: process.pid, token: randomUUID() };
120
+ try {
121
+ writeExclusive(lock, candidate);
122
+ claim = candidate;
123
+ break;
124
+ }
125
+ catch (error) {
126
+ if (error?.code !== "EEXIST")
127
+ throw error;
128
+ }
129
+ const held = readLock(lock);
130
+ if (held && !pidAlive(held.pid)) {
131
+ const guard = { pid: process.pid, token: randomUUID() };
132
+ try {
133
+ writeExclusive(reclaim, guard);
134
+ const current = readLock(lock);
135
+ if (current?.pid === held.pid && current.token === held.token && !pidAlive(current.pid))
136
+ rmSync(lock);
137
+ }
138
+ catch {
139
+ // Another process won reclamation, or the lock is malformed. Retry without failing open.
140
+ }
141
+ finally {
142
+ const currentGuard = readLock(reclaim);
143
+ if (currentGuard?.pid === process.pid && currentGuard.token === guard.token)
144
+ rmSync(reclaim, { force: true });
145
+ }
146
+ }
147
+ Atomics.wait(sleepCell, 0, 0, LOCK_WAIT_MS);
148
+ }
149
+ if (!claim)
150
+ throw new Error("task store is busy; retry the operation");
151
+ try {
152
+ return fn();
153
+ }
154
+ finally {
155
+ const current = readLock(lock);
156
+ if (current?.pid === process.pid && current.token === claim.token)
157
+ rmSync(lock, { force: true });
158
+ }
159
+ }
160
+ function isTaskItem(value) {
161
+ if (!value || typeof value !== "object")
162
+ return false;
163
+ const t = value;
164
+ return (typeof t.id === "string" && TASK_ID.test(t.id) &&
165
+ typeof t.subject === "string" && !!t.subject.trim() && t.subject.length <= 500 &&
166
+ typeof t.status === "string" && TASK_STATUSES.has(t.status) &&
167
+ (t.owner === undefined || (typeof t.owner === "string" && t.owner.length <= 128)) &&
168
+ (t.blockedBy === undefined ||
169
+ (Array.isArray(t.blockedBy) && t.blockedBy.length <= 100 && t.blockedBy.every((id) => typeof id === "string" && TASK_ID.test(id)))) &&
170
+ typeof t.createdAt === "string" && Number.isFinite(Date.parse(t.createdAt)) &&
171
+ typeof t.updatedAt === "string" && Number.isFinite(Date.parse(t.updatedAt)));
172
+ }
173
+ function parseTaskStore(raw, cwd) {
174
+ const parsed = JSON.parse(raw);
175
+ const canonical = canonicalTaskCwd(cwd);
176
+ if (parsed && !Array.isArray(parsed) && typeof parsed === "object" && typeof parsed.project === "string" && parsed.project !== canonical) {
177
+ throw new Error("task store project key mismatch");
178
+ }
179
+ const list = Array.isArray(parsed) ? parsed : parsed?.tasks;
180
+ if (!validTaskList(list))
181
+ throw new Error("task store contains invalid data");
182
+ return list;
183
+ }
184
+ function loadTasksUnlocked(cwd, strict) {
185
+ const file = taskFile(cwd);
186
+ if (!existsSync(file))
187
+ return [];
188
+ try {
189
+ return parseTaskStore(readFileSync(file, "utf8"), cwd);
190
+ }
191
+ catch (error) {
192
+ if (strict)
193
+ throw error;
194
+ return [];
195
+ }
196
+ }
197
+ function validTaskList(value) {
198
+ if (!Array.isArray(value) || value.length > 10_000 || !value.every(isTaskItem))
199
+ return false;
200
+ const ids = new Set(value.map((task) => task.id));
201
+ if (ids.size !== value.length)
202
+ return false;
203
+ if (!value.every((task) => {
204
+ const blockedBy = task.blockedBy ?? [];
205
+ return new Set(blockedBy).size === blockedBy.length && !blockedBy.includes(task.id) && blockedBy.every((id) => ids.has(id));
206
+ }))
207
+ return false;
208
+ const byId = new Map(value.map((task) => [task.id, task]));
209
+ const visiting = new Set();
210
+ const visited = new Set();
211
+ const cyclic = (id) => {
212
+ if (visiting.has(id))
213
+ return true;
214
+ if (visited.has(id))
215
+ return false;
216
+ visiting.add(id);
217
+ for (const dependency of byId.get(id)?.blockedBy ?? []) {
218
+ if (cyclic(dependency))
219
+ return true;
220
+ }
221
+ visiting.delete(id);
222
+ visited.add(id);
223
+ return false;
224
+ };
225
+ return value.every((task) => !cyclic(task.id));
226
+ }
227
+ export function loadTasks(cwd) {
228
+ return loadTasksUnlocked(cwd, false);
229
+ }
230
+ function atomicWrite(path, content) {
231
+ const tmp = `${path}.${process.pid}.${randomUUID()}.tmp`;
232
+ let fd;
233
+ try {
234
+ fd = openSync(tmp, "wx", 0o600);
235
+ writeFileSync(fd, content, "utf8");
236
+ fsyncSync(fd);
237
+ closeSync(fd);
238
+ fd = undefined;
239
+ renameSync(tmp, path);
240
+ try {
241
+ chmodSync(path, 0o600);
242
+ }
243
+ catch {
244
+ /* best effort on non-POSIX filesystems */
245
+ }
246
+ }
247
+ finally {
248
+ if (fd !== undefined)
249
+ closeSync(fd);
250
+ rmSync(tmp, { force: true });
251
+ }
252
+ }
253
+ function saveTasksUnlocked(cwd, list) {
254
+ if (!validTaskList(list)) {
255
+ throw new Error("refusing to save invalid task data");
256
+ }
257
+ atomicWrite(taskFile(cwd), JSON.stringify({ project: canonicalTaskCwd(cwd), tasks: list }, null, 2) + "\n");
258
+ }
259
+ export function saveTasks(cwd, list) {
260
+ withTaskLock(cwd, () => saveTasksUnlocked(cwd, list));
261
+ }
262
+ /** A task is blocked if any dependency is unfinished OR missing. Missing dependencies fail closed rather
263
+ * than accidentally making work runnable after a malformed edit/removal. */
264
+ export function isBlocked(t, all) {
265
+ return (t.blockedBy ?? []).some((id) => {
266
+ const dependency = all.find((x) => x.id === id);
267
+ return !dependency || dependency.status !== "done";
268
+ });
269
+ }
270
+ export function renderTasks(list) {
271
+ if (!list.length)
272
+ return "(no project tasks)";
273
+ const mark = { pending: "☐", in_progress: "▶", done: "☑" };
274
+ const open = list.filter((t) => t.status !== "done").length;
275
+ return (`Project tasks (${open} open / ${list.length} total):\n` +
276
+ list
277
+ .map((t) => {
278
+ const flags = [t.owner ? `@${t.owner}` : "", isBlocked(t, list) ? `⛔blocked by ${(t.blockedBy ?? []).join(",")}` : ""]
279
+ .filter(Boolean)
280
+ .join(" ");
281
+ return ` ${mark[t.status]} [${t.id}] ${t.subject}${flags ? ` (${flags})` : ""}`;
282
+ })
283
+ .join("\n"));
284
+ }
285
+ function invalid(message, list) {
286
+ return { list, reply: `task: invalid input (${message}).` };
287
+ }
288
+ function normalizeInput(input, list) {
289
+ if (!input || typeof input !== "object" || Array.isArray(input))
290
+ return { result: invalid("expected an object", list) };
291
+ const raw = input;
292
+ const allowed = new Set(["action", "subject", "id", "status", "owner", "blockedBy"]);
293
+ const extra = Object.keys(raw).find((key) => !allowed.has(key));
294
+ if (extra)
295
+ return { result: invalid(`unknown field '${extra}'`, list) };
296
+ if (typeof raw.action !== "string" || !["add", "update", "list", "remove"].includes(raw.action)) {
297
+ return { result: invalid("action must be add, update, list, or remove", list) };
298
+ }
299
+ const value = { action: raw.action };
300
+ if (raw.subject !== undefined) {
301
+ if (typeof raw.subject !== "string" || !raw.subject.trim() || raw.subject.trim().length > 500) {
302
+ return { result: invalid("subject must be 1-500 characters", list) };
303
+ }
304
+ value.subject = raw.subject.trim();
305
+ }
306
+ if (raw.id !== undefined) {
307
+ if (typeof raw.id !== "string" || !TASK_ID.test(raw.id))
308
+ return { result: invalid("id has an invalid format", list) };
309
+ value.id = raw.id;
310
+ }
311
+ if (raw.status !== undefined) {
312
+ if (typeof raw.status !== "string" || !TASK_STATUSES.has(raw.status))
313
+ return { result: invalid("status is invalid", list) };
314
+ value.status = raw.status;
315
+ }
316
+ if (raw.owner !== undefined) {
317
+ if (typeof raw.owner !== "string" || raw.owner.trim().length > 128)
318
+ return { result: invalid("owner must be at most 128 characters", list) };
319
+ value.owner = raw.owner.trim();
320
+ }
321
+ if (raw.blockedBy !== undefined) {
322
+ if (!Array.isArray(raw.blockedBy) || raw.blockedBy.length > 100 ||
323
+ !raw.blockedBy.every((id) => typeof id === "string" && TASK_ID.test(id)))
324
+ return { result: invalid("blockedBy must contain at most 100 valid task ids", list) };
325
+ value.blockedBy = [...new Set(raw.blockedBy)];
326
+ }
327
+ if (value.action === "list" && Object.keys(raw).length !== 1)
328
+ return { result: invalid("list accepts no other fields", list) };
329
+ if (value.action === "add") {
330
+ if (!value.subject)
331
+ return { result: invalid("subject is required for add", list) };
332
+ if (value.id !== undefined || value.status !== undefined)
333
+ return { result: invalid("add does not accept id or status", list) };
334
+ }
335
+ if (value.action === "update") {
336
+ if (!value.id)
337
+ return { result: invalid("id is required for update", list) };
338
+ if (value.subject === undefined && value.status === undefined && value.owner === undefined && value.blockedBy === undefined) {
339
+ return { result: invalid("update requires a field to change", list) };
340
+ }
341
+ }
342
+ if (value.action === "remove") {
343
+ if (!value.id)
344
+ return { result: invalid("id is required for remove", list) };
345
+ if (Object.keys(raw).some((key) => key !== "action" && key !== "id"))
346
+ return { result: invalid("remove accepts only id", list) };
347
+ }
348
+ return { value };
349
+ }
350
+ function newId(existing) {
351
+ let id;
352
+ do
353
+ id = `t${Date.now().toString(36)}${randomUUID().replace(/-/g, "").slice(0, 12)}`;
354
+ while (existing.some((t) => t.id === id));
355
+ return id;
356
+ }
357
+ /** Pure state transition — returns the new list + a reply string (testable without fs). */
358
+ export function applyTaskAction(list, input, nowIso) {
359
+ const normalized = normalizeInput(input, list);
360
+ if ("result" in normalized)
361
+ return normalized.result;
362
+ const value = normalized.value;
363
+ if (!Number.isFinite(Date.parse(nowIso)))
364
+ return invalid("timestamp is invalid", list);
365
+ if (value.action === "list")
366
+ return { list, reply: renderTasks(list) };
367
+ if (value.action === "add") {
368
+ const missing = (value.blockedBy ?? []).find((id) => !list.some((t) => t.id === id));
369
+ if (missing)
370
+ return invalid(`blockedBy references unknown task '${missing}'`, list);
371
+ const item = {
372
+ id: newId(list),
373
+ subject: value.subject,
374
+ status: "pending",
375
+ ...(value.owner ? { owner: value.owner } : {}),
376
+ ...(value.blockedBy?.length ? { blockedBy: value.blockedBy } : {}),
377
+ createdAt: nowIso,
378
+ updatedAt: nowIso,
379
+ };
380
+ const next = [...list, item];
381
+ return { list: next, reply: `added [${item.id}] ${item.subject}\n\n${renderTasks(next)}` };
382
+ }
383
+ const task = list.find((item) => item.id === value.id);
384
+ if (!task)
385
+ return { list, reply: `task ${value.action}: no task with id '${value.id ?? ""}' — use action:"list" to see ids.` };
386
+ if (value.action === "remove") {
387
+ const dependent = list.find((item) => item.id !== task.id && item.blockedBy?.includes(task.id));
388
+ if (dependent)
389
+ return invalid(`task '${task.id}' still blocks '${dependent.id}'; clear that dependency first`, list);
390
+ const next = list.filter((item) => item.id !== task.id);
391
+ return { list: next, reply: `removed [${task.id}] ${task.subject}` };
392
+ }
393
+ if (value.blockedBy?.includes(task.id))
394
+ return invalid("a task cannot block itself", list);
395
+ const missing = (value.blockedBy ?? []).find((id) => !list.some((item) => item.id === id));
396
+ if (missing)
397
+ return invalid(`blockedBy references unknown task '${missing}'`, list);
398
+ const next = list.map((item) => item.id !== task.id
399
+ ? item
400
+ : {
401
+ ...item,
402
+ ...(value.subject !== undefined ? { subject: value.subject } : {}),
403
+ ...(value.status !== undefined ? { status: value.status } : {}),
404
+ ...(value.owner !== undefined ? (value.owner ? { owner: value.owner } : { owner: undefined }) : {}),
405
+ ...(value.blockedBy !== undefined ? (value.blockedBy.length ? { blockedBy: value.blockedBy } : { blockedBy: undefined }) : {}),
406
+ updatedAt: nowIso,
407
+ });
408
+ if (!validTaskList(next))
409
+ return invalid("blockedBy would create a dependency cycle", list);
410
+ const updated = next.find((item) => item.id === task.id);
411
+ // Validate the whole merged graph, not only the edited task. Reopening a prerequisite must not silently
412
+ // leave an already-running/done dependent in a logically impossible state.
413
+ const blockedActive = next.find((item) => item.status !== "pending" && isBlocked(item, next));
414
+ if (blockedActive) {
415
+ return invalid(`task '${blockedActive.id}' cannot remain ${blockedActive.status} until every blockedBy task is done`, list);
416
+ }
417
+ return { list: next, reply: `updated [${updated.id}] → ${updated.status}${updated.owner ? ` @${updated.owner}` : ""}` };
418
+ }
419
+ registerTool({
420
+ name: "task",
421
+ description: "The PROJECT's persistent task pool — survives sessions and is shared by every hara run in this directory " +
422
+ "(unlike todo_write, which is your in-session scratchpad). Use it for work that outlives this conversation: " +
423
+ "backlog items, delegated work, multi-day efforts. Actions: `add` (subject, optional owner/blockedBy), " +
424
+ "`update` (id + status/subject/owner/blockedBy), `list`, `remove` (id). Dependencies: blockedBy holds task " +
425
+ "ids that must be done first. Keep subjects short and imperative.",
426
+ input_schema: {
427
+ type: "object",
428
+ properties: {
429
+ action: { type: "string", enum: ["add", "update", "list", "remove"] },
430
+ subject: { type: "string", minLength: 1, maxLength: 500, description: "task text (add/update) — short imperative phrase" },
431
+ id: { type: "string", minLength: 1, maxLength: 128, pattern: TASK_ID.source, description: "task id (update/remove) — see list output" },
432
+ status: { type: "string", enum: ["pending", "in_progress", "done"], description: "new status (update)" },
433
+ owner: { type: "string", maxLength: 128, description: "who's on it — a role/agent name (add/update; empty string clears)" },
434
+ blockedBy: { type: "array", maxItems: 100, uniqueItems: true, items: { type: "string", pattern: TASK_ID.source }, description: "task ids that must finish first (add/update; [] clears)" },
435
+ },
436
+ required: ["action"],
437
+ },
438
+ kind: "edit",
439
+ async run(input, ctx) {
440
+ try {
441
+ return withTaskLock(ctx.cwd, () => {
442
+ const list = loadTasksUnlocked(ctx.cwd, true);
443
+ const { list: next, reply } = applyTaskAction(list, input, new Date().toISOString());
444
+ if (next !== list)
445
+ saveTasksUnlocked(ctx.cwd, next);
446
+ return reply;
447
+ });
448
+ }
449
+ catch (error) {
450
+ return `task: ${error instanceof Error ? error.message : String(error)}`;
451
+ }
452
+ },
453
+ });
@@ -2,23 +2,55 @@
2
2
  // Claude Code's TodoWrite). Keeps the model organized on multi-step work and shows the user live progress.
3
3
  // In-memory, replace-whole-list semantics; kind:"read" so it never prompts and is safe to call freely.
4
4
  import { registerTool } from "./registry.js";
5
- let todos = [];
5
+ const DEFAULT_SCOPE = "default";
6
+ const stores = new Map([[DEFAULT_SCOPE, []]]);
7
+ function scopeKey(scope) {
8
+ return scope?.trim() || DEFAULT_SCOPE;
9
+ }
10
+ function scopedTodos(scope) {
11
+ return stores.get(scopeKey(scope)) ?? [];
12
+ }
13
+ /** Snapshot for persistence (session meta) — a defensive copy. */
14
+ export function serializeTodos(scope) {
15
+ return scopedTodos(scope).map((t) => ({ ...t, ...(t.blockedBy ? { blockedBy: [...t.blockedBy] } : {}) }));
16
+ }
17
+ /** Restore a persisted checklist (session resume) — replaces the selected run's list and notifies its UI. */
18
+ export function restoreTodos(list, scope) {
19
+ const key = scopeKey(scope);
20
+ const next = (Array.isArray(list) ? list : [])
21
+ .filter((t) => t && typeof t.text === "string" && t.text.trim())
22
+ .map((t) => ({
23
+ text: t.text,
24
+ status: (["pending", "in_progress", "done"].includes(t.status) ? t.status : "pending"),
25
+ ...(t.activeForm ? { activeForm: t.activeForm } : {}),
26
+ ...(Array.isArray(t.blockedBy) && t.blockedBy.length ? { blockedBy: t.blockedBy.map(String) } : {}),
27
+ ...(t.owner ? { owner: t.owner } : {}),
28
+ }));
29
+ stores.set(key, next);
30
+ emit(key);
31
+ }
6
32
  /** The current checklist (latest todo_write wins) — for a TUI/statusline to render. */
7
- export function currentTodos() {
8
- return todos;
33
+ export function currentTodos(scope) {
34
+ return scopedTodos(scope);
9
35
  }
10
- const listeners = new Set();
36
+ const listeners = new Map();
11
37
  /** Subscribe to checklist changes. Returns an unsubscribe fn. */
12
- export function onTodosChange(fn) {
13
- listeners.add(fn);
38
+ export function onTodosChange(fn, scope) {
39
+ const key = scopeKey(scope);
40
+ const set = listeners.get(key) ?? new Set();
41
+ set.add(fn);
42
+ listeners.set(key, set);
14
43
  return () => {
15
- listeners.delete(fn);
44
+ set.delete(fn);
45
+ if (!set.size)
46
+ listeners.delete(key);
16
47
  };
17
48
  }
18
- function emit() {
19
- for (const fn of listeners) {
49
+ function emit(scope) {
50
+ const list = scopedTodos(scope);
51
+ for (const fn of listeners.get(scope) ?? []) {
20
52
  try {
21
- fn(todos);
53
+ fn(list);
22
54
  }
23
55
  catch {
24
56
  /* listeners must not break the tool */
@@ -26,9 +58,20 @@ function emit() {
26
58
  }
27
59
  }
28
60
  /** Reset between sessions/turns if a runner wants a clean slate (not used by the tool itself). */
29
- export function clearTodos() {
30
- todos = [];
31
- emit();
61
+ export function clearTodos(scope) {
62
+ const key = scopeKey(scope);
63
+ stores.set(key, []);
64
+ emit(key);
65
+ }
66
+ /** Drop an ephemeral run's state entirely (used by completed sub-agents and deleted serve sessions). */
67
+ export function disposeTodoScope(scope) {
68
+ const key = scopeKey(scope);
69
+ if (key === DEFAULT_SCOPE) {
70
+ clearTodos();
71
+ return;
72
+ }
73
+ stores.delete(key);
74
+ listeners.delete(key);
32
75
  }
33
76
  const MARK = { pending: "☐", in_progress: "▶", done: "☑" };
34
77
  export function renderTodos(list) {
@@ -81,6 +124,8 @@ registerTool({
81
124
  description: "present-continuous form shown while in_progress (e.g. 'Running tests'). Always provide.",
82
125
  },
83
126
  status: { type: "string", enum: ["pending", "in_progress", "done"] },
127
+ blockedBy: { type: "array", items: { type: "string" }, description: "texts of items that must finish first (optional ordering hint)" },
128
+ owner: { type: "string", description: "who's on it when delegated (role/agent name) — optional" },
84
129
  },
85
130
  required: ["text", "status"],
86
131
  },
@@ -89,9 +134,10 @@ registerTool({
89
134
  required: ["todos"],
90
135
  },
91
136
  kind: "read", // pure state + display: never prompts, parallel-safe
92
- async run(input) {
137
+ async run(input, ctx) {
138
+ const scope = scopeKey(ctx.todoScope);
93
139
  const raw = Array.isArray(input.todos) ? input.todos : [];
94
- todos = raw
140
+ const todos = raw
95
141
  .map((t) => {
96
142
  const text = String(t?.text ?? "").trim();
97
143
  const status = (["pending", "in_progress", "done"].includes(t?.status) ? t.status : "pending");
@@ -99,10 +145,15 @@ registerTool({
99
145
  const item = { text, status };
100
146
  if (activeFormRaw)
101
147
  item.activeForm = activeFormRaw;
148
+ if (Array.isArray(t?.blockedBy) && t.blockedBy.length)
149
+ item.blockedBy = t.blockedBy.map(String);
150
+ if (typeof t?.owner === "string" && t.owner.trim())
151
+ item.owner = t.owner.trim();
102
152
  return item;
103
153
  })
104
154
  .filter((t) => t.text);
105
- emit();
155
+ stores.set(scope, todos);
156
+ emit(scope);
106
157
  return renderTodos(todos);
107
158
  },
108
159
  });