@hasna/todos 0.3.6 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli/index.js CHANGED
@@ -2067,19 +2067,54 @@ var require_commander = __commonJS((exports) => {
2067
2067
  import { Database } from "bun:sqlite";
2068
2068
  import { existsSync, mkdirSync } from "fs";
2069
2069
  import { dirname, join, resolve } from "path";
2070
+ function isInMemoryDb(path) {
2071
+ return path === ":memory:" || path.startsWith("file::memory:");
2072
+ }
2073
+ function findNearestTodosDb(startDir) {
2074
+ let dir = resolve(startDir);
2075
+ while (true) {
2076
+ const candidate = join(dir, ".todos", "todos.db");
2077
+ if (existsSync(candidate))
2078
+ return candidate;
2079
+ const parent = dirname(dir);
2080
+ if (parent === dir)
2081
+ break;
2082
+ dir = parent;
2083
+ }
2084
+ return null;
2085
+ }
2086
+ function findGitRoot(startDir) {
2087
+ let dir = resolve(startDir);
2088
+ while (true) {
2089
+ if (existsSync(join(dir, ".git")))
2090
+ return dir;
2091
+ const parent = dirname(dir);
2092
+ if (parent === dir)
2093
+ break;
2094
+ dir = parent;
2095
+ }
2096
+ return null;
2097
+ }
2070
2098
  function getDbPath() {
2071
2099
  if (process.env["TODOS_DB_PATH"]) {
2072
2100
  return process.env["TODOS_DB_PATH"];
2073
2101
  }
2074
2102
  const cwd = process.cwd();
2075
- const localDb = join(cwd, ".todos", "todos.db");
2076
- if (existsSync(localDb)) {
2077
- return localDb;
2103
+ const nearest = findNearestTodosDb(cwd);
2104
+ if (nearest)
2105
+ return nearest;
2106
+ if (process.env["TODOS_DB_SCOPE"] === "project") {
2107
+ const gitRoot = findGitRoot(cwd);
2108
+ if (gitRoot) {
2109
+ return join(gitRoot, ".todos", "todos.db");
2110
+ }
2078
2111
  }
2079
2112
  const home = process.env["HOME"] || process.env["USERPROFILE"] || "~";
2080
2113
  return join(home, ".todos", "todos.db");
2081
2114
  }
2082
2115
  function ensureDir(filePath) {
2116
+ if (isInMemoryDb(filePath))
2117
+ return;
2083
2118
  const dir = dirname(resolve(filePath));
2084
2119
  if (!existsSync(dir)) {
2085
2120
  mkdirSync(dir, { recursive: true });
@@ -2095,6 +2130,7 @@ function getDatabase(dbPath) {
2095
2130
  _db.run("PRAGMA busy_timeout = 5000");
2096
2131
  _db.run("PRAGMA foreign_keys = ON");
2097
2132
  runMigrations(_db);
2133
+ backfillTaskTags(_db);
2098
2134
  return _db;
2099
2135
  }
2100
2136
  function runMigrations(db) {
@@ -2110,6 +2146,35 @@ function runMigrations(db) {
2110
2146
  }
2111
2147
  }
2112
2148
  }
2149
+ function backfillTaskTags(db) {
2150
+ try {
2151
+ const count = db.query("SELECT COUNT(*) as count FROM task_tags").get();
2152
+ if (count && count.count > 0)
2153
+ return;
2154
+ } catch {
2155
+ return;
2156
+ }
2157
+ try {
2158
+ const rows = db.query("SELECT id, tags FROM tasks WHERE tags IS NOT NULL AND tags != '[]'").all();
2159
+ if (rows.length === 0)
2160
+ return;
2161
+ const insert = db.prepare("INSERT OR IGNORE INTO task_tags (task_id, tag) VALUES (?, ?)");
2162
+ for (const row of rows) {
2163
+ if (!row.tags)
2164
+ continue;
2165
+ let tags = [];
2166
+ try {
2167
+ tags = JSON.parse(row.tags);
2168
+ } catch {
2169
+ continue;
2170
+ }
2171
+ for (const tag of tags) {
2172
+ if (tag)
2173
+ insert.run(row.id, tag);
2174
+ }
2175
+ }
2176
+ } catch {}
2177
+ }
2113
2178
  function now() {
2114
2179
  return new Date().toISOString();
2115
2180
  }
@@ -2123,6 +2188,14 @@ function isLockExpired(lockedAt) {
2123
2188
  const expiryMs = LOCK_EXPIRY_MINUTES * 60 * 1000;
2124
2189
  return Date.now() - lockTime > expiryMs;
2125
2190
  }
2191
+ function lockExpiryCutoff(nowMs = Date.now()) {
2192
+ const expiryMs = LOCK_EXPIRY_MINUTES * 60 * 1000;
2193
+ return new Date(nowMs - expiryMs).toISOString();
2194
+ }
2195
+ function clearExpiredLocks(db) {
2196
+ const cutoff = lockExpiryCutoff();
2197
+ db.run("UPDATE tasks SET locked_by = NULL, locked_at = NULL WHERE locked_at IS NOT NULL AND locked_at < ?", [cutoff]);
2198
+ }
2126
2199
  function resolvePartialId(db, table, partialId) {
2127
2200
  if (partialId.length >= 36) {
2128
2201
  const row = db.query(`SELECT id FROM ${table} WHERE id = ?`).get(partialId);
@@ -2215,12 +2288,43 @@ var init_database = __esm(() => {
2215
2288
  );
2216
2289
 
2217
2290
  INSERT OR IGNORE INTO _migrations (id) VALUES (1);
2291
+ `,
2292
+ `
2293
+ ALTER TABLE projects ADD COLUMN task_list_id TEXT;
2294
+ INSERT OR IGNORE INTO _migrations (id) VALUES (2);
2295
+ `,
2296
+ `
2297
+ CREATE TABLE IF NOT EXISTS task_tags (
2298
+ task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
2299
+ tag TEXT NOT NULL,
2300
+ PRIMARY KEY (task_id, tag)
2301
+ );
2302
+ CREATE INDEX IF NOT EXISTS idx_task_tags_tag ON task_tags(tag);
2303
+ CREATE INDEX IF NOT EXISTS idx_task_tags_task ON task_tags(task_id);
2304
+
2305
+ INSERT OR IGNORE INTO _migrations (id) VALUES (3);
2306
+ `,
2307
+ `
2308
+ CREATE TABLE IF NOT EXISTS plans (
2309
+ id TEXT PRIMARY KEY,
2310
+ project_id TEXT REFERENCES projects(id) ON DELETE CASCADE,
2311
+ name TEXT NOT NULL,
2312
+ description TEXT,
2313
+ status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active', 'completed', 'archived')),
2314
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
2315
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
2316
+ );
2317
+ CREATE INDEX IF NOT EXISTS idx_plans_project ON plans(project_id);
2318
+ CREATE INDEX IF NOT EXISTS idx_plans_status ON plans(status);
2319
+ ALTER TABLE tasks ADD COLUMN plan_id TEXT REFERENCES plans(id) ON DELETE SET NULL;
2320
+ CREATE INDEX IF NOT EXISTS idx_tasks_plan ON tasks(plan_id);
2321
+ INSERT OR IGNORE INTO _migrations (id) VALUES (4);
2218
2322
  `
2219
2323
  ];
2220
2324
  });
2221
2325
 
2222
2326
  // src/types/index.ts
2223
- var VersionConflictError, TaskNotFoundError, LockError, DependencyCycleError;
2327
+ var VersionConflictError, TaskNotFoundError, ProjectNotFoundError, PlanNotFoundError, LockError, DependencyCycleError;
2224
2328
  var init_types = __esm(() => {
2225
2329
  VersionConflictError = class VersionConflictError extends Error {
2226
2330
  taskId;
@@ -2242,6 +2346,22 @@ var init_types = __esm(() => {
2242
2346
  this.name = "TaskNotFoundError";
2243
2347
  }
2244
2348
  };
2349
+ ProjectNotFoundError = class ProjectNotFoundError extends Error {
2350
+ projectId;
2351
+ constructor(projectId) {
2352
+ super(`Project not found: ${projectId}`);
2353
+ this.projectId = projectId;
2354
+ this.name = "ProjectNotFoundError";
2355
+ }
2356
+ };
2357
+ PlanNotFoundError = class PlanNotFoundError extends Error {
2358
+ planId;
2359
+ constructor(planId) {
2360
+ super(`Plan not found: ${planId}`);
2361
+ this.planId = planId;
2362
+ this.name = "PlanNotFoundError";
2363
+ }
2364
+ };
2245
2365
  LockError = class LockError extends Error {
2246
2366
  taskId;
2247
2367
  lockedBy;
@@ -2274,15 +2394,30 @@ function rowToTask(row) {
2274
2394
  priority: row.priority
2275
2395
  };
2276
2396
  }
2397
+ function insertTaskTags(taskId, tags, db) {
2398
+ if (tags.length === 0)
2399
+ return;
2400
+ const stmt = db.prepare("INSERT OR IGNORE INTO task_tags (task_id, tag) VALUES (?, ?)");
2401
+ for (const tag of tags) {
2402
+ if (tag)
2403
+ stmt.run(taskId, tag);
2404
+ }
2405
+ }
2406
+ function replaceTaskTags(taskId, tags, db) {
2407
+ db.run("DELETE FROM task_tags WHERE task_id = ?", [taskId]);
2408
+ insertTaskTags(taskId, tags, db);
2409
+ }
2277
2410
  function createTask(input, db) {
2278
2411
  const d = db || getDatabase();
2279
2412
  const id = uuid();
2280
2413
  const timestamp = now();
2281
- d.run(`INSERT INTO tasks (id, project_id, parent_id, title, description, status, priority, agent_id, assigned_to, session_id, working_dir, tags, metadata, version, created_at, updated_at)
2282
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?)`, [
2414
+ const tags = input.tags || [];
2415
+ d.run(`INSERT INTO tasks (id, project_id, parent_id, plan_id, title, description, status, priority, agent_id, assigned_to, session_id, working_dir, tags, metadata, version, created_at, updated_at)
2416
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?)`, [
2283
2417
  id,
2284
2418
  input.project_id || null,
2285
2419
  input.parent_id || null,
2420
+ input.plan_id || null,
2286
2421
  input.title,
2287
2422
  input.description || null,
2288
2423
  input.status || "pending",
@@ -2291,11 +2426,14 @@ function createTask(input, db) {
2291
2426
  input.assigned_to || null,
2292
2427
  input.session_id || null,
2293
2428
  input.working_dir || null,
2294
- JSON.stringify(input.tags || []),
2429
+ JSON.stringify(tags),
2295
2430
  JSON.stringify(input.metadata || {}),
2296
2431
  timestamp,
2297
2432
  timestamp
2298
2433
  ]);
2434
+ if (tags.length > 0) {
2435
+ insertTaskTags(id, tags, d);
2436
+ }
2299
2437
  return getTask(id, d);
2300
2438
  }
2301
2439
  function getTask(id, db) {
@@ -2333,6 +2471,7 @@ function getTaskWithRelations(id, db) {
2333
2471
  }
2334
2472
  function listTasks(filter = {}, db) {
2335
2473
  const d = db || getDatabase();
2474
+ clearExpiredLocks(d);
2336
2475
  const conditions = [];
2337
2476
  const params = [];
2338
2477
  if (filter.project_id) {
@@ -2378,9 +2517,13 @@ function listTasks(filter = {}, db) {
2378
2517
  params.push(filter.session_id);
2379
2518
  }
2380
2519
  if (filter.tags && filter.tags.length > 0) {
2381
- const tagConditions = filter.tags.map(() => "tags LIKE ?");
2382
- conditions.push(`(${tagConditions.join(" OR ")})`);
2383
- params.push(...filter.tags.map((t) => `%"${t}"%`));
2520
+ const placeholders = filter.tags.map(() => "?").join(",");
2521
+ conditions.push(`id IN (SELECT task_id FROM task_tags WHERE tag IN (${placeholders}))`);
2522
+ params.push(...filter.tags);
2523
+ }
2524
+ if (filter.plan_id) {
2525
+ conditions.push("plan_id = ?");
2526
+ params.push(filter.plan_id);
2384
2527
  }
2385
2528
  const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
2386
2529
  const rows = d.query(`SELECT * FROM tasks ${where} ORDER BY
@@ -2430,12 +2573,19 @@ function updateTask(id, input, db) {
2430
2573
  sets.push("metadata = ?");
2431
2574
  params.push(JSON.stringify(input.metadata));
2432
2575
  }
2576
+ if (input.plan_id !== undefined) {
2577
+ sets.push("plan_id = ?");
2578
+ params.push(input.plan_id);
2579
+ }
2433
2580
  params.push(id, input.version);
2434
2581
  const result = d.run(`UPDATE tasks SET ${sets.join(", ")} WHERE id = ? AND version = ?`, params);
2435
2582
  if (result.changes === 0) {
2436
2583
  const current = getTask(id, d);
2437
2584
  throw new VersionConflictError(id, input.version, current?.version ?? -1);
2438
2585
  }
2586
+ if (input.tags !== undefined) {
2587
+ replaceTaskTags(id, input.tags, d);
2588
+ }
2439
2589
  return getTask(id, d);
2440
2590
  }
2441
2591
  function deleteTask(id, db) {
@@ -2445,15 +2595,18 @@ function deleteTask(id, db) {
2445
2595
  }
2446
2596
  function startTask(id, agentId, db) {
2447
2597
  const d = db || getDatabase();
2448
- const task = getTask(id, d);
2449
- if (!task)
2450
- throw new TaskNotFoundError(id);
2451
- if (task.locked_by && task.locked_by !== agentId && !isLockExpired(task.locked_at)) {
2452
- throw new LockError(id, task.locked_by);
2453
- }
2598
+ const cutoff = lockExpiryCutoff();
2454
2599
  const timestamp = now();
2455
- d.run(`UPDATE tasks SET status = 'in_progress', assigned_to = ?, locked_by = ?, locked_at = ?, version = version + 1, updated_at = ?
2456
- WHERE id = ?`, [agentId, agentId, timestamp, timestamp, id]);
2600
+ const result = d.run(`UPDATE tasks SET status = 'in_progress', assigned_to = ?, locked_by = ?, locked_at = ?, version = version + 1, updated_at = ?
2601
+ WHERE id = ? AND (locked_by IS NULL OR locked_by = ? OR locked_at < ?)`, [agentId, agentId, timestamp, timestamp, id, agentId, cutoff]);
2602
+ if (result.changes === 0) {
2603
+ const current = getTask(id, d);
2604
+ if (!current)
2605
+ throw new TaskNotFoundError(id);
2606
+ if (current.locked_by && current.locked_by !== agentId && !isLockExpired(current.locked_at)) {
2607
+ throw new LockError(id, current.locked_by);
2608
+ }
2609
+ }
2457
2610
  return getTask(id, d);
2458
2611
  }
2459
2612
  function completeTask(id, agentId, db) {
@@ -2474,20 +2627,26 @@ function lockTask(id, agentId, db) {
2474
2627
  const task = getTask(id, d);
2475
2628
  if (!task)
2476
2629
  throw new TaskNotFoundError(id);
2477
- if (task.locked_by === agentId) {
2630
+ if (task.locked_by === agentId && !isLockExpired(task.locked_at)) {
2478
2631
  return { success: true, locked_by: agentId, locked_at: task.locked_at };
2479
2632
  }
2480
- if (task.locked_by && !isLockExpired(task.locked_at)) {
2481
- return {
2482
- success: false,
2483
- locked_by: task.locked_by,
2484
- locked_at: task.locked_at,
2485
- error: `Task is locked by ${task.locked_by}`
2486
- };
2487
- }
2633
+ const cutoff = lockExpiryCutoff();
2488
2634
  const timestamp = now();
2489
- d.run(`UPDATE tasks SET locked_by = ?, locked_at = ?, version = version + 1, updated_at = ?
2490
- WHERE id = ?`, [agentId, timestamp, timestamp, id]);
2635
+ const result = d.run(`UPDATE tasks SET locked_by = ?, locked_at = ?, version = version + 1, updated_at = ?
2636
+ WHERE id = ? AND (locked_by IS NULL OR locked_by = ? OR locked_at < ?)`, [agentId, timestamp, timestamp, id, agentId, cutoff]);
2637
+ if (result.changes === 0) {
2638
+ const current = getTask(id, d);
2639
+ if (!current)
2640
+ throw new TaskNotFoundError(id);
2641
+ if (current.locked_by && !isLockExpired(current.locked_at)) {
2642
+ return {
2643
+ success: false,
2644
+ locked_by: current.locked_by,
2645
+ locked_at: current.locked_at,
2646
+ error: `Task is locked by ${current.locked_by}`
2647
+ };
2648
+ }
2649
+ }
2491
2650
  return { success: true, locked_by: agentId, locked_at: timestamp };
2492
2651
  }
2493
2652
  function unlockTask(id, agentId, db) {
@@ -2542,12 +2701,16 @@ var init_tasks = __esm(() => {
2542
2701
  });
2543
2702
 
2544
2703
  // src/db/projects.ts
2704
+ function slugify(name) {
2705
+ return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
2706
+ }
2545
2707
  function createProject(input, db) {
2546
2708
  const d = db || getDatabase();
2547
2709
  const id = uuid();
2548
2710
  const timestamp = now();
2549
- d.run(`INSERT INTO projects (id, name, path, description, created_at, updated_at)
2550
- VALUES (?, ?, ?, ?, ?, ?)`, [id, input.name, input.path, input.description || null, timestamp, timestamp]);
2711
+ const taskListId = input.task_list_id ?? `todos-${slugify(input.name)}`;
2712
+ d.run(`INSERT INTO projects (id, name, path, description, task_list_id, created_at, updated_at)
2713
+ VALUES (?, ?, ?, ?, ?, ?, ?)`, [id, input.name, input.path, input.description || null, taskListId, timestamp, timestamp]);
2551
2714
  return getProject(id, d);
2552
2715
  }
2553
2716
  function getProject(id, db) {
@@ -2564,6 +2727,34 @@ function listProjects(db) {
2564
2727
  const d = db || getDatabase();
2565
2728
  return d.query("SELECT * FROM projects ORDER BY name").all();
2566
2729
  }
2730
+ function updateProject(id, input, db) {
2731
+ const d = db || getDatabase();
2732
+ const project = getProject(id, d);
2733
+ if (!project)
2734
+ throw new ProjectNotFoundError(id);
2735
+ const sets = ["updated_at = ?"];
2736
+ const params = [now()];
2737
+ if (input.name !== undefined) {
2738
+ sets.push("name = ?");
2739
+ params.push(input.name);
2740
+ }
2741
+ if (input.description !== undefined) {
2742
+ sets.push("description = ?");
2743
+ params.push(input.description);
2744
+ }
2745
+ if (input.task_list_id !== undefined) {
2746
+ sets.push("task_list_id = ?");
2747
+ params.push(input.task_list_id);
2748
+ }
2749
+ params.push(id);
2750
+ d.run(`UPDATE projects SET ${sets.join(", ")} WHERE id = ?`, params);
2751
+ return getProject(id, d);
2752
+ }
2753
+ function deleteProject(id, db) {
2754
+ const d = db || getDatabase();
2755
+ const result = d.run("DELETE FROM projects WHERE id = ?", [id]);
2756
+ return result.changes > 0;
2757
+ }
2567
2758
  function ensureProject(name, path, db) {
2568
2759
  const d = db || getDatabase();
2569
2760
  const existing = getProjectByPath(path, d);
@@ -2576,6 +2767,68 @@ var init_projects = __esm(() => {
2576
2767
  init_database();
2577
2768
  });
2578
2769
 
2770
+ // src/db/plans.ts
2771
+ function createPlan(input, db) {
2772
+ const d = db || getDatabase();
2773
+ const id = uuid();
2774
+ const timestamp = now();
2775
+ d.run(`INSERT INTO plans (id, project_id, name, description, status, created_at, updated_at)
2776
+ VALUES (?, ?, ?, ?, ?, ?, ?)`, [
2777
+ id,
2778
+ input.project_id || null,
2779
+ input.name,
2780
+ input.description || null,
2781
+ input.status || "active",
2782
+ timestamp,
2783
+ timestamp
2784
+ ]);
2785
+ return getPlan(id, d);
2786
+ }
2787
+ function getPlan(id, db) {
2788
+ const d = db || getDatabase();
2789
+ const row = d.query("SELECT * FROM plans WHERE id = ?").get(id);
2790
+ return row;
2791
+ }
2792
+ function listPlans(projectId, db) {
2793
+ const d = db || getDatabase();
2794
+ if (projectId) {
2795
+ return d.query("SELECT * FROM plans WHERE project_id = ? ORDER BY created_at DESC").all(projectId);
2796
+ }
2797
+ return d.query("SELECT * FROM plans ORDER BY created_at DESC").all();
2798
+ }
2799
+ function updatePlan(id, input, db) {
2800
+ const d = db || getDatabase();
2801
+ const plan = getPlan(id, d);
2802
+ if (!plan)
2803
+ throw new PlanNotFoundError(id);
2804
+ const sets = ["updated_at = ?"];
2805
+ const params = [now()];
2806
+ if (input.name !== undefined) {
2807
+ sets.push("name = ?");
2808
+ params.push(input.name);
2809
+ }
2810
+ if (input.description !== undefined) {
2811
+ sets.push("description = ?");
2812
+ params.push(input.description);
2813
+ }
2814
+ if (input.status !== undefined) {
2815
+ sets.push("status = ?");
2816
+ params.push(input.status);
2817
+ }
2818
+ params.push(id);
2819
+ d.run(`UPDATE plans SET ${sets.join(", ")} WHERE id = ?`, params);
2820
+ return getPlan(id, d);
2821
+ }
2822
+ function deletePlan(id, db) {
2823
+ const d = db || getDatabase();
2824
+ const result = d.run("DELETE FROM plans WHERE id = ?", [id]);
2825
+ return result.changes > 0;
2826
+ }
2827
+ var init_plans = __esm(() => {
2828
+ init_types();
2829
+ init_database();
2830
+ });
2831
+
2579
2832
  // src/db/comments.ts
2580
2833
  function addComment(input, db) {
2581
2834
  const d = db || getDatabase();
@@ -2621,8 +2874,9 @@ function rowToTask2(row) {
2621
2874
  }
2622
2875
  function searchTasks(query, projectId, db) {
2623
2876
  const d = db || getDatabase();
2877
+ clearExpiredLocks(d);
2624
2878
  const pattern = `%${query}%`;
2625
- let sql = `SELECT * FROM tasks WHERE (title LIKE ? OR description LIKE ? OR tags LIKE ?)`;
2879
+ let sql = `SELECT * FROM tasks WHERE (title LIKE ? OR description LIKE ? OR EXISTS (SELECT 1 FROM task_tags WHERE task_tags.task_id = tasks.id AND tag LIKE ?))`;
2626
2880
  const params = [pattern, pattern, pattern];
2627
2881
  if (projectId) {
2628
2882
  sql += " AND project_id = ?";
@@ -2638,11 +2892,28 @@ var init_search = __esm(() => {
2638
2892
  init_database();
2639
2893
  });
2640
2894
 
2641
- // src/lib/claude-tasks.ts
2642
- import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync, readdirSync, writeFileSync } from "fs";
2895
+ // src/lib/sync-utils.ts
2896
+ import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync, readdirSync as readdirSync2, statSync, writeFileSync } from "fs";
2643
2897
  import { join as join2 } from "path";
2644
- function getTaskListDir(taskListId) {
2645
- return join2(HOME, ".claude", "tasks", taskListId);
2898
+ function ensureDir2(dir) {
2899
+ if (!existsSync2(dir))
2900
+ mkdirSync2(dir, { recursive: true });
2901
+ }
2902
+ function listJsonFiles(dir) {
2903
+ if (!existsSync2(dir))
2904
+ return [];
2905
+ return readdirSync2(dir).filter((f) => f.endsWith(".json"));
2906
+ }
2907
+ function readJsonFile(path) {
2908
+ try {
2909
+ return JSON.parse(readFileSync(path, "utf-8"));
2910
+ } catch {
2911
+ return null;
2912
+ }
2913
+ }
2914
+ function writeJsonFile(path, data) {
2915
+ writeFileSync(path, JSON.stringify(data, null, 2) + `
2916
+ `);
2646
2917
  }
2647
2918
  function readHighWaterMark(dir) {
2648
2919
  const path = join2(dir, ".highwatermark");
@@ -2654,17 +2925,40 @@ function readHighWaterMark(dir) {
2654
2925
  function writeHighWaterMark(dir, value) {
2655
2926
  writeFileSync(join2(dir, ".highwatermark"), String(value));
2656
2927
  }
2657
- function readClaudeTask(dir, filename) {
2928
+ function getFileMtimeMs(path) {
2658
2929
  try {
2659
- const content = readFileSync(join2(dir, filename), "utf-8");
2660
- return JSON.parse(content);
2930
+ return statSync(path).mtimeMs;
2661
2931
  } catch {
2662
2932
  return null;
2663
2933
  }
2664
2934
  }
2935
+ function parseTimestamp(value) {
2936
+ if (typeof value !== "string")
2937
+ return null;
2938
+ const parsed = Date.parse(value);
2939
+ return Number.isNaN(parsed) ? null : parsed;
2940
+ }
2941
+ function appendSyncConflict(metadata, conflict, limit = 5) {
2942
+ const current = Array.isArray(metadata["sync_conflicts"]) ? metadata["sync_conflicts"] : [];
2943
+ const next = [conflict, ...current].slice(0, limit);
2944
+ return { ...metadata, sync_conflicts: next };
2945
+ }
2946
+ var HOME;
2947
+ var init_sync_utils = __esm(() => {
2948
+ HOME = process.env["HOME"] || process.env["USERPROFILE"] || "~";
2949
+ });
2950
+
2951
+ // src/lib/claude-tasks.ts
2952
+ import { existsSync as existsSync3 } from "fs";
2953
+ import { join as join3 } from "path";
2954
+ function getTaskListDir(taskListId) {
2955
+ return join3(HOME, ".claude", "tasks", taskListId);
2956
+ }
2957
+ function readClaudeTask(dir, filename) {
2958
+ return readJsonFile(join3(dir, filename));
2959
+ }
2665
2960
  function writeClaudeTask(dir, task) {
2666
- writeFileSync(join2(dir, `${task.id}.json`), JSON.stringify(task, null, 2) + `
2667
- `);
2961
+ writeJsonFile(join3(dir, `${task.id}.json`), task);
2668
2962
  }
2669
2963
  function toClaudeStatus(status) {
2670
2964
  if (status === "pending" || status === "in_progress" || status === "completed") {
@@ -2675,7 +2969,7 @@ function toClaudeStatus(status) {
2675
2969
  function toSqliteStatus(status) {
2676
2970
  return status;
2677
2971
  }
2678
- function taskToClaudeTask(task, claudeTaskId) {
2972
+ function taskToClaudeTask(task, claudeTaskId, existingMeta) {
2679
2973
  return {
2680
2974
  id: claudeTaskId,
2681
2975
  subject: task.title,
@@ -2686,39 +2980,80 @@ function taskToClaudeTask(task, claudeTaskId) {
2686
2980
  blocks: [],
2687
2981
  blockedBy: [],
2688
2982
  metadata: {
2983
+ ...existingMeta || {},
2689
2984
  todos_id: task.id,
2690
- priority: task.priority
2985
+ priority: task.priority,
2986
+ todos_updated_at: task.updated_at,
2987
+ todos_version: task.version
2691
2988
  }
2692
2989
  };
2693
2990
  }
2694
- function pushToClaudeTaskList(taskListId, projectId) {
2991
+ function pushToClaudeTaskList(taskListId, projectId, options = {}) {
2695
2992
  const dir = getTaskListDir(taskListId);
2696
- if (!existsSync2(dir))
2697
- mkdirSync2(dir, { recursive: true });
2993
+ if (!existsSync3(dir))
2994
+ ensureDir2(dir);
2698
2995
  const filter = {};
2699
2996
  if (projectId)
2700
2997
  filter["project_id"] = projectId;
2701
2998
  const tasks = listTasks(filter);
2702
2999
  const existingByTodosId = new Map;
2703
- const files = readdirSync(dir).filter((f) => f.endsWith(".json"));
3000
+ const files = listJsonFiles(dir);
2704
3001
  for (const f of files) {
3002
+ const path = join3(dir, f);
2705
3003
  const ct = readClaudeTask(dir, f);
2706
3004
  if (ct?.metadata?.["todos_id"]) {
2707
- existingByTodosId.set(ct.metadata["todos_id"], ct);
3005
+ existingByTodosId.set(ct.metadata["todos_id"], { task: ct, mtimeMs: getFileMtimeMs(path) });
2708
3006
  }
2709
3007
  }
2710
3008
  let hwm = readHighWaterMark(dir);
2711
3009
  let pushed = 0;
2712
3010
  const errors = [];
3011
+ const prefer = options.prefer || "remote";
2713
3012
  for (const task of tasks) {
2714
3013
  try {
2715
3014
  const existing = existingByTodosId.get(task.id);
2716
3015
  if (existing) {
2717
- const updated = taskToClaudeTask(task, existing.id);
2718
- updated.blocks = existing.blocks;
2719
- updated.blockedBy = existing.blockedBy;
2720
- updated.activeForm = existing.activeForm;
3016
+ const lastSyncedAt = parseTimestamp(existing.task.metadata?.["todos_updated_at"]);
3017
+ const localUpdatedAt = parseTimestamp(task.updated_at);
3018
+ const remoteUpdatedAt = existing.mtimeMs;
3019
+ let recordConflict = false;
3020
+ if (lastSyncedAt && localUpdatedAt && remoteUpdatedAt && localUpdatedAt > lastSyncedAt && remoteUpdatedAt > lastSyncedAt) {
3021
+ if (prefer === "remote") {
3022
+ const conflict = {
3023
+ agent: "claude",
3024
+ direction: "push",
3025
+ prefer,
3026
+ local_updated_at: task.updated_at,
3027
+ remote_updated_at: new Date(remoteUpdatedAt).toISOString(),
3028
+ detected_at: new Date().toISOString()
3029
+ };
3030
+ const newMeta = appendSyncConflict(task.metadata, conflict);
3031
+ updateTask(task.id, { version: task.version, metadata: newMeta });
3032
+ errors.push(`conflict push ${task.id}: remote newer`);
3033
+ continue;
3034
+ }
3035
+ recordConflict = true;
3036
+ }
3037
+ const updated = taskToClaudeTask(task, existing.task.id, existing.task.metadata);
3038
+ updated.blocks = existing.task.blocks;
3039
+ updated.blockedBy = existing.task.blockedBy;
3040
+ updated.activeForm = existing.task.activeForm;
2721
3041
  writeClaudeTask(dir, updated);
3042
+ if (recordConflict) {
3043
+ const latest = getTask(task.id);
3044
+ if (latest) {
3045
+ const conflict = {
3046
+ agent: "claude",
3047
+ direction: "push",
3048
+ prefer,
3049
+ local_updated_at: latest.updated_at,
3050
+ remote_updated_at: remoteUpdatedAt ? new Date(remoteUpdatedAt).toISOString() : undefined,
3051
+ detected_at: new Date().toISOString()
3052
+ };
3053
+ const newMeta = appendSyncConflict(latest.metadata, conflict);
3054
+ updateTask(latest.id, { version: latest.version, metadata: newMeta });
3055
+ }
3056
+ }
2722
3057
  } else {
2723
3058
  const claudeId = String(hwm);
2724
3059
  hwm++;
@@ -2738,14 +3073,15 @@ function pushToClaudeTaskList(taskListId, projectId) {
2738
3073
  writeHighWaterMark(dir, hwm);
2739
3074
  return { pushed, pulled: 0, errors };
2740
3075
  }
2741
- function pullFromClaudeTaskList(taskListId, projectId) {
3076
+ function pullFromClaudeTaskList(taskListId, projectId, options = {}) {
2742
3077
  const dir = getTaskListDir(taskListId);
2743
- if (!existsSync2(dir)) {
3078
+ if (!existsSync3(dir)) {
2744
3079
  return { pushed: 0, pulled: 0, errors: [`Task list directory not found: ${dir}`] };
2745
3080
  }
2746
3081
  const files = readdirSync(dir).filter((f) => f.endsWith(".json"));
2747
3082
  let pulled = 0;
2748
3083
  const errors = [];
3084
+ const prefer = options.prefer || "remote";
2749
3085
  const allTasks = listTasks({});
2750
3086
  const byClaudeId = new Map;
2751
3087
  for (const t of allTasks) {
@@ -2759,6 +3095,7 @@ function pullFromClaudeTaskList(taskListId, projectId) {
2759
3095
  }
2760
3096
  for (const f of files) {
2761
3097
  try {
3098
+ const filePath = join3(dir, f);
2762
3099
  const ct = readClaudeTask(dir, f);
2763
3100
  if (!ct)
2764
3101
  continue;
@@ -2769,13 +3106,33 @@ function pullFromClaudeTaskList(taskListId, projectId) {
2769
3106
  const existingByTodos = todosId ? byTodosId.get(todosId) : undefined;
2770
3107
  const existing = existingByMapping || existingByTodos;
2771
3108
  if (existing) {
3109
+ const lastSyncedAt = parseTimestamp(ct.metadata?.["todos_updated_at"]);
3110
+ const localUpdatedAt = parseTimestamp(existing.updated_at);
3111
+ const remoteUpdatedAt = getFileMtimeMs(filePath);
3112
+ let conflictMeta = null;
3113
+ if (lastSyncedAt && localUpdatedAt && remoteUpdatedAt && localUpdatedAt > lastSyncedAt && remoteUpdatedAt > lastSyncedAt) {
3114
+ const conflict = {
3115
+ agent: "claude",
3116
+ direction: "pull",
3117
+ prefer,
3118
+ local_updated_at: existing.updated_at,
3119
+ remote_updated_at: new Date(remoteUpdatedAt).toISOString(),
3120
+ detected_at: new Date().toISOString()
3121
+ };
3122
+ conflictMeta = appendSyncConflict(existing.metadata, conflict);
3123
+ if (prefer === "local") {
3124
+ updateTask(existing.id, { version: existing.version, metadata: conflictMeta });
3125
+ errors.push(`conflict pull ${existing.id}: local newer`);
3126
+ continue;
3127
+ }
3128
+ }
2772
3129
  updateTask(existing.id, {
2773
3130
  version: existing.version,
2774
3131
  title: ct.subject,
2775
3132
  description: ct.description || undefined,
2776
3133
  status: toSqliteStatus(ct.status),
2777
3134
  assigned_to: ct.owner || undefined,
2778
- metadata: { ...existing.metadata, claude_task_id: ct.id }
3135
+ metadata: { ...conflictMeta || existing.metadata, claude_task_id: ct.id, ...ct.metadata }
2779
3136
  });
2780
3137
  } else {
2781
3138
  createTask({
@@ -2784,7 +3141,7 @@ function pullFromClaudeTaskList(taskListId, projectId) {
2784
3141
  status: toSqliteStatus(ct.status),
2785
3142
  assigned_to: ct.owner || undefined,
2786
3143
  project_id: projectId,
2787
- metadata: { claude_task_id: ct.id },
3144
+ metadata: { ...ct.metadata, claude_task_id: ct.id },
2788
3145
  priority: ct.metadata?.["priority"] || "medium"
2789
3146
  });
2790
3147
  }
@@ -2795,19 +3152,349 @@ function pullFromClaudeTaskList(taskListId, projectId) {
2795
3152
  }
2796
3153
  return { pushed: 0, pulled, errors };
2797
3154
  }
2798
- function syncClaudeTaskList(taskListId, projectId) {
2799
- const pullResult = pullFromClaudeTaskList(taskListId, projectId);
2800
- const pushResult = pushToClaudeTaskList(taskListId, projectId);
3155
+ function syncClaudeTaskList(taskListId, projectId, options = {}) {
3156
+ const pullResult = pullFromClaudeTaskList(taskListId, projectId, options);
3157
+ const pushResult = pushToClaudeTaskList(taskListId, projectId, options);
2801
3158
  return {
2802
3159
  pushed: pushResult.pushed,
2803
3160
  pulled: pullResult.pulled,
2804
3161
  errors: [...pullResult.errors, ...pushResult.errors]
2805
3162
  };
2806
3163
  }
2807
- var HOME;
2808
3164
  var init_claude_tasks = __esm(() => {
2809
3165
  init_tasks();
2810
- HOME = process.env["HOME"] || process.env["USERPROFILE"] || "~";
3166
+ init_sync_utils();
3167
+ });
3168
+
3169
+ // src/lib/config.ts
3170
+ import { existsSync as existsSync4 } from "fs";
3171
+ import { join as join4 } from "path";
3172
+ function normalizeAgent(agent) {
3173
+ return agent.trim().toLowerCase();
3174
+ }
3175
+ function loadConfig() {
3176
+ if (cached)
3177
+ return cached;
3178
+ if (!existsSync4(CONFIG_PATH)) {
3179
+ cached = {};
3180
+ return cached;
3181
+ }
3182
+ const config = readJsonFile(CONFIG_PATH) || {};
3183
+ if (typeof config.sync_agents === "string") {
3184
+ config.sync_agents = config.sync_agents.split(",").map((a) => a.trim()).filter(Boolean);
3185
+ }
3186
+ cached = config;
3187
+ return cached;
3188
+ }
3189
+ function getSyncAgentsFromConfig() {
3190
+ const config = loadConfig();
3191
+ const agents = config.sync_agents;
3192
+ if (Array.isArray(agents) && agents.length > 0)
3193
+ return agents.map(normalizeAgent);
3194
+ return null;
3195
+ }
3196
+ function getAgentTaskListId(agent) {
3197
+ const config = loadConfig();
3198
+ const key = normalizeAgent(agent);
3199
+ return config.agents?.[key]?.task_list_id || config.task_list_id || null;
3200
+ }
3201
+ function getAgentTasksDir(agent) {
3202
+ const config = loadConfig();
3203
+ const key = normalizeAgent(agent);
3204
+ return config.agents?.[key]?.tasks_dir || config.agent_tasks_dir || null;
3205
+ }
3206
+ var CONFIG_PATH, cached = null;
3207
+ var init_config = __esm(() => {
3208
+ init_sync_utils();
3209
+ CONFIG_PATH = join4(HOME, ".todos", "config.json");
3210
+ });
3211
+
3212
+ // src/lib/agent-tasks.ts
3213
+ import { existsSync as existsSync5 } from "fs";
3214
+ import { join as join5 } from "path";
3215
+ function agentBaseDir(agent) {
3216
+ const key = `TODOS_${agent.toUpperCase()}_TASKS_DIR`;
3217
+ return process.env[key] || getAgentTasksDir(agent) || process.env["TODOS_AGENT_TASKS_DIR"] || join5(HOME, ".todos", "agents");
3218
+ }
3219
+ function getTaskListDir2(agent, taskListId) {
3220
+ return join5(agentBaseDir(agent), agent, taskListId);
3221
+ }
3222
+ function readAgentTask(dir, filename) {
3223
+ return readJsonFile(join5(dir, filename));
3224
+ }
3225
+ function writeAgentTask(dir, task) {
3226
+ writeJsonFile(join5(dir, `${task.id}.json`), task);
3227
+ }
3228
+ function taskToAgentTask(task, externalId, existingMeta) {
3229
+ return {
3230
+ id: externalId,
3231
+ title: task.title,
3232
+ description: task.description || "",
3233
+ status: task.status,
3234
+ priority: task.priority,
3235
+ assigned_to: task.assigned_to || task.agent_id || "",
3236
+ tags: task.tags || [],
3237
+ metadata: {
3238
+ ...existingMeta || {},
3239
+ ...task.metadata,
3240
+ todos_id: task.id,
3241
+ todos_updated_at: task.updated_at,
3242
+ todos_version: task.version
3243
+ }
3244
+ };
3245
+ }
3246
+ function metadataKey(agent) {
3247
+ return `${agent}_task_id`;
3248
+ }
3249
+ function pushToAgentTaskList(agent, taskListId, projectId, options = {}) {
3250
+ const dir = getTaskListDir2(agent, taskListId);
3251
+ if (!existsSync5(dir))
3252
+ ensureDir2(dir);
3253
+ const filter = {};
3254
+ if (projectId)
3255
+ filter["project_id"] = projectId;
3256
+ const tasks = listTasks(filter);
3257
+ const existingByTodosId = new Map;
3258
+ const files = listJsonFiles(dir);
3259
+ for (const f of files) {
3260
+ const path = join5(dir, f);
3261
+ const at = readAgentTask(dir, f);
3262
+ if (at?.metadata?.["todos_id"]) {
3263
+ existingByTodosId.set(at.metadata["todos_id"], { task: at, mtimeMs: getFileMtimeMs(path) });
3264
+ }
3265
+ }
3266
+ let hwm = readHighWaterMark(dir);
3267
+ let pushed = 0;
3268
+ const errors = [];
3269
+ const metaKey = metadataKey(agent);
3270
+ const prefer = options.prefer || "remote";
3271
+ for (const task of tasks) {
3272
+ try {
3273
+ const existing = existingByTodosId.get(task.id);
3274
+ if (existing) {
3275
+ const lastSyncedAt = parseTimestamp(existing.task.metadata?.["todos_updated_at"]);
3276
+ const localUpdatedAt = parseTimestamp(task.updated_at);
3277
+ const remoteUpdatedAt = existing.mtimeMs;
3278
+ let recordConflict = false;
3279
+ if (lastSyncedAt && localUpdatedAt && remoteUpdatedAt && localUpdatedAt > lastSyncedAt && remoteUpdatedAt > lastSyncedAt) {
3280
+ if (prefer === "remote") {
3281
+ const conflict = {
3282
+ agent,
3283
+ direction: "push",
3284
+ prefer,
3285
+ local_updated_at: task.updated_at,
3286
+ remote_updated_at: new Date(remoteUpdatedAt).toISOString(),
3287
+ detected_at: new Date().toISOString()
3288
+ };
3289
+ const newMeta = appendSyncConflict(task.metadata, conflict);
3290
+ updateTask(task.id, { version: task.version, metadata: newMeta });
3291
+ errors.push(`conflict push ${task.id}: remote newer`);
3292
+ continue;
3293
+ }
3294
+ recordConflict = true;
3295
+ }
3296
+ const updated = taskToAgentTask(task, existing.task.id, existing.task.metadata);
3297
+ writeAgentTask(dir, updated);
3298
+ if (recordConflict) {
3299
+ const latest = getTask(task.id);
3300
+ if (latest) {
3301
+ const conflict = {
3302
+ agent,
3303
+ direction: "push",
3304
+ prefer,
3305
+ local_updated_at: latest.updated_at,
3306
+ remote_updated_at: remoteUpdatedAt ? new Date(remoteUpdatedAt).toISOString() : undefined,
3307
+ detected_at: new Date().toISOString()
3308
+ };
3309
+ const newMeta = appendSyncConflict(latest.metadata, conflict);
3310
+ updateTask(latest.id, { version: latest.version, metadata: newMeta });
3311
+ }
3312
+ }
3313
+ } else {
3314
+ const externalId = String(hwm);
3315
+ hwm++;
3316
+ const at = taskToAgentTask(task, externalId);
3317
+ writeAgentTask(dir, at);
3318
+ const current = getTask(task.id);
3319
+ if (current) {
3320
+ const newMeta = { ...current.metadata, [metaKey]: externalId };
3321
+ updateTask(task.id, { version: current.version, metadata: newMeta });
3322
+ }
3323
+ }
3324
+ pushed++;
3325
+ } catch (e) {
3326
+ errors.push(`push ${task.id}: ${e instanceof Error ? e.message : String(e)}`);
3327
+ }
3328
+ }
3329
+ writeHighWaterMark(dir, hwm);
3330
+ return { pushed, pulled: 0, errors };
3331
+ }
3332
+ function pullFromAgentTaskList(agent, taskListId, projectId, options = {}) {
3333
+ const dir = getTaskListDir2(agent, taskListId);
3334
+ if (!existsSync5(dir)) {
3335
+ return { pushed: 0, pulled: 0, errors: [`Task list directory not found: ${dir}`] };
3336
+ }
3337
+ const files = listJsonFiles(dir);
3338
+ let pulled = 0;
3339
+ const errors = [];
3340
+ const metaKey = metadataKey(agent);
3341
+ const prefer = options.prefer || "remote";
3342
+ const allTasks = listTasks({});
3343
+ const byExternalId = new Map;
3344
+ const byTodosId = new Map;
3345
+ for (const t of allTasks) {
3346
+ const extId = t.metadata[metaKey];
3347
+ if (extId)
3348
+ byExternalId.set(String(extId), t);
3349
+ byTodosId.set(t.id, t);
3350
+ }
3351
+ for (const f of files) {
3352
+ try {
3353
+ const filePath = join5(dir, f);
3354
+ const at = readAgentTask(dir, f);
3355
+ if (!at)
3356
+ continue;
3357
+ if (at.metadata?.["_internal"])
3358
+ continue;
3359
+ const todosId = at.metadata?.["todos_id"];
3360
+ const existingByMapping = byExternalId.get(at.id);
3361
+ const existingByTodos = todosId ? byTodosId.get(todosId) : undefined;
3362
+ const existing = existingByMapping || existingByTodos;
3363
+ if (existing) {
3364
+ const lastSyncedAt = parseTimestamp(at.metadata?.["todos_updated_at"]);
3365
+ const localUpdatedAt = parseTimestamp(existing.updated_at);
3366
+ const remoteUpdatedAt = getFileMtimeMs(filePath);
3367
+ let conflictMeta = null;
3368
+ if (lastSyncedAt && localUpdatedAt && remoteUpdatedAt && localUpdatedAt > lastSyncedAt && remoteUpdatedAt > lastSyncedAt) {
3369
+ const conflict = {
3370
+ agent,
3371
+ direction: "pull",
3372
+ prefer,
3373
+ local_updated_at: existing.updated_at,
3374
+ remote_updated_at: new Date(remoteUpdatedAt).toISOString(),
3375
+ detected_at: new Date().toISOString()
3376
+ };
3377
+ conflictMeta = appendSyncConflict(existing.metadata, conflict);
3378
+ if (prefer === "local") {
3379
+ updateTask(existing.id, { version: existing.version, metadata: conflictMeta });
3380
+ errors.push(`conflict pull ${existing.id}: local newer`);
3381
+ continue;
3382
+ }
3383
+ }
3384
+ updateTask(existing.id, {
3385
+ version: existing.version,
3386
+ title: at.title,
3387
+ description: at.description || undefined,
3388
+ status: at.status,
3389
+ priority: at.priority,
3390
+ assigned_to: at.assigned_to || undefined,
3391
+ tags: at.tags || [],
3392
+ metadata: { ...conflictMeta || existing.metadata, ...at.metadata, [metaKey]: at.id }
3393
+ });
3394
+ } else {
3395
+ createTask({
3396
+ title: at.title,
3397
+ description: at.description || undefined,
3398
+ status: at.status,
3399
+ priority: at.priority || "medium",
3400
+ assigned_to: at.assigned_to || undefined,
3401
+ tags: at.tags || [],
3402
+ project_id: projectId,
3403
+ metadata: { ...at.metadata, [metaKey]: at.id }
3404
+ });
3405
+ }
3406
+ pulled++;
3407
+ } catch (e) {
3408
+ errors.push(`pull ${f}: ${e instanceof Error ? e.message : String(e)}`);
3409
+ }
3410
+ }
3411
+ return { pushed: 0, pulled, errors };
3412
+ }
3413
+ function syncAgentTaskList(agent, taskListId, projectId, options = {}) {
3414
+ const pullResult = pullFromAgentTaskList(agent, taskListId, projectId, options);
3415
+ const pushResult = pushToAgentTaskList(agent, taskListId, projectId, options);
3416
+ return {
3417
+ pushed: pushResult.pushed,
3418
+ pulled: pullResult.pulled,
3419
+ errors: [...pullResult.errors, ...pushResult.errors]
3420
+ };
3421
+ }
3422
+ var init_agent_tasks = __esm(() => {
3423
+ init_tasks();
3424
+ init_sync_utils();
3425
+ init_config();
3426
+ });
3427
+
3428
+ // src/lib/sync.ts
3429
+ function normalizeAgent2(agent) {
3430
+ return agent.trim().toLowerCase();
3431
+ }
3432
+ function isClaudeAgent(agent) {
3433
+ const a = normalizeAgent2(agent);
3434
+ return a === "claude" || a === "claude-code" || a === "claude_code";
3435
+ }
3436
+ function defaultSyncAgents() {
3437
+ const env = process.env["TODOS_SYNC_AGENTS"];
3438
+ if (env) {
3439
+ return env.split(",").map((a) => a.trim()).filter(Boolean);
3440
+ }
3441
+ const fromConfig = getSyncAgentsFromConfig();
3442
+ if (fromConfig && fromConfig.length > 0)
3443
+ return fromConfig;
3444
+ return ["claude", "codex", "gemini"];
3445
+ }
3446
+ function syncWithAgent(agent, taskListId, projectId, direction = "both", options = {}) {
3447
+ const normalized = normalizeAgent2(agent);
3448
+ if (isClaudeAgent(normalized)) {
3449
+ if (direction === "push")
3450
+ return pushToClaudeTaskList(taskListId, projectId, options);
3451
+ if (direction === "pull")
3452
+ return pullFromClaudeTaskList(taskListId, projectId, options);
3453
+ return syncClaudeTaskList(taskListId, projectId, options);
3454
+ }
3455
+ if (direction === "push")
3456
+ return pushToAgentTaskList(normalized, taskListId, projectId, options);
3457
+ if (direction === "pull")
3458
+ return pullFromAgentTaskList(normalized, taskListId, projectId, options);
3459
+ return syncAgentTaskList(normalized, taskListId, projectId, options);
3460
+ }
3461
+ function syncWithAgents(agents, taskListIdByAgent, projectId, direction = "both", options = {}) {
3462
+ let pushed = 0;
3463
+ let pulled = 0;
3464
+ const errors = [];
3465
+ const normalized = agents.map(normalizeAgent2);
3466
+ if (direction === "pull" || direction === "both") {
3467
+ for (const agent of normalized) {
3468
+ const listId = taskListIdByAgent(agent);
3469
+ if (!listId) {
3470
+ errors.push(`sync ${agent}: missing task list id`);
3471
+ continue;
3472
+ }
3473
+ const result = syncWithAgent(agent, listId, projectId, "pull", options);
3474
+ pushed += result.pushed;
3475
+ pulled += result.pulled;
3476
+ errors.push(...result.errors.map((e) => `${agent}: ${e}`));
3477
+ }
3478
+ }
3479
+ if (direction === "push" || direction === "both") {
3480
+ for (const agent of normalized) {
3481
+ const listId = taskListIdByAgent(agent);
3482
+ if (!listId) {
3483
+ errors.push(`sync ${agent}: missing task list id`);
3484
+ continue;
3485
+ }
3486
+ const result = syncWithAgent(agent, listId, projectId, "push", options);
3487
+ pushed += result.pushed;
3488
+ pulled += result.pulled;
3489
+ errors.push(...result.errors.map((e) => `${agent}: ${e}`));
3490
+ }
3491
+ }
3492
+ return { pushed, pulled, errors };
3493
+ }
3494
+ var init_sync = __esm(() => {
3495
+ init_claude_tasks();
3496
+ init_agent_tasks();
3497
+ init_config();
2811
3498
  });
2812
3499
 
2813
3500
  // node_modules/zod/v3/helpers/util.js
@@ -6785,6 +7472,8 @@ function formatError(error) {
6785
7472
  return `Version conflict: ${error.message}`;
6786
7473
  if (error instanceof TaskNotFoundError)
6787
7474
  return `Not found: ${error.message}`;
7475
+ if (error instanceof PlanNotFoundError)
7476
+ return `Not found: ${error.message}`;
6788
7477
  if (error instanceof LockError)
6789
7478
  return `Lock error: ${error.message}`;
6790
7479
  if (error instanceof DependencyCycleError)
@@ -6800,6 +7489,16 @@ function resolveId(partialId, table = "tasks") {
6800
7489
  throw new Error(`Could not resolve ID: ${partialId}`);
6801
7490
  return id;
6802
7491
  }
7492
+ function resolveTaskListId(agent, explicit) {
7493
+ if (explicit)
7494
+ return explicit;
7495
+ const normalized = agent.trim().toLowerCase();
7496
+ if (normalized === "claude" || normalized === "claude-code" || normalized === "claude_code") {
7497
+ return process.env["TODOS_CLAUDE_TASK_LIST"] || process.env["CLAUDE_CODE_TASK_LIST_ID"] || process.env["CLAUDE_CODE_SESSION_ID"] || getAgentTaskListId(normalized) || null;
7498
+ }
7499
+ const key = `TODOS_${normalized.toUpperCase()}_TASK_LIST`;
7500
+ return process.env[key] || process.env["TODOS_TASK_LIST_ID"] || getAgentTaskListId(normalized) || "default";
7501
+ }
6803
7502
  function formatTask(task) {
6804
7503
  const parts = [
6805
7504
  `ID: ${task.id}`,
@@ -6819,6 +7518,8 @@ function formatTask(task) {
6819
7518
  parts.push(`Parent: ${task.parent_id}`);
6820
7519
  if (task.project_id)
6821
7520
  parts.push(`Project: ${task.project_id}`);
7521
+ if (task.plan_id)
7522
+ parts.push(`Plan: ${task.plan_id}`);
6822
7523
  if (task.tags.length > 0)
6823
7524
  parts.push(`Tags: ${task.tags.join(", ")}`);
6824
7525
  parts.push(`Version: ${task.version}`);
@@ -6838,8 +7539,10 @@ var init_mcp = __esm(() => {
6838
7539
  init_tasks();
6839
7540
  init_comments();
6840
7541
  init_projects();
7542
+ init_plans();
6841
7543
  init_search();
6842
- init_claude_tasks();
7544
+ init_sync();
7545
+ init_config();
6843
7546
  init_database();
6844
7547
  init_types();
6845
7548
  server = new McpServer({
@@ -6857,6 +7560,7 @@ var init_mcp = __esm(() => {
6857
7560
  assigned_to: exports_external.string().optional().describe("Assigned agent ID"),
6858
7561
  session_id: exports_external.string().optional().describe("Session ID"),
6859
7562
  working_dir: exports_external.string().optional().describe("Working directory context"),
7563
+ plan_id: exports_external.string().optional().describe("Plan ID to assign task to"),
6860
7564
  tags: exports_external.array(exports_external.string()).optional().describe("Task tags"),
6861
7565
  metadata: exports_external.record(exports_external.unknown()).optional().describe("Arbitrary metadata")
6862
7566
  }, async (params) => {
@@ -6866,6 +7570,8 @@ var init_mcp = __esm(() => {
6866
7570
  resolved.project_id = resolveId(resolved.project_id, "projects");
6867
7571
  if (resolved.parent_id)
6868
7572
  resolved.parent_id = resolveId(resolved.parent_id);
7573
+ if (resolved.plan_id)
7574
+ resolved.plan_id = resolveId(resolved.plan_id, "plans");
6869
7575
  const task = createTask(resolved);
6870
7576
  return { content: [{ type: "text", text: `Task created:
6871
7577
  ${formatTask(task)}` }] };
@@ -6884,12 +7590,15 @@ ${formatTask(task)}` }] };
6884
7590
  exports_external.array(exports_external.enum(["low", "medium", "high", "critical"]))
6885
7591
  ]).optional().describe("Filter by priority"),
6886
7592
  assigned_to: exports_external.string().optional().describe("Filter by assigned agent"),
6887
- tags: exports_external.array(exports_external.string()).optional().describe("Filter by tags (any match)")
7593
+ tags: exports_external.array(exports_external.string()).optional().describe("Filter by tags (any match)"),
7594
+ plan_id: exports_external.string().optional().describe("Filter by plan")
6888
7595
  }, async (params) => {
6889
7596
  try {
6890
7597
  const resolved = { ...params };
6891
7598
  if (resolved.project_id)
6892
7599
  resolved.project_id = resolveId(resolved.project_id, "projects");
7600
+ if (resolved.plan_id)
7601
+ resolved.plan_id = resolveId(resolved.plan_id, "plans");
6893
7602
  const tasks = listTasks(resolved);
6894
7603
  if (tasks.length === 0) {
6895
7604
  return { content: [{ type: "text", text: "No tasks found." }] };
@@ -6963,7 +7672,8 @@ Parent: ${task.parent.id.slice(0, 8)} | ${task.parent.title}`);
6963
7672
  priority: exports_external.enum(["low", "medium", "high", "critical"]).optional().describe("New priority"),
6964
7673
  assigned_to: exports_external.string().optional().describe("Assign to agent"),
6965
7674
  tags: exports_external.array(exports_external.string()).optional().describe("New tags"),
6966
- metadata: exports_external.record(exports_external.unknown()).optional().describe("New metadata")
7675
+ metadata: exports_external.record(exports_external.unknown()).optional().describe("New metadata"),
7676
+ plan_id: exports_external.string().optional().describe("Plan ID to assign task to")
6967
7677
  }, async ({ id, ...rest }) => {
6968
7678
  try {
6969
7679
  const resolvedId = resolveId(id);
@@ -7074,45 +7784,151 @@ ${formatTask(task)}` }] };
7074
7784
  return { content: [{ type: "text", text: formatError(e) }], isError: true };
7075
7785
  }
7076
7786
  });
7077
- server.tool("add_comment", "Add a comment/note to a task", {
7078
- task_id: exports_external.string().describe("Task ID (full or partial)"),
7079
- content: exports_external.string().describe("Comment content"),
7080
- agent_id: exports_external.string().optional().describe("Agent adding comment"),
7081
- session_id: exports_external.string().optional().describe("Session ID")
7082
- }, async ({ task_id, ...rest }) => {
7787
+ server.tool("add_comment", "Add a comment/note to a task", {
7788
+ task_id: exports_external.string().describe("Task ID (full or partial)"),
7789
+ content: exports_external.string().describe("Comment content"),
7790
+ agent_id: exports_external.string().optional().describe("Agent adding comment"),
7791
+ session_id: exports_external.string().optional().describe("Session ID")
7792
+ }, async ({ task_id, ...rest }) => {
7793
+ try {
7794
+ const resolvedId = resolveId(task_id);
7795
+ const comment = addComment({ task_id: resolvedId, ...rest });
7796
+ return { content: [{ type: "text", text: `Comment added (${comment.id.slice(0, 8)}) at ${comment.created_at}` }] };
7797
+ } catch (e) {
7798
+ return { content: [{ type: "text", text: formatError(e) }], isError: true };
7799
+ }
7800
+ });
7801
+ server.tool("list_projects", "List all registered projects", {}, async () => {
7802
+ try {
7803
+ const projects = listProjects();
7804
+ if (projects.length === 0) {
7805
+ return { content: [{ type: "text", text: "No projects registered." }] };
7806
+ }
7807
+ const text = projects.map((p) => {
7808
+ const taskList = p.task_list_id ? ` [${p.task_list_id}]` : "";
7809
+ return `${p.id.slice(0, 8)} | ${p.name} | ${p.path}${taskList}${p.description ? ` - ${p.description}` : ""}`;
7810
+ }).join(`
7811
+ `);
7812
+ return { content: [{ type: "text", text: `${projects.length} project(s):
7813
+ ${text}` }] };
7814
+ } catch (e) {
7815
+ return { content: [{ type: "text", text: formatError(e) }], isError: true };
7816
+ }
7817
+ });
7818
+ server.tool("create_project", "Register a new project", {
7819
+ name: exports_external.string().describe("Project name"),
7820
+ path: exports_external.string().describe("Absolute path to project"),
7821
+ description: exports_external.string().optional().describe("Project description"),
7822
+ task_list_id: exports_external.string().optional().describe("Custom task list ID for Claude Code sync (defaults to todos-<slugified-name>)")
7823
+ }, async (params) => {
7824
+ try {
7825
+ const project = createProject(params);
7826
+ const taskList = project.task_list_id ? ` [${project.task_list_id}]` : "";
7827
+ return {
7828
+ content: [{
7829
+ type: "text",
7830
+ text: `Project created: ${project.id.slice(0, 8)} | ${project.name} | ${project.path}${taskList}`
7831
+ }]
7832
+ };
7833
+ } catch (e) {
7834
+ return { content: [{ type: "text", text: formatError(e) }], isError: true };
7835
+ }
7836
+ });
7837
+ server.tool("create_plan", "Create a new plan", {
7838
+ name: exports_external.string().describe("Plan name"),
7839
+ project_id: exports_external.string().optional().describe("Project ID"),
7840
+ description: exports_external.string().optional().describe("Plan description"),
7841
+ status: exports_external.enum(["active", "completed", "archived"]).optional().describe("Plan status")
7842
+ }, async (params) => {
7843
+ try {
7844
+ const resolved = { ...params };
7845
+ if (resolved.project_id)
7846
+ resolved.project_id = resolveId(resolved.project_id, "projects");
7847
+ const plan = createPlan(resolved);
7848
+ return {
7849
+ content: [{
7850
+ type: "text",
7851
+ text: `Plan created: ${plan.id.slice(0, 8)} | ${plan.name} | ${plan.status}`
7852
+ }]
7853
+ };
7854
+ } catch (e) {
7855
+ return { content: [{ type: "text", text: formatError(e) }], isError: true };
7856
+ }
7857
+ });
7858
+ server.tool("list_plans", "List plans with optional project filter", {
7859
+ project_id: exports_external.string().optional().describe("Filter by project")
7860
+ }, async ({ project_id }) => {
7861
+ try {
7862
+ const resolvedProjectId = project_id ? resolveId(project_id, "projects") : undefined;
7863
+ const plans = listPlans(resolvedProjectId);
7864
+ if (plans.length === 0) {
7865
+ return { content: [{ type: "text", text: "No plans found." }] };
7866
+ }
7867
+ const text = plans.map((p) => {
7868
+ const project = p.project_id ? ` (project: ${p.project_id.slice(0, 8)})` : "";
7869
+ return `[${p.status}] ${p.id.slice(0, 8)} | ${p.name}${project}`;
7870
+ }).join(`
7871
+ `);
7872
+ return { content: [{ type: "text", text: `${plans.length} plan(s):
7873
+ ${text}` }] };
7874
+ } catch (e) {
7875
+ return { content: [{ type: "text", text: formatError(e) }], isError: true };
7876
+ }
7877
+ });
7878
+ server.tool("get_plan", "Get plan details", {
7879
+ id: exports_external.string().describe("Plan ID (full or partial)")
7880
+ }, async ({ id }) => {
7083
7881
  try {
7084
- const resolvedId = resolveId(task_id);
7085
- const comment = addComment({ task_id: resolvedId, ...rest });
7086
- return { content: [{ type: "text", text: `Comment added (${comment.id.slice(0, 8)}) at ${comment.created_at}` }] };
7882
+ const resolvedId = resolveId(id, "plans");
7883
+ const plan = getPlan(resolvedId);
7884
+ if (!plan)
7885
+ return { content: [{ type: "text", text: `Plan not found: ${id}` }], isError: true };
7886
+ const parts = [
7887
+ `ID: ${plan.id}`,
7888
+ `Name: ${plan.name}`,
7889
+ `Status: ${plan.status}`
7890
+ ];
7891
+ if (plan.description)
7892
+ parts.push(`Description: ${plan.description}`);
7893
+ if (plan.project_id)
7894
+ parts.push(`Project: ${plan.project_id}`);
7895
+ parts.push(`Created: ${plan.created_at}`);
7896
+ parts.push(`Updated: ${plan.updated_at}`);
7897
+ return { content: [{ type: "text", text: parts.join(`
7898
+ `) }] };
7087
7899
  } catch (e) {
7088
7900
  return { content: [{ type: "text", text: formatError(e) }], isError: true };
7089
7901
  }
7090
7902
  });
7091
- server.tool("list_projects", "List all registered projects", {}, async () => {
7903
+ server.tool("update_plan", "Update a plan", {
7904
+ id: exports_external.string().describe("Plan ID (full or partial)"),
7905
+ name: exports_external.string().optional().describe("New name"),
7906
+ description: exports_external.string().optional().describe("New description"),
7907
+ status: exports_external.enum(["active", "completed", "archived"]).optional().describe("New status")
7908
+ }, async ({ id, ...rest }) => {
7092
7909
  try {
7093
- const projects = listProjects();
7094
- if (projects.length === 0) {
7095
- return { content: [{ type: "text", text: "No projects registered." }] };
7096
- }
7097
- const text = projects.map((p) => `${p.id.slice(0, 8)} | ${p.name} | ${p.path}${p.description ? ` - ${p.description}` : ""}`).join(`
7098
- `);
7099
- return { content: [{ type: "text", text: `${projects.length} project(s):
7100
- ${text}` }] };
7910
+ const resolvedId = resolveId(id, "plans");
7911
+ const plan = updatePlan(resolvedId, rest);
7912
+ return {
7913
+ content: [{
7914
+ type: "text",
7915
+ text: `Plan updated: ${plan.id.slice(0, 8)} | ${plan.name} | ${plan.status}`
7916
+ }]
7917
+ };
7101
7918
  } catch (e) {
7102
7919
  return { content: [{ type: "text", text: formatError(e) }], isError: true };
7103
7920
  }
7104
7921
  });
7105
- server.tool("create_project", "Register a new project", {
7106
- name: exports_external.string().describe("Project name"),
7107
- path: exports_external.string().describe("Absolute path to project"),
7108
- description: exports_external.string().optional().describe("Project description")
7109
- }, async (params) => {
7922
+ server.tool("delete_plan", "Delete a plan", {
7923
+ id: exports_external.string().describe("Plan ID (full or partial)")
7924
+ }, async ({ id }) => {
7110
7925
  try {
7111
- const project = createProject(params);
7926
+ const resolvedId = resolveId(id, "plans");
7927
+ const deleted = deletePlan(resolvedId);
7112
7928
  return {
7113
7929
  content: [{
7114
7930
  type: "text",
7115
- text: `Project created: ${project.id.slice(0, 8)} | ${project.name} | ${project.path}`
7931
+ text: deleted ? `Plan ${id} deleted.` : `Plan ${id} not found.`
7116
7932
  }]
7117
7933
  };
7118
7934
  } catch (e) {
@@ -7137,27 +7953,42 @@ ${text}` }] };
7137
7953
  return { content: [{ type: "text", text: formatError(e) }], isError: true };
7138
7954
  }
7139
7955
  });
7140
- server.tool("sync", "Sync tasks with a Claude Code task list. Writes SQLite tasks as JSON files to ~/.claude/tasks/<task_list_id>/ so they appear in Claude Code's native task UI. The task_list_id is your Claude Code session ID (visible in the conversation or via CLAUDE_CODE_SESSION_ID).", {
7141
- task_list_id: exports_external.string().describe("Claude Code task list ID \u2014 use your session ID"),
7142
- project_id: exports_external.string().optional().describe("Limit sync to a project"),
7143
- direction: exports_external.enum(["push", "pull", "both"]).optional().describe("Sync direction: push (SQLite->Claude), pull (Claude->SQLite), or both (default)")
7144
- }, async ({ task_list_id, project_id, direction }) => {
7956
+ server.tool("sync", "Sync tasks with an agent task list (Claude uses native task list; others use JSON lists).", {
7957
+ task_list_id: exports_external.string().optional().describe("Task list ID (required for Claude)"),
7958
+ agent: exports_external.string().optional().describe("Agent/provider name (default: claude)"),
7959
+ all_agents: exports_external.boolean().optional().describe("Sync across all configured agents"),
7960
+ project_id: exports_external.string().optional().describe("Project ID \u2014 its task_list_id will be used for Claude if task_list_id is not provided"),
7961
+ direction: exports_external.enum(["push", "pull", "both"]).optional().describe("Sync direction: push (SQLite->agent), pull (agent->SQLite), or both (default)"),
7962
+ prefer: exports_external.enum(["local", "remote"]).optional().describe("Conflict strategy")
7963
+ }, async ({ task_list_id, agent, all_agents, project_id, direction, prefer }) => {
7145
7964
  try {
7146
7965
  const resolvedProjectId = project_id ? resolveId(project_id, "projects") : undefined;
7147
- const taskListId = task_list_id;
7966
+ const project = resolvedProjectId ? getProject(resolvedProjectId) : undefined;
7967
+ const dir = direction ?? "both";
7968
+ const options = { prefer: prefer ?? "remote" };
7148
7969
  let result;
7149
- if (direction === "push") {
7150
- result = pushToClaudeTaskList(taskListId, resolvedProjectId);
7151
- } else if (direction === "pull") {
7152
- result = pullFromClaudeTaskList(taskListId, resolvedProjectId);
7970
+ if (all_agents) {
7971
+ const agents = defaultSyncAgents();
7972
+ result = syncWithAgents(agents, (a) => resolveTaskListId(a, task_list_id || project?.task_list_id || undefined), resolvedProjectId, dir, options);
7153
7973
  } else {
7154
- result = syncClaudeTaskList(taskListId, resolvedProjectId);
7974
+ const resolvedAgent = agent || "claude";
7975
+ const taskListId = resolveTaskListId(resolvedAgent, task_list_id || project?.task_list_id || undefined);
7976
+ if (!taskListId) {
7977
+ return {
7978
+ content: [{
7979
+ type: "text",
7980
+ text: `Could not determine task list ID for ${resolvedAgent}. Provide task_list_id or set task_list_id on the project.`
7981
+ }],
7982
+ isError: true
7983
+ };
7984
+ }
7985
+ result = syncWithAgent(resolvedAgent, taskListId, resolvedProjectId, dir, options);
7155
7986
  }
7156
7987
  const parts = [];
7157
7988
  if (result.pulled > 0)
7158
- parts.push(`Pulled ${result.pulled} task(s) from Claude task list.`);
7989
+ parts.push(`Pulled ${result.pulled} task(s).`);
7159
7990
  if (result.pushed > 0)
7160
- parts.push(`Pushed ${result.pushed} task(s) to Claude task list.`);
7991
+ parts.push(`Pushed ${result.pushed} task(s).`);
7161
7992
  if (result.pulled === 0 && result.pushed === 0 && result.errors.length === 0) {
7162
7993
  parts.push("Nothing to sync.");
7163
7994
  }
@@ -7187,30 +8018,34 @@ ${text}` }] };
7187
8018
  // src/server/serve.ts
7188
8019
  var exports_serve = {};
7189
8020
  __export(exports_serve, {
7190
- startServer: () => startServer
8021
+ startServer: () => startServer,
8022
+ createFetchHandler: () => createFetchHandler
7191
8023
  });
7192
8024
  import { execSync } from "child_process";
7193
- import { existsSync as existsSync3, readFileSync as readFileSync2 } from "fs";
7194
- import { join as join3, dirname as dirname2, extname } from "path";
8025
+ import { existsSync as existsSync6, readFileSync as readFileSync2 } from "fs";
8026
+ import { join as join6, dirname as dirname2, extname } from "path";
7195
8027
  import { fileURLToPath } from "url";
7196
8028
  function resolveDashboardDir() {
7197
8029
  const candidates = [];
7198
8030
  try {
7199
8031
  const scriptDir = dirname2(fileURLToPath(import.meta.url));
7200
- candidates.push(join3(scriptDir, "..", "dashboard", "dist"));
7201
- candidates.push(join3(scriptDir, "..", "..", "dashboard", "dist"));
8032
+ candidates.push(join6(scriptDir, "..", "dashboard", "dist"));
8033
+ candidates.push(join6(scriptDir, "..", "..", "dashboard", "dist"));
7202
8034
  } catch {}
7203
8035
  if (process.argv[1]) {
7204
8036
  const mainDir = dirname2(process.argv[1]);
7205
- candidates.push(join3(mainDir, "..", "dashboard", "dist"));
7206
- candidates.push(join3(mainDir, "..", "..", "dashboard", "dist"));
8037
+ candidates.push(join6(mainDir, "..", "dashboard", "dist"));
8038
+ candidates.push(join6(mainDir, "..", "..", "dashboard", "dist"));
7207
8039
  }
7208
- candidates.push(join3(process.cwd(), "dashboard", "dist"));
8040
+ candidates.push(join6(process.cwd(), "dashboard", "dist"));
7209
8041
  for (const candidate of candidates) {
7210
- if (existsSync3(candidate))
8042
+ if (existsSync6(candidate))
7211
8043
  return candidate;
7212
8044
  }
7213
- return join3(process.cwd(), "dashboard", "dist");
8045
+ return join6(process.cwd(), "dashboard", "dist");
8046
+ }
8047
+ function randomPort() {
8048
+ return 20000 + Math.floor(Math.random() * 20000);
7214
8049
  }
7215
8050
  function json(data, status = 200, port) {
7216
8051
  return new Response(JSON.stringify(data), {
@@ -7224,14 +8059,21 @@ function json(data, status = 200, port) {
7224
8059
  }
7225
8060
  function getPackageVersion() {
7226
8061
  try {
7227
- const pkgPath = join3(dirname2(fileURLToPath(import.meta.url)), "..", "..", "package.json");
8062
+ const pkgPath = join6(dirname2(fileURLToPath(import.meta.url)), "..", "..", "package.json");
7228
8063
  return JSON.parse(readFileSync2(pkgPath, "utf-8")).version || "0.0.0";
7229
8064
  } catch {
7230
8065
  return "0.0.0";
7231
8066
  }
7232
8067
  }
8068
+ async function parseJsonBody(req) {
8069
+ try {
8070
+ return await req.json();
8071
+ } catch {
8072
+ return null;
8073
+ }
8074
+ }
7233
8075
  function serveStaticFile(filePath) {
7234
- if (!existsSync3(filePath))
8076
+ if (!existsSync6(filePath))
7235
8077
  return null;
7236
8078
  const ext = extname(filePath);
7237
8079
  const contentType = MIME_TYPES[ext] || "application/octet-stream";
@@ -7239,299 +8081,474 @@ function serveStaticFile(filePath) {
7239
8081
  headers: { "Content-Type": contentType }
7240
8082
  });
7241
8083
  }
7242
- async function startServer(port, options) {
7243
- const shouldOpen = options?.open ?? true;
7244
- const dashboardDir = resolveDashboardDir();
7245
- const dashboardExists = existsSync3(dashboardDir);
7246
- if (!dashboardExists) {
7247
- console.error(`
7248
- Dashboard not found at: ${dashboardDir}`);
7249
- console.error(`Run this to build it:
7250
- `);
7251
- console.error(` cd dashboard && bun install && bun run build
7252
- `);
7253
- console.error(`Or from the project root:
7254
- `);
7255
- console.error(` bun run build:dashboard
7256
- `);
7257
- }
7258
- const server2 = Bun.serve({
7259
- port,
7260
- async fetch(req) {
7261
- const url = new URL(req.url);
7262
- const path = url.pathname;
7263
- const method = req.method;
7264
- if (path === "/api/tasks" && method === "GET") {
7265
- try {
7266
- const filter = {};
7267
- const status = url.searchParams.get("status");
7268
- const priority = url.searchParams.get("priority");
7269
- const projectId = url.searchParams.get("project_id");
7270
- if (status)
7271
- filter.status = status;
7272
- if (priority)
7273
- filter.priority = priority;
7274
- if (projectId)
7275
- filter.project_id = projectId;
7276
- const tasks = listTasks(filter);
7277
- const projectCache = new Map;
7278
- const enriched = tasks.map((t) => {
7279
- let projectName;
7280
- if (t.project_id) {
7281
- if (projectCache.has(t.project_id)) {
7282
- projectName = projectCache.get(t.project_id);
7283
- } else {
7284
- const p = getProject(t.project_id);
7285
- projectName = p?.name;
7286
- if (projectName)
7287
- projectCache.set(t.project_id, projectName);
7288
- }
7289
- }
7290
- return { ...t, project_name: projectName };
7291
- });
7292
- return json(enriched, 200, port);
7293
- } catch (e) {
7294
- return json({ error: e instanceof Error ? e.message : "Failed to list tasks" }, 500, port);
7295
- }
7296
- }
7297
- const taskGetMatch = path.match(/^\/api\/tasks\/([^/]+)$/);
7298
- if (taskGetMatch && method === "GET") {
7299
- try {
7300
- const id = taskGetMatch[1];
7301
- const task = getTaskWithRelations(id);
7302
- if (!task)
7303
- return json({ error: "Task not found" }, 404, port);
8084
+ function createFetchHandler(getPort, dashboardDir, dashboardExists) {
8085
+ const dir = dashboardDir || resolveDashboardDir();
8086
+ const hasDashboard = dashboardExists ?? existsSync6(dir);
8087
+ return async (req) => {
8088
+ const url = new URL(req.url);
8089
+ let path = url.pathname;
8090
+ if (path.startsWith("/api/v1/")) {
8091
+ path = path.replace("/api/v1", "/api");
8092
+ }
8093
+ const method = req.method;
8094
+ const port = getPort();
8095
+ if (path === "/api/tasks" && method === "GET") {
8096
+ try {
8097
+ const filter = {};
8098
+ const status = url.searchParams.get("status");
8099
+ const priority = url.searchParams.get("priority");
8100
+ const projectId = url.searchParams.get("project_id");
8101
+ if (status)
8102
+ filter.status = status;
8103
+ if (priority)
8104
+ filter.priority = priority;
8105
+ if (projectId)
8106
+ filter.project_id = projectId;
8107
+ const tasks = listTasks(filter);
8108
+ const projectCache = new Map;
8109
+ const planCache = new Map;
8110
+ const enriched = tasks.map((t) => {
7304
8111
  let projectName;
7305
- if (task.project_id) {
7306
- const p = getProject(task.project_id);
7307
- projectName = p?.name;
8112
+ if (t.project_id) {
8113
+ if (projectCache.has(t.project_id)) {
8114
+ projectName = projectCache.get(t.project_id);
8115
+ } else {
8116
+ const p = getProject(t.project_id);
8117
+ projectName = p?.name;
8118
+ if (projectName)
8119
+ projectCache.set(t.project_id, projectName);
8120
+ }
7308
8121
  }
7309
- return json({ ...task, project_name: projectName }, 200, port);
7310
- } catch (e) {
7311
- return json({ error: e instanceof Error ? e.message : "Failed to get task" }, 500, port);
7312
- }
7313
- }
7314
- if (path === "/api/tasks" && method === "POST") {
7315
- try {
7316
- const contentLength = parseInt(req.headers.get("content-length") || "0", 10);
7317
- if (contentLength > MAX_BODY_SIZE)
7318
- return json({ error: "Request body too large" }, 413, port);
7319
- const body = await req.json();
7320
- if (!body.title || typeof body.title !== "string") {
7321
- return json({ error: "Missing required field: title" }, 400, port);
8122
+ let planName;
8123
+ if (t.plan_id) {
8124
+ if (planCache.has(t.plan_id)) {
8125
+ planName = planCache.get(t.plan_id);
8126
+ } else {
8127
+ const pl = getPlan(t.plan_id);
8128
+ planName = pl?.name;
8129
+ if (planName)
8130
+ planCache.set(t.plan_id, planName);
8131
+ }
7322
8132
  }
7323
- const task = createTask({
7324
- title: body.title,
7325
- description: body.description,
7326
- priority: body.priority,
7327
- project_id: body.project_id,
7328
- parent_id: body.parent_id,
7329
- tags: body.tags,
7330
- assigned_to: body.assigned_to,
7331
- agent_id: body.agent_id,
7332
- status: body.status
7333
- });
7334
- return json(task, 201, port);
7335
- } catch (e) {
7336
- return json({ error: e instanceof Error ? e.message : "Failed to create task" }, 500, port);
7337
- }
8133
+ return { ...t, project_name: projectName, plan_name: planName };
8134
+ });
8135
+ return json(enriched, 200, port);
8136
+ } catch (e) {
8137
+ return json({ error: e instanceof Error ? e.message : "Failed to list tasks" }, 500, port);
7338
8138
  }
7339
- const taskPatchMatch = path.match(/^\/api\/tasks\/([^/]+)$/);
7340
- if (taskPatchMatch && method === "PATCH") {
7341
- try {
7342
- const id = taskPatchMatch[1];
7343
- const contentLength = parseInt(req.headers.get("content-length") || "0", 10);
7344
- if (contentLength > MAX_BODY_SIZE)
7345
- return json({ error: "Request body too large" }, 413, port);
7346
- const body = await req.json();
7347
- if (typeof body.version !== "number") {
7348
- return json({ error: "Missing required field: version" }, 400, port);
7349
- }
7350
- const task = updateTask(id, {
7351
- version: body.version,
7352
- title: body.title,
7353
- description: body.description,
7354
- status: body.status,
7355
- priority: body.priority,
7356
- assigned_to: body.assigned_to,
7357
- tags: body.tags,
7358
- metadata: body.metadata
7359
- });
7360
- return json(task, 200, port);
7361
- } catch (e) {
7362
- const status = e instanceof Error && e.name === "VersionConflictError" ? 409 : 500;
7363
- return json({ error: e instanceof Error ? e.message : "Failed to update task" }, status, port);
8139
+ }
8140
+ const taskGetMatch = path.match(/^\/api\/tasks\/([^/]+)$/);
8141
+ if (taskGetMatch && method === "GET") {
8142
+ try {
8143
+ const id = taskGetMatch[1];
8144
+ const task = getTaskWithRelations(id);
8145
+ if (!task)
8146
+ return json({ error: "Task not found" }, 404, port);
8147
+ let projectName;
8148
+ if (task.project_id) {
8149
+ const p = getProject(task.project_id);
8150
+ projectName = p?.name;
7364
8151
  }
8152
+ return json({ ...task, project_name: projectName }, 200, port);
8153
+ } catch (e) {
8154
+ return json({ error: e instanceof Error ? e.message : "Failed to get task" }, 500, port);
7365
8155
  }
7366
- const taskDeleteMatch = path.match(/^\/api\/tasks\/([^/]+)$/);
7367
- if (taskDeleteMatch && method === "DELETE") {
7368
- try {
7369
- const id = taskDeleteMatch[1];
7370
- const deleted = deleteTask(id);
7371
- if (!deleted)
7372
- return json({ error: "Task not found" }, 404, port);
7373
- return json({ deleted: true }, 200, port);
7374
- } catch (e) {
7375
- return json({ error: e instanceof Error ? e.message : "Failed to delete task" }, 500, port);
7376
- }
8156
+ }
8157
+ if (path === "/api/tasks" && method === "POST") {
8158
+ try {
8159
+ const contentLength = parseInt(req.headers.get("content-length") || "0", 10);
8160
+ if (contentLength > MAX_BODY_SIZE)
8161
+ return json({ error: "Request body too large" }, 413, port);
8162
+ const body = await parseJsonBody(req);
8163
+ if (!body)
8164
+ return json({ error: "Invalid JSON" }, 400, port);
8165
+ if (!body.title || typeof body.title !== "string") {
8166
+ return json({ error: "Missing required field: title" }, 400, port);
8167
+ }
8168
+ const parsed = createTaskSchema.safeParse(body);
8169
+ if (!parsed.success) {
8170
+ return json({ error: "Invalid request body" }, 400, port);
8171
+ }
8172
+ const task = createTask({
8173
+ title: parsed.data.title,
8174
+ description: parsed.data.description,
8175
+ priority: parsed.data.priority,
8176
+ project_id: parsed.data.project_id,
8177
+ parent_id: parsed.data.parent_id,
8178
+ plan_id: parsed.data.plan_id,
8179
+ tags: parsed.data.tags,
8180
+ assigned_to: parsed.data.assigned_to,
8181
+ agent_id: parsed.data.agent_id,
8182
+ status: parsed.data.status
8183
+ });
8184
+ return json(task, 201, port);
8185
+ } catch (e) {
8186
+ return json({ error: e instanceof Error ? e.message : "Failed to create task" }, 500, port);
7377
8187
  }
7378
- const taskStartMatch = path.match(/^\/api\/tasks\/([^/]+)\/start$/);
7379
- if (taskStartMatch && method === "POST") {
7380
- try {
7381
- const id = taskStartMatch[1];
7382
- const body = await req.json();
7383
- const agentId = body.agent_id || "dashboard";
7384
- const task = startTask(id, agentId);
7385
- return json(task, 200, port);
7386
- } catch (e) {
7387
- const status = e instanceof Error && e.name === "TaskNotFoundError" ? 404 : e instanceof Error && e.name === "LockError" ? 409 : 500;
7388
- return json({ error: e instanceof Error ? e.message : "Failed to start task" }, status, port);
7389
- }
7390
- }
7391
- const taskCompleteMatch = path.match(/^\/api\/tasks\/([^/]+)\/complete$/);
7392
- if (taskCompleteMatch && method === "POST") {
7393
- try {
7394
- const id = taskCompleteMatch[1];
7395
- const body = await req.json();
7396
- const agentId = body.agent_id;
7397
- const task = completeTask(id, agentId);
7398
- return json(task, 200, port);
7399
- } catch (e) {
7400
- const status = e instanceof Error && e.name === "TaskNotFoundError" ? 404 : e instanceof Error && e.name === "LockError" ? 409 : 500;
7401
- return json({ error: e instanceof Error ? e.message : "Failed to complete task" }, status, port);
7402
- }
7403
- }
7404
- const commentsGetMatch = path.match(/^\/api\/tasks\/([^/]+)\/comments$/);
7405
- if (commentsGetMatch && method === "GET") {
7406
- try {
7407
- const taskId = commentsGetMatch[1];
7408
- const comments = listComments(taskId);
7409
- return json(comments, 200, port);
7410
- } catch (e) {
7411
- return json({ error: e instanceof Error ? e.message : "Failed to list comments" }, 500, port);
7412
- }
8188
+ }
8189
+ const taskPatchMatch = path.match(/^\/api\/tasks\/([^/]+)$/);
8190
+ if (taskPatchMatch && method === "PATCH") {
8191
+ try {
8192
+ const id = taskPatchMatch[1];
8193
+ const contentLength = parseInt(req.headers.get("content-length") || "0", 10);
8194
+ if (contentLength > MAX_BODY_SIZE)
8195
+ return json({ error: "Request body too large" }, 413, port);
8196
+ const body = await parseJsonBody(req);
8197
+ if (!body)
8198
+ return json({ error: "Invalid JSON" }, 400, port);
8199
+ if (typeof body.version !== "number") {
8200
+ return json({ error: "Missing required field: version" }, 400, port);
8201
+ }
8202
+ const parsed = updateTaskSchema.safeParse(body);
8203
+ if (!parsed.success) {
8204
+ return json({ error: "Invalid request body" }, 400, port);
8205
+ }
8206
+ const task = updateTask(id, {
8207
+ version: parsed.data.version,
8208
+ title: parsed.data.title,
8209
+ description: parsed.data.description,
8210
+ status: parsed.data.status,
8211
+ priority: parsed.data.priority,
8212
+ assigned_to: parsed.data.assigned_to,
8213
+ plan_id: parsed.data.plan_id,
8214
+ tags: parsed.data.tags,
8215
+ metadata: parsed.data.metadata
8216
+ });
8217
+ return json(task, 200, port);
8218
+ } catch (e) {
8219
+ const status = e instanceof Error && e.name === "VersionConflictError" ? 409 : 500;
8220
+ return json({ error: e instanceof Error ? e.message : "Failed to update task" }, status, port);
7413
8221
  }
7414
- const commentsPostMatch = path.match(/^\/api\/tasks\/([^/]+)\/comments$/);
7415
- if (commentsPostMatch && method === "POST") {
7416
- try {
7417
- const taskId = commentsPostMatch[1];
7418
- const contentLength = parseInt(req.headers.get("content-length") || "0", 10);
7419
- if (contentLength > MAX_BODY_SIZE)
7420
- return json({ error: "Request body too large" }, 413, port);
7421
- const body = await req.json();
7422
- if (!body.content || typeof body.content !== "string") {
7423
- return json({ error: "Missing required field: content" }, 400, port);
7424
- }
7425
- const comment = addComment({
7426
- task_id: taskId,
7427
- content: body.content,
7428
- agent_id: body.agent_id,
7429
- session_id: body.session_id
7430
- });
7431
- return json(comment, 201, port);
7432
- } catch (e) {
7433
- return json({ error: e instanceof Error ? e.message : "Failed to add comment" }, 500, port);
7434
- }
8222
+ }
8223
+ const taskDeleteMatch = path.match(/^\/api\/tasks\/([^/]+)$/);
8224
+ if (taskDeleteMatch && method === "DELETE") {
8225
+ try {
8226
+ const id = taskDeleteMatch[1];
8227
+ const deleted = deleteTask(id);
8228
+ if (!deleted)
8229
+ return json({ error: "Task not found" }, 404, port);
8230
+ return json({ deleted: true }, 200, port);
8231
+ } catch (e) {
8232
+ return json({ error: e instanceof Error ? e.message : "Failed to delete task" }, 500, port);
7435
8233
  }
7436
- if (path === "/api/projects" && method === "GET") {
7437
- try {
7438
- const projects = listProjects();
7439
- return json(projects, 200, port);
7440
- } catch (e) {
7441
- return json({ error: e instanceof Error ? e.message : "Failed to list projects" }, 500, port);
7442
- }
8234
+ }
8235
+ const taskStartMatch = path.match(/^\/api\/tasks\/([^/]+)\/start$/);
8236
+ if (taskStartMatch && method === "POST") {
8237
+ try {
8238
+ const id = taskStartMatch[1];
8239
+ const body = await parseJsonBody(req);
8240
+ if (!body)
8241
+ return json({ error: "Invalid JSON" }, 400, port);
8242
+ const parsed = agentSchema.safeParse(body);
8243
+ if (!parsed.success)
8244
+ return json({ error: "Invalid request body" }, 400, port);
8245
+ const agentId = parsed.data.agent_id || "dashboard";
8246
+ const task = startTask(id, agentId);
8247
+ return json(task, 200, port);
8248
+ } catch (e) {
8249
+ const status = e instanceof Error && e.name === "TaskNotFoundError" ? 404 : e instanceof Error && e.name === "LockError" ? 409 : 500;
8250
+ return json({ error: e instanceof Error ? e.message : "Failed to start task" }, status, port);
8251
+ }
8252
+ }
8253
+ const taskCompleteMatch = path.match(/^\/api\/tasks\/([^/]+)\/complete$/);
8254
+ if (taskCompleteMatch && method === "POST") {
8255
+ try {
8256
+ const id = taskCompleteMatch[1];
8257
+ const body = await parseJsonBody(req);
8258
+ if (!body)
8259
+ return json({ error: "Invalid JSON" }, 400, port);
8260
+ const parsed = agentSchema.safeParse(body);
8261
+ if (!parsed.success)
8262
+ return json({ error: "Invalid request body" }, 400, port);
8263
+ const agentId = parsed.data.agent_id;
8264
+ const task = completeTask(id, agentId);
8265
+ return json(task, 200, port);
8266
+ } catch (e) {
8267
+ const status = e instanceof Error && e.name === "TaskNotFoundError" ? 404 : e instanceof Error && e.name === "LockError" ? 409 : 500;
8268
+ return json({ error: e instanceof Error ? e.message : "Failed to complete task" }, status, port);
8269
+ }
8270
+ }
8271
+ const commentsGetMatch = path.match(/^\/api\/tasks\/([^/]+)\/comments$/);
8272
+ if (commentsGetMatch && method === "GET") {
8273
+ try {
8274
+ const taskId = commentsGetMatch[1];
8275
+ const comments = listComments(taskId);
8276
+ return json(comments, 200, port);
8277
+ } catch (e) {
8278
+ return json({ error: e instanceof Error ? e.message : "Failed to list comments" }, 500, port);
7443
8279
  }
7444
- if (path === "/api/projects" && method === "POST") {
7445
- try {
7446
- const contentLength = parseInt(req.headers.get("content-length") || "0", 10);
7447
- if (contentLength > MAX_BODY_SIZE)
7448
- return json({ error: "Request body too large" }, 413, port);
7449
- const body = await req.json();
7450
- if (!body.name || typeof body.name !== "string") {
7451
- return json({ error: "Missing required field: name" }, 400, port);
7452
- }
7453
- const project = createProject({
7454
- name: body.name,
7455
- path: body.path || process.cwd(),
7456
- description: body.description
7457
- });
7458
- return json(project, 201, port);
7459
- } catch (e) {
7460
- return json({ error: e instanceof Error ? e.message : "Failed to create project" }, 500, port);
7461
- }
8280
+ }
8281
+ const commentsPostMatch = path.match(/^\/api\/tasks\/([^/]+)\/comments$/);
8282
+ if (commentsPostMatch && method === "POST") {
8283
+ try {
8284
+ const taskId = commentsPostMatch[1];
8285
+ const contentLength = parseInt(req.headers.get("content-length") || "0", 10);
8286
+ if (contentLength > MAX_BODY_SIZE)
8287
+ return json({ error: "Request body too large" }, 413, port);
8288
+ const body = await parseJsonBody(req);
8289
+ if (!body)
8290
+ return json({ error: "Invalid JSON" }, 400, port);
8291
+ if (!body.content || typeof body.content !== "string") {
8292
+ return json({ error: "Missing required field: content" }, 400, port);
8293
+ }
8294
+ const parsed = createCommentSchema.safeParse(body);
8295
+ if (!parsed.success) {
8296
+ return json({ error: "Invalid request body" }, 400, port);
8297
+ }
8298
+ const comment = addComment({
8299
+ task_id: taskId,
8300
+ content: parsed.data.content,
8301
+ agent_id: parsed.data.agent_id,
8302
+ session_id: parsed.data.session_id
8303
+ });
8304
+ return json(comment, 201, port);
8305
+ } catch (e) {
8306
+ return json({ error: e instanceof Error ? e.message : "Failed to add comment" }, 500, port);
7462
8307
  }
7463
- if (path === "/api/search" && method === "GET") {
7464
- try {
7465
- const q = url.searchParams.get("q");
7466
- if (!q)
7467
- return json({ error: "Missing query parameter: q" }, 400, port);
7468
- const projectId = url.searchParams.get("project_id") || undefined;
7469
- const results = searchTasks(q, projectId);
7470
- return json(results, 200, port);
7471
- } catch (e) {
7472
- return json({ error: e instanceof Error ? e.message : "Search failed" }, 500, port);
7473
- }
8308
+ }
8309
+ if (path === "/api/projects" && method === "GET") {
8310
+ try {
8311
+ const projects = listProjects();
8312
+ return json(projects, 200, port);
8313
+ } catch (e) {
8314
+ return json({ error: e instanceof Error ? e.message : "Failed to list projects" }, 500, port);
7474
8315
  }
7475
- if (path === "/api/system/version" && method === "GET") {
7476
- try {
7477
- const current = getPackageVersion();
7478
- const npmRes = await fetch("https://registry.npmjs.org/@hasna/todos/latest");
7479
- if (!npmRes.ok) {
7480
- return json({ current, latest: current, updateAvailable: false }, 200, port);
7481
- }
7482
- const data = await npmRes.json();
7483
- const latest = data.version;
7484
- return json({ current, latest, updateAvailable: current !== latest }, 200, port);
7485
- } catch {
7486
- const current = getPackageVersion();
8316
+ }
8317
+ if (path === "/api/projects" && method === "POST") {
8318
+ try {
8319
+ const contentLength = parseInt(req.headers.get("content-length") || "0", 10);
8320
+ if (contentLength > MAX_BODY_SIZE)
8321
+ return json({ error: "Request body too large" }, 413, port);
8322
+ const body = await parseJsonBody(req);
8323
+ if (!body)
8324
+ return json({ error: "Invalid JSON" }, 400, port);
8325
+ if (!body.name || typeof body.name !== "string") {
8326
+ return json({ error: "Missing required field: name" }, 400, port);
8327
+ }
8328
+ const parsed = createProjectSchema.safeParse(body);
8329
+ if (!parsed.success) {
8330
+ return json({ error: "Invalid request body" }, 400, port);
8331
+ }
8332
+ const project = createProject({
8333
+ name: parsed.data.name,
8334
+ path: parsed.data.path || process.cwd(),
8335
+ description: parsed.data.description,
8336
+ task_list_id: parsed.data.task_list_id
8337
+ });
8338
+ return json(project, 201, port);
8339
+ } catch (e) {
8340
+ return json({ error: e instanceof Error ? e.message : "Failed to create project" }, 500, port);
8341
+ }
8342
+ }
8343
+ if (path === "/api/search" && method === "GET") {
8344
+ try {
8345
+ const q = url.searchParams.get("q");
8346
+ if (!q)
8347
+ return json({ error: "Missing query parameter: q" }, 400, port);
8348
+ const projectId = url.searchParams.get("project_id") || undefined;
8349
+ const results = searchTasks(q, projectId);
8350
+ return json(results, 200, port);
8351
+ } catch (e) {
8352
+ return json({ error: e instanceof Error ? e.message : "Search failed" }, 500, port);
8353
+ }
8354
+ }
8355
+ if (path === "/api/system/version" && method === "GET") {
8356
+ try {
8357
+ const current = getPackageVersion();
8358
+ const npmRes = await fetch("https://registry.npmjs.org/@hasna/todos/latest");
8359
+ if (!npmRes.ok) {
7487
8360
  return json({ current, latest: current, updateAvailable: false }, 200, port);
7488
8361
  }
8362
+ const data = await npmRes.json();
8363
+ const latest = data.version;
8364
+ return json({ current, latest, updateAvailable: current !== latest }, 200, port);
8365
+ } catch {
8366
+ const current = getPackageVersion();
8367
+ return json({ current, latest: current, updateAvailable: false }, 200, port);
7489
8368
  }
7490
- if (path === "/api/system/update" && method === "POST") {
8369
+ }
8370
+ if (path === "/api/system/update" && method === "POST") {
8371
+ try {
8372
+ let useBun = false;
7491
8373
  try {
7492
- let useBun = false;
7493
- try {
7494
- execSync("which bun", { stdio: "ignore" });
7495
- useBun = true;
7496
- } catch {}
7497
- const cmd = useBun ? "bun add -g @hasna/todos@latest" : "npm install -g @hasna/todos@latest";
7498
- execSync(cmd, { stdio: "ignore", timeout: 60000 });
7499
- return json({ success: true, message: "Updated! Restart the server to use the new version." }, 200, port);
7500
- } catch (e) {
7501
- return json({ success: false, message: e instanceof Error ? e.message : "Update failed" }, 500, port);
7502
- }
7503
- }
7504
- if (method === "OPTIONS") {
7505
- return new Response(null, {
7506
- headers: {
7507
- "Access-Control-Allow-Origin": `http://localhost:${port}`,
7508
- "Access-Control-Allow-Methods": "GET, POST, PATCH, DELETE, OPTIONS",
7509
- "Access-Control-Allow-Headers": "Content-Type"
8374
+ execSync("which bun", { stdio: "ignore" });
8375
+ useBun = true;
8376
+ } catch {}
8377
+ const cmd = useBun ? "bun add -g @hasna/todos@latest" : "npm install -g @hasna/todos@latest";
8378
+ execSync(cmd, { stdio: "ignore", timeout: 60000 });
8379
+ return json({ success: true, message: "Updated! Restart the server to use the new version." }, 200, port);
8380
+ } catch (e) {
8381
+ return json({ success: false, message: e instanceof Error ? e.message : "Update failed" }, 500, port);
8382
+ }
8383
+ }
8384
+ if (path === "/api/plans" && method === "GET") {
8385
+ try {
8386
+ const projectId = url.searchParams.get("project_id") || undefined;
8387
+ const plans = listPlans(projectId);
8388
+ const enriched = plans.map((p) => {
8389
+ const db = getDatabase();
8390
+ const row = db.query("SELECT COUNT(*) as count FROM tasks WHERE plan_id = ?").get(p.id);
8391
+ let projectName;
8392
+ if (p.project_id) {
8393
+ const proj = getProject(p.project_id);
8394
+ projectName = proj?.name;
7510
8395
  }
8396
+ return { ...p, task_count: row?.count ?? 0, project_name: projectName };
7511
8397
  });
8398
+ return json(enriched, 200, port);
8399
+ } catch (e) {
8400
+ return json({ error: e instanceof Error ? e.message : "Failed to list plans" }, 500, port);
8401
+ }
8402
+ }
8403
+ if (path === "/api/plans" && method === "POST") {
8404
+ try {
8405
+ const contentLength = parseInt(req.headers.get("content-length") || "0", 10);
8406
+ if (contentLength > MAX_BODY_SIZE)
8407
+ return json({ error: "Request body too large" }, 413, port);
8408
+ const body = await parseJsonBody(req);
8409
+ if (!body)
8410
+ return json({ error: "Invalid JSON" }, 400, port);
8411
+ if (!body.name || typeof body.name !== "string") {
8412
+ return json({ error: "Missing required field: name" }, 400, port);
8413
+ }
8414
+ const parsed = createPlanSchema.safeParse(body);
8415
+ if (!parsed.success) {
8416
+ return json({ error: "Invalid request body" }, 400, port);
8417
+ }
8418
+ const plan = createPlan(parsed.data);
8419
+ return json(plan, 201, port);
8420
+ } catch (e) {
8421
+ return json({ error: e instanceof Error ? e.message : "Failed to create plan" }, 500, port);
8422
+ }
8423
+ }
8424
+ const planGetMatch = path.match(/^\/api\/plans\/([^/]+)$/);
8425
+ if (planGetMatch && method === "GET") {
8426
+ try {
8427
+ const id = planGetMatch[1];
8428
+ const plan = getPlan(id);
8429
+ if (!plan)
8430
+ return json({ error: "Plan not found" }, 404, port);
8431
+ return json(plan, 200, port);
8432
+ } catch (e) {
8433
+ return json({ error: e instanceof Error ? e.message : "Failed to get plan" }, 500, port);
8434
+ }
8435
+ }
8436
+ const planPatchMatch = path.match(/^\/api\/plans\/([^/]+)$/);
8437
+ if (planPatchMatch && method === "PATCH") {
8438
+ try {
8439
+ const id = planPatchMatch[1];
8440
+ const contentLength = parseInt(req.headers.get("content-length") || "0", 10);
8441
+ if (contentLength > MAX_BODY_SIZE)
8442
+ return json({ error: "Request body too large" }, 413, port);
8443
+ const body = await parseJsonBody(req);
8444
+ if (!body)
8445
+ return json({ error: "Invalid JSON" }, 400, port);
8446
+ const parsed = updatePlanSchema.safeParse(body);
8447
+ if (!parsed.success) {
8448
+ return json({ error: "Invalid request body" }, 400, port);
8449
+ }
8450
+ const plan = updatePlan(id, parsed.data);
8451
+ return json(plan, 200, port);
8452
+ } catch (e) {
8453
+ const status = e instanceof Error && e.name === "PlanNotFoundError" ? 404 : 500;
8454
+ return json({ error: e instanceof Error ? e.message : "Failed to update plan" }, status, port);
8455
+ }
8456
+ }
8457
+ const planDeleteMatch = path.match(/^\/api\/plans\/([^/]+)$/);
8458
+ if (planDeleteMatch && method === "DELETE") {
8459
+ try {
8460
+ const id = planDeleteMatch[1];
8461
+ const deleted = deletePlan(id);
8462
+ if (!deleted)
8463
+ return json({ error: "Plan not found" }, 404, port);
8464
+ return json({ deleted: true }, 200, port);
8465
+ } catch (e) {
8466
+ return json({ error: e instanceof Error ? e.message : "Failed to delete plan" }, 500, port);
7512
8467
  }
7513
- if (dashboardExists && (method === "GET" || method === "HEAD")) {
7514
- if (path !== "/") {
7515
- const filePath = join3(dashboardDir, path);
7516
- const res2 = serveStaticFile(filePath);
7517
- if (res2)
7518
- return res2;
8468
+ }
8469
+ const projectDeleteMatch = path.match(/^\/api\/projects\/([^/]+)$/);
8470
+ if (projectDeleteMatch && method === "DELETE") {
8471
+ try {
8472
+ const id = projectDeleteMatch[1];
8473
+ const deleted = deleteProject(id);
8474
+ if (!deleted)
8475
+ return json({ error: "Project not found" }, 404, port);
8476
+ return json({ deleted: true }, 200, port);
8477
+ } catch (e) {
8478
+ return json({ error: e instanceof Error ? e.message : "Failed to delete project" }, 500, port);
8479
+ }
8480
+ }
8481
+ if (method === "OPTIONS") {
8482
+ return new Response(null, {
8483
+ headers: {
8484
+ "Access-Control-Allow-Origin": `http://localhost:${port}`,
8485
+ "Access-Control-Allow-Methods": "GET, POST, PATCH, DELETE, OPTIONS",
8486
+ "Access-Control-Allow-Headers": "Content-Type"
7519
8487
  }
7520
- const indexPath = join3(dashboardDir, "index.html");
7521
- const res = serveStaticFile(indexPath);
7522
- if (res)
7523
- return res;
8488
+ });
8489
+ }
8490
+ if (hasDashboard && (method === "GET" || method === "HEAD")) {
8491
+ if (path !== "/") {
8492
+ const filePath = join6(dir, path);
8493
+ const res2 = serveStaticFile(filePath);
8494
+ if (res2)
8495
+ return res2;
7524
8496
  }
7525
- return json({ error: "Not found" }, 404, port);
8497
+ const indexPath = join6(dir, "index.html");
8498
+ const res = serveStaticFile(indexPath);
8499
+ if (res)
8500
+ return res;
7526
8501
  }
7527
- });
8502
+ return json({ error: "Not found" }, 404, port);
8503
+ };
8504
+ }
8505
+ async function startServer(port, options) {
8506
+ const shouldOpen = options?.open ?? true;
8507
+ const dashboardDir = resolveDashboardDir();
8508
+ const dashboardExists = existsSync6(dashboardDir);
8509
+ if (!dashboardExists) {
8510
+ console.error(`
8511
+ Dashboard not found at: ${dashboardDir}`);
8512
+ console.error(`Run this to build it:
8513
+ `);
8514
+ console.error(` cd dashboard && bun install && bun run build
8515
+ `);
8516
+ console.error(`Or from the project root:
8517
+ `);
8518
+ console.error(` bun run build:dashboard
8519
+ `);
8520
+ }
8521
+ let actualPort = port;
8522
+ const fetchHandler = createFetchHandler(() => actualPort, dashboardDir, dashboardExists);
8523
+ const attempts = port === 0 ? 20 : 1;
8524
+ let server2 = null;
8525
+ let lastError;
8526
+ for (let i = 0;i < attempts; i++) {
8527
+ const candidate = port === 0 ? randomPort() : port;
8528
+ try {
8529
+ server2 = Bun.serve({
8530
+ port: candidate,
8531
+ fetch: fetchHandler
8532
+ });
8533
+ actualPort = server2.port;
8534
+ break;
8535
+ } catch (e) {
8536
+ lastError = e;
8537
+ if (port !== 0) {
8538
+ throw e;
8539
+ }
8540
+ }
8541
+ }
8542
+ if (!server2) {
8543
+ throw lastError;
8544
+ }
7528
8545
  const shutdown = () => {
7529
8546
  server2.stop();
7530
8547
  process.exit(0);
7531
8548
  };
7532
8549
  process.on("SIGINT", shutdown);
7533
8550
  process.on("SIGTERM", shutdown);
7534
- const serverUrl = `http://localhost:${port}`;
8551
+ const serverUrl = `http://localhost:${actualPort}`;
7535
8552
  console.log(`Todos Dashboard running at ${serverUrl}`);
7536
8553
  if (shouldOpen) {
7537
8554
  try {
@@ -7542,10 +8559,13 @@ Dashboard not found at: ${dashboardDir}`);
7542
8559
  }
7543
8560
  return server2;
7544
8561
  }
7545
- var MIME_TYPES, SECURITY_HEADERS, MAX_BODY_SIZE;
8562
+ var MIME_TYPES, SECURITY_HEADERS, MAX_BODY_SIZE, createTaskSchema, updateTaskSchema, createProjectSchema, createCommentSchema, createPlanSchema, updatePlanSchema, agentSchema;
7546
8563
  var init_serve = __esm(() => {
8564
+ init_zod();
7547
8565
  init_tasks();
7548
8566
  init_projects();
8567
+ init_plans();
8568
+ init_database();
7549
8569
  init_comments();
7550
8570
  init_search();
7551
8571
  MIME_TYPES = {
@@ -7565,6 +8585,54 @@ var init_serve = __esm(() => {
7565
8585
  "X-Frame-Options": "DENY"
7566
8586
  };
7567
8587
  MAX_BODY_SIZE = 1024 * 1024;
8588
+ createTaskSchema = exports_external.object({
8589
+ title: exports_external.string(),
8590
+ description: exports_external.string().optional(),
8591
+ status: exports_external.enum(["pending", "in_progress", "completed", "failed", "cancelled"]).optional(),
8592
+ priority: exports_external.enum(["low", "medium", "high", "critical"]).optional(),
8593
+ project_id: exports_external.string().optional(),
8594
+ parent_id: exports_external.string().optional(),
8595
+ plan_id: exports_external.string().optional(),
8596
+ tags: exports_external.array(exports_external.string()).optional(),
8597
+ assigned_to: exports_external.string().optional(),
8598
+ agent_id: exports_external.string().optional()
8599
+ });
8600
+ updateTaskSchema = exports_external.object({
8601
+ version: exports_external.number(),
8602
+ title: exports_external.string().optional(),
8603
+ description: exports_external.string().optional(),
8604
+ status: exports_external.enum(["pending", "in_progress", "completed", "failed", "cancelled"]).optional(),
8605
+ priority: exports_external.enum(["low", "medium", "high", "critical"]).optional(),
8606
+ assigned_to: exports_external.string().optional(),
8607
+ plan_id: exports_external.string().optional(),
8608
+ tags: exports_external.array(exports_external.string()).optional(),
8609
+ metadata: exports_external.record(exports_external.unknown()).optional()
8610
+ });
8611
+ createProjectSchema = exports_external.object({
8612
+ name: exports_external.string(),
8613
+ path: exports_external.string().optional(),
8614
+ description: exports_external.string().optional(),
8615
+ task_list_id: exports_external.string().optional()
8616
+ });
8617
+ createCommentSchema = exports_external.object({
8618
+ content: exports_external.string(),
8619
+ agent_id: exports_external.string().optional(),
8620
+ session_id: exports_external.string().optional()
8621
+ });
8622
+ createPlanSchema = exports_external.object({
8623
+ name: exports_external.string(),
8624
+ project_id: exports_external.string().optional(),
8625
+ description: exports_external.string().optional(),
8626
+ status: exports_external.enum(["active", "completed", "archived"]).optional()
8627
+ });
8628
+ updatePlanSchema = exports_external.object({
8629
+ name: exports_external.string().optional(),
8630
+ description: exports_external.string().optional(),
8631
+ status: exports_external.enum(["active", "completed", "archived"]).optional()
8632
+ });
8633
+ agentSchema = exports_external.object({
8634
+ agent_id: exports_external.string().optional()
8635
+ });
7568
8636
  });
7569
8637
 
7570
8638
  // src/cli/components/Header.tsx
@@ -8644,17 +9712,19 @@ var {
8644
9712
  init_database();
8645
9713
  init_tasks();
8646
9714
  init_projects();
9715
+ init_plans();
8647
9716
  init_comments();
8648
9717
  init_search();
8649
- init_claude_tasks();
9718
+ init_sync();
9719
+ init_config();
8650
9720
  import chalk from "chalk";
8651
9721
  import { execSync as execSync2 } from "child_process";
8652
- import { existsSync as existsSync4, mkdirSync as mkdirSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
8653
- import { basename, dirname as dirname3, join as join4, resolve as resolve2 } from "path";
9722
+ import { existsSync as existsSync7, mkdirSync as mkdirSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
9723
+ import { basename, dirname as dirname3, join as join7, resolve as resolve2 } from "path";
8654
9724
  import { fileURLToPath as fileURLToPath2 } from "url";
8655
9725
  function getPackageVersion2() {
8656
9726
  try {
8657
- const pkgPath = join4(dirname3(fileURLToPath2(import.meta.url)), "..", "..", "package.json");
9727
+ const pkgPath = join7(dirname3(fileURLToPath2(import.meta.url)), "..", "..", "package.json");
8658
9728
  return JSON.parse(readFileSync3(pkgPath, "utf-8")).version || "0.0.0";
8659
9729
  } catch {
8660
9730
  return "0.0.0";
@@ -8681,20 +9751,21 @@ function detectGitRoot() {
8681
9751
  return null;
8682
9752
  }
8683
9753
  }
8684
- function autoProject(opts) {
9754
+ function autoDetectProject(opts) {
8685
9755
  if (opts.project) {
8686
- const p = getProjectByPath(resolve2(opts.project));
8687
- return p?.id;
9756
+ return getProjectByPath(resolve2(opts.project)) ?? undefined;
8688
9757
  }
8689
9758
  if (process.env["TODOS_AUTO_PROJECT"] === "false")
8690
9759
  return;
8691
9760
  const gitRoot = detectGitRoot();
8692
9761
  if (gitRoot) {
8693
- const p = ensureProject(basename(gitRoot), gitRoot);
8694
- return p.id;
9762
+ return ensureProject(basename(gitRoot), gitRoot);
8695
9763
  }
8696
9764
  return;
8697
9765
  }
9766
+ function autoProject(opts) {
9767
+ return autoDetectProject(opts)?.id;
9768
+ }
8698
9769
  function output(data, jsonMode) {
8699
9770
  if (jsonMode) {
8700
9771
  console.log(JSON.stringify(data, null, 2));
@@ -8719,10 +9790,11 @@ function formatTaskLine(t) {
8719
9790
  const lock = t.locked_by ? chalk.magenta(` [locked:${t.locked_by}]`) : "";
8720
9791
  const assigned = t.assigned_to ? chalk.cyan(` -> ${t.assigned_to}`) : "";
8721
9792
  const tags = t.tags.length > 0 ? chalk.dim(` [${t.tags.join(",")}]`) : "";
8722
- return `${chalk.dim(t.id.slice(0, 8))} ${statusFn(t.status.padEnd(11))} ${priorityFn(t.priority.padEnd(8))} ${t.title}${assigned}${lock}${tags}`;
9793
+ const plan = t.plan_id ? chalk.magenta(` [plan:${t.plan_id.slice(0, 8)}]`) : "";
9794
+ return `${chalk.dim(t.id.slice(0, 8))} ${statusFn(t.status.padEnd(11))} ${priorityFn(t.priority.padEnd(8))} ${t.title}${assigned}${lock}${tags}${plan}`;
8723
9795
  }
8724
9796
  program2.name("todos").description("Universal task management for AI coding agents").version(getPackageVersion2()).option("--project <path>", "Project path").option("--json", "Output as JSON").option("--agent <name>", "Agent name").option("--session <id>", "Session ID");
8725
- program2.command("add <title>").description("Create a new task").option("-d, --description <text>", "Task description").option("-p, --priority <level>", "Priority: low, medium, high, critical").option("--parent <id>", "Parent task ID").option("--tags <tags>", "Comma-separated tags").option("--assign <agent>", "Assign to agent").option("--status <status>", "Initial status").action((title, opts) => {
9797
+ program2.command("add <title>").description("Create a new task").option("-d, --description <text>", "Task description").option("-p, --priority <level>", "Priority: low, medium, high, critical").option("--parent <id>", "Parent task ID").option("--tags <tags>", "Comma-separated tags").option("--plan <id>", "Assign to a plan").option("--assign <agent>", "Assign to agent").option("--status <status>", "Initial status").action((title, opts) => {
8726
9798
  const globalOpts = program2.opts();
8727
9799
  const projectId = autoProject(globalOpts);
8728
9800
  const task = createTask({
@@ -8731,6 +9803,15 @@ program2.command("add <title>").description("Create a new task").option("-d, --d
8731
9803
  priority: opts.priority,
8732
9804
  parent_id: opts.parent ? resolveTaskId(opts.parent) : undefined,
8733
9805
  tags: opts.tags ? opts.tags.split(",").map((t) => t.trim()) : undefined,
9806
+ plan_id: opts.plan ? (() => {
9807
+ const db = getDatabase();
9808
+ const id = resolvePartialId(db, "plans", opts.plan);
9809
+ if (!id) {
9810
+ console.error(chalk.red(`Could not resolve plan ID: ${opts.plan}`));
9811
+ process.exit(1);
9812
+ }
9813
+ return id;
9814
+ })() : undefined,
8734
9815
  assigned_to: opts.assign,
8735
9816
  status: opts.status,
8736
9817
  agent_id: globalOpts.agent,
@@ -8807,6 +9888,8 @@ program2.command("show <id>").description("Show full task details").action((id)
8807
9888
  console.log(` ${chalk.dim("Locked:")} ${task.locked_by} (at ${task.locked_at})`);
8808
9889
  if (task.project_id)
8809
9890
  console.log(` ${chalk.dim("Project:")} ${task.project_id}`);
9891
+ if (task.plan_id)
9892
+ console.log(` ${chalk.dim("Plan:")} ${task.plan_id}`);
8810
9893
  if (task.working_dir)
8811
9894
  console.log(` ${chalk.dim("WorkDir:")} ${task.working_dir}`);
8812
9895
  if (task.parent)
@@ -8955,45 +10038,114 @@ program2.command("delete <id>").description("Delete a task").action((id) => {
8955
10038
  process.exit(1);
8956
10039
  }
8957
10040
  });
8958
- program2.command("plan <title>").description("Create a plan with subtasks").option("-d, --description <text>", "Plan description").option("--tasks <tasks>", "Comma-separated subtask titles").option("-p, --priority <priority>", "Priority").action((title, opts) => {
10041
+ program2.command("plans").description("List and manage plans").option("--add <name>", "Create a plan").option("-d, --description <text>", "Plan description (with --add)").option("--show <id>", "Show plan details with its tasks").option("--delete <id>", "Delete a plan").option("--complete <id>", "Mark a plan as completed").action((opts) => {
8959
10042
  const globalOpts = program2.opts();
8960
10043
  const projectId = autoProject(globalOpts);
8961
- const parent = createTask({
8962
- title,
8963
- description: opts.description,
8964
- priority: opts.priority,
8965
- agent_id: globalOpts.agent,
8966
- session_id: globalOpts.session,
8967
- project_id: projectId,
8968
- working_dir: process.cwd()
8969
- });
8970
- const subtasks = [];
8971
- if (opts.tasks) {
8972
- const taskTitles = opts.tasks.split(",").map((t) => t.trim());
8973
- for (const st of taskTitles) {
8974
- subtasks.push(createTask({
8975
- title: st,
8976
- parent_id: parent.id,
8977
- priority: opts.priority,
8978
- agent_id: globalOpts.agent,
8979
- session_id: globalOpts.session,
8980
- project_id: projectId,
8981
- working_dir: process.cwd()
8982
- }));
10044
+ if (opts.add) {
10045
+ const plan = createPlan({
10046
+ name: opts.add,
10047
+ description: opts.description,
10048
+ project_id: projectId
10049
+ });
10050
+ if (globalOpts.json) {
10051
+ output(plan, true);
10052
+ } else {
10053
+ console.log(chalk.green("Plan created:"));
10054
+ console.log(`${chalk.dim(plan.id.slice(0, 8))} ${chalk.bold(plan.name)} ${chalk.cyan(`[${plan.status}]`)}`);
8983
10055
  }
10056
+ return;
8984
10057
  }
8985
- if (globalOpts.json) {
8986
- output({ parent, subtasks }, true);
8987
- } else {
8988
- console.log(chalk.green("Plan created:"));
8989
- console.log(formatTaskLine(parent));
8990
- if (subtasks.length > 0) {
10058
+ if (opts.show) {
10059
+ const db = getDatabase();
10060
+ const resolvedId = resolvePartialId(db, "plans", opts.show);
10061
+ if (!resolvedId) {
10062
+ console.error(chalk.red(`Could not resolve plan ID: ${opts.show}`));
10063
+ process.exit(1);
10064
+ }
10065
+ const plan = getPlan(resolvedId);
10066
+ if (!plan) {
10067
+ console.error(chalk.red(`Plan not found: ${opts.show}`));
10068
+ process.exit(1);
10069
+ }
10070
+ const tasks = listTasks({ plan_id: resolvedId });
10071
+ if (globalOpts.json) {
10072
+ output({ plan, tasks }, true);
10073
+ return;
10074
+ }
10075
+ console.log(chalk.bold(`Plan Details:
10076
+ `));
10077
+ console.log(` ${chalk.dim("ID:")} ${plan.id}`);
10078
+ console.log(` ${chalk.dim("Name:")} ${plan.name}`);
10079
+ console.log(` ${chalk.dim("Status:")} ${chalk.cyan(plan.status)}`);
10080
+ if (plan.description)
10081
+ console.log(` ${chalk.dim("Desc:")} ${plan.description}`);
10082
+ if (plan.project_id)
10083
+ console.log(` ${chalk.dim("Project:")} ${plan.project_id}`);
10084
+ console.log(` ${chalk.dim("Created:")} ${plan.created_at}`);
10085
+ if (tasks.length > 0) {
8991
10086
  console.log(chalk.bold(`
8992
- Subtasks:`));
8993
- for (const st of subtasks) {
8994
- console.log(` ${formatTaskLine(st)}`);
10087
+ Tasks (${tasks.length}):`));
10088
+ for (const t of tasks) {
10089
+ console.log(` ${formatTaskLine(t)}`);
10090
+ }
10091
+ } else {
10092
+ console.log(chalk.dim(`
10093
+ No tasks in this plan.`));
10094
+ }
10095
+ return;
10096
+ }
10097
+ if (opts.delete) {
10098
+ const db = getDatabase();
10099
+ const resolvedId = resolvePartialId(db, "plans", opts.delete);
10100
+ if (!resolvedId) {
10101
+ console.error(chalk.red(`Could not resolve plan ID: ${opts.delete}`));
10102
+ process.exit(1);
10103
+ }
10104
+ const deleted = deletePlan(resolvedId);
10105
+ if (globalOpts.json) {
10106
+ output({ deleted }, true);
10107
+ } else if (deleted) {
10108
+ console.log(chalk.green("Plan deleted."));
10109
+ } else {
10110
+ console.error(chalk.red("Plan not found."));
10111
+ process.exit(1);
10112
+ }
10113
+ return;
10114
+ }
10115
+ if (opts.complete) {
10116
+ const db = getDatabase();
10117
+ const resolvedId = resolvePartialId(db, "plans", opts.complete);
10118
+ if (!resolvedId) {
10119
+ console.error(chalk.red(`Could not resolve plan ID: ${opts.complete}`));
10120
+ process.exit(1);
10121
+ }
10122
+ try {
10123
+ const plan = updatePlan(resolvedId, { status: "completed" });
10124
+ if (globalOpts.json) {
10125
+ output(plan, true);
10126
+ } else {
10127
+ console.log(chalk.green("Plan completed:"));
10128
+ console.log(`${chalk.dim(plan.id.slice(0, 8))} ${chalk.bold(plan.name)} ${chalk.cyan(`[${plan.status}]`)}`);
8995
10129
  }
10130
+ } catch (e) {
10131
+ handleError(e);
8996
10132
  }
10133
+ return;
10134
+ }
10135
+ const plans = listPlans(projectId);
10136
+ if (globalOpts.json) {
10137
+ output(plans, true);
10138
+ return;
10139
+ }
10140
+ if (plans.length === 0) {
10141
+ console.log(chalk.dim("No plans found."));
10142
+ return;
10143
+ }
10144
+ console.log(chalk.bold(`${plans.length} plan(s):
10145
+ `));
10146
+ for (const p of plans) {
10147
+ const desc = p.description ? chalk.dim(` - ${p.description}`) : "";
10148
+ console.log(`${chalk.dim(p.id.slice(0, 8))} ${chalk.bold(p.name)} ${chalk.cyan(`[${p.status}]`)}${desc}`);
8997
10149
  }
8998
10150
  });
8999
10151
  program2.command("comment <id> <text>").description("Add a comment to a task").action((id, text) => {
@@ -9080,16 +10232,27 @@ program2.command("deps <id>").description("Manage task dependencies").option("--
9080
10232
  }
9081
10233
  }
9082
10234
  });
9083
- program2.command("projects").description("List and manage projects").option("--add <path>", "Register a project by path").option("--name <name>", "Project name (with --add)").action((opts) => {
10235
+ program2.command("projects").description("List and manage projects").option("--add <path>", "Register a project by path").option("--name <name>", "Project name (with --add)").option("--task-list-id <id>", "Custom task list ID (with --add)").action((opts) => {
9084
10236
  const globalOpts = program2.opts();
9085
10237
  if (opts.add) {
9086
10238
  const projectPath = resolve2(opts.add);
9087
10239
  const name = opts.name || basename(projectPath);
9088
- const project = ensureProject(name, projectPath);
10240
+ const existing = getProjectByPath(projectPath);
10241
+ let project;
10242
+ if (existing) {
10243
+ project = existing;
10244
+ if (opts.taskListId) {
10245
+ project = updateProject(existing.id, { task_list_id: opts.taskListId });
10246
+ }
10247
+ } else {
10248
+ project = createProject({ name, path: projectPath, task_list_id: opts.taskListId });
10249
+ }
9089
10250
  if (globalOpts.json) {
9090
10251
  output(project, true);
9091
10252
  } else {
9092
10253
  console.log(chalk.green(`Project registered: ${project.name} (${project.path})`));
10254
+ if (project.task_list_id)
10255
+ console.log(chalk.dim(` Task list: ${project.task_list_id}`));
9093
10256
  }
9094
10257
  return;
9095
10258
  }
@@ -9105,7 +10268,8 @@ program2.command("projects").description("List and manage projects").option("--a
9105
10268
  console.log(chalk.bold(`${projects.length} project(s):
9106
10269
  `));
9107
10270
  for (const p of projects) {
9108
- console.log(`${chalk.dim(p.id.slice(0, 8))} ${chalk.bold(p.name)} ${chalk.dim(p.path)}${p.description ? ` - ${p.description}` : ""}`);
10271
+ const taskList = p.task_list_id ? chalk.cyan(` [${p.task_list_id}]`) : "";
10272
+ console.log(`${chalk.dim(p.id.slice(0, 8))} ${chalk.bold(p.name)} ${chalk.dim(p.path)}${taskList}${p.description ? ` - ${p.description}` : ""}`);
9109
10273
  }
9110
10274
  });
9111
10275
  program2.command("export").description("Export tasks").option("-f, --format <format>", "Format: json or md", "json").action((opts) => {
@@ -9125,33 +10289,43 @@ program2.command("export").description("Export tasks").option("-f, --format <for
9125
10289
  console.log(JSON.stringify(tasks, null, 2));
9126
10290
  }
9127
10291
  });
9128
- function resolveClaudeTaskListId(explicit) {
9129
- return explicit || process.env["TODOS_CLAUDE_TASK_LIST"] || process.env["CLAUDE_CODE_TASK_LIST_ID"] || process.env["CLAUDE_CODE_SESSION_ID"] || null;
10292
+ function resolveTaskListId2(agent, explicit, projectTaskListId) {
10293
+ if (explicit)
10294
+ return explicit;
10295
+ const normalized = agent.trim().toLowerCase();
10296
+ if (normalized === "claude" || normalized === "claude-code" || normalized === "claude_code") {
10297
+ return process.env["TODOS_CLAUDE_TASK_LIST"] || process.env["CLAUDE_CODE_TASK_LIST_ID"] || process.env["CLAUDE_CODE_SESSION_ID"] || getAgentTaskListId(normalized) || projectTaskListId || null;
10298
+ }
10299
+ const key = `TODOS_${normalized.toUpperCase()}_TASK_LIST`;
10300
+ return process.env[key] || process.env["TODOS_TASK_LIST_ID"] || getAgentTaskListId(normalized) || "default";
9130
10301
  }
9131
- program2.command("sync").description("Sync tasks with a Claude Code task list").option("--task-list <id>", "Task list ID (auto-detects from CLAUDE_CODE_TASK_LIST_ID or CLAUDE_CODE_SESSION_ID)").option("--push", "One-way: push SQLite tasks to Claude task list").option("--pull", "One-way: pull Claude task list into SQLite").action((opts) => {
10302
+ program2.command("sync").description("Sync tasks with an agent task list (Claude uses native task list; others use JSON lists)").option("--task-list <id>", "Task list ID (Claude auto-detects from CLAUDE_CODE_TASK_LIST_ID or CLAUDE_CODE_SESSION_ID)").option("--agent <name>", "Agent/provider to sync (default: claude)").option("--all", "Sync across all configured agents (TODOS_SYNC_AGENTS or default: claude,codex,gemini)").option("--push", "One-way: push SQLite tasks to agent task list").option("--pull", "One-way: pull agent task list into SQLite").option("--prefer <side>", "Conflict strategy: local or remote", "remote").action((opts) => {
9132
10303
  const globalOpts = program2.opts();
9133
- const projectId = autoProject(globalOpts);
9134
- const taskListId = resolveClaudeTaskListId(opts.taskList);
9135
- if (!taskListId) {
9136
- console.error(chalk.red("Could not detect task list ID. Use --task-list <id>, or run inside a Claude Code session."));
9137
- process.exit(1);
9138
- }
10304
+ const project = autoDetectProject(globalOpts);
10305
+ const projectId = project?.id;
10306
+ const direction = opts.push && !opts.pull ? "push" : opts.pull && !opts.push ? "pull" : "both";
9139
10307
  let result;
9140
- if (opts.push && !opts.pull) {
9141
- result = pushToClaudeTaskList(taskListId, projectId);
9142
- } else if (opts.pull && !opts.push) {
9143
- result = pullFromClaudeTaskList(taskListId, projectId);
10308
+ const prefer = opts.prefer === "local" ? "local" : "remote";
10309
+ if (opts.all) {
10310
+ const agents = defaultSyncAgents();
10311
+ result = syncWithAgents(agents, (agent) => resolveTaskListId2(agent, opts.taskList, project?.task_list_id), projectId, direction, { prefer });
9144
10312
  } else {
9145
- result = syncClaudeTaskList(taskListId, projectId);
10313
+ const agent = opts.agent || "claude";
10314
+ const taskListId = resolveTaskListId2(agent, opts.taskList, project?.task_list_id);
10315
+ if (!taskListId) {
10316
+ console.error(chalk.red(`Could not detect task list ID for ${agent}. Use --task-list <id> or set appropriate env vars.`));
10317
+ process.exit(1);
10318
+ }
10319
+ result = syncWithAgent(agent, taskListId, projectId, direction, { prefer });
9146
10320
  }
9147
10321
  if (globalOpts.json) {
9148
10322
  output(result, true);
9149
10323
  return;
9150
10324
  }
9151
10325
  if (result.pulled > 0)
9152
- console.log(chalk.green(`Pulled ${result.pulled} task(s) from Claude task list.`));
10326
+ console.log(chalk.green(`Pulled ${result.pulled} task(s).`));
9153
10327
  if (result.pushed > 0)
9154
- console.log(chalk.green(`Pushed ${result.pushed} task(s) to Claude task list.`));
10328
+ console.log(chalk.green(`Pushed ${result.pushed} task(s).`));
9155
10329
  if (result.pulled === 0 && result.pushed === 0 && result.errors.length === 0) {
9156
10330
  console.log(chalk.dim("Nothing to sync."));
9157
10331
  }
@@ -9167,45 +10341,38 @@ hooks.command("install").description("Install Claude Code hooks for auto-sync").
9167
10341
  if (p)
9168
10342
  todosBin = p;
9169
10343
  } catch {}
9170
- const hooksDir = join4(process.cwd(), ".claude", "hooks");
9171
- if (!existsSync4(hooksDir))
10344
+ const hooksDir = join7(process.cwd(), ".claude", "hooks");
10345
+ if (!existsSync7(hooksDir))
9172
10346
  mkdirSync3(hooksDir, { recursive: true });
9173
10347
  const hookScript = `#!/usr/bin/env bash
9174
10348
  # Auto-generated by: todos hooks install
9175
10349
  # Syncs todos with Claude Code task list on tool use events.
9176
- # Reads session_id and tool_name from the hook JSON stdin.
10350
+ # Uses session_id when available; falls back to project-based task_list_id.
9177
10351
 
9178
10352
  INPUT=$(cat)
9179
10353
 
9180
- # Extract session_id from stdin JSON (hooks always receive this)
9181
10354
  SESSION_ID=$(echo "$INPUT" | grep -o '"session_id":"[^"]*"' | head -1 | cut -d'"' -f4 2>/dev/null || true)
9182
-
9183
- # Task list priority: env override > session ID from hook input
9184
- TASK_LIST="\${TODOS_CLAUDE_TASK_LIST:-\${CLAUDE_CODE_TASK_LIST_ID:-$SESSION_ID}}"
9185
-
9186
- if [ -z "$TASK_LIST" ]; then
9187
- exit 0
9188
- fi
10355
+ TASK_LIST="\${TODOS_CLAUDE_TASK_LIST:-\${SESSION_ID}}"
9189
10356
 
9190
10357
  TOOL_NAME=$(echo "$INPUT" | grep -o '"tool_name":"[^"]*"' | head -1 | cut -d'"' -f4 2>/dev/null || true)
9191
10358
 
9192
10359
  case "$TOOL_NAME" in
9193
10360
  TaskCreate|TaskUpdate)
9194
- ${todosBin} sync --pull --task-list "$TASK_LIST" 2>/dev/null || true
10361
+ TODOS_CLAUDE_TASK_LIST="$TASK_LIST" ${todosBin} sync --all --pull 2>/dev/null || true
9195
10362
  ;;
9196
10363
  mcp__todos__*)
9197
- ${todosBin} sync --push --task-list "$TASK_LIST" 2>/dev/null || true
10364
+ TODOS_CLAUDE_TASK_LIST="$TASK_LIST" ${todosBin} sync --all --push 2>/dev/null || true
9198
10365
  ;;
9199
10366
  esac
9200
10367
 
9201
10368
  exit 0
9202
10369
  `;
9203
- const hookPath = join4(hooksDir, "todos-sync.sh");
10370
+ const hookPath = join7(hooksDir, "todos-sync.sh");
9204
10371
  writeFileSync2(hookPath, hookScript);
9205
10372
  execSync2(`chmod +x "${hookPath}"`);
9206
10373
  console.log(chalk.green(`Hook script created: ${hookPath}`));
9207
- const settingsPath = join4(process.cwd(), ".claude", "settings.json");
9208
- const settings = readJsonFile(settingsPath);
10374
+ const settingsPath = join7(process.cwd(), ".claude", "settings.json");
10375
+ const settings = readJsonFile2(settingsPath);
9209
10376
  if (!settings["hooks"]) {
9210
10377
  settings["hooks"] = {};
9211
10378
  }
@@ -9229,9 +10396,9 @@ exit 0
9229
10396
  hooks: [{ type: "command", command: hookPath }]
9230
10397
  });
9231
10398
  hooksConfig["PostToolUse"] = filtered;
9232
- writeJsonFile(settingsPath, settings);
10399
+ writeJsonFile2(settingsPath, settings);
9233
10400
  console.log(chalk.green(`Claude Code hooks configured in: ${settingsPath}`));
9234
- console.log(chalk.dim("Task list ID auto-detected from hook stdin session_id."));
10401
+ console.log(chalk.dim("Task list ID auto-detected from project."));
9235
10402
  });
9236
10403
  program2.command("mcp").description("Start MCP server (stdio)").option("--register <agent>", "Register MCP server with an agent (claude, codex, gemini, all)").option("--unregister <agent>", "Unregister MCP server from an agent (claude, codex, gemini, all)").option("-g, --global", "Register/unregister globally (user-level) instead of project-level").action(async (opts) => {
9237
10404
  if (opts.register) {
@@ -9251,13 +10418,13 @@ function getMcpBinaryPath() {
9251
10418
  if (p)
9252
10419
  return p;
9253
10420
  } catch {}
9254
- const bunBin = join4(HOME2, ".bun", "bin", "todos-mcp");
9255
- if (existsSync4(bunBin))
10421
+ const bunBin = join7(HOME2, ".bun", "bin", "todos-mcp");
10422
+ if (existsSync7(bunBin))
9256
10423
  return bunBin;
9257
10424
  return "todos-mcp";
9258
10425
  }
9259
- function readJsonFile(path) {
9260
- if (!existsSync4(path))
10426
+ function readJsonFile2(path) {
10427
+ if (!existsSync7(path))
9261
10428
  return {};
9262
10429
  try {
9263
10430
  return JSON.parse(readFileSync3(path, "utf-8"));
@@ -9265,27 +10432,27 @@ function readJsonFile(path) {
9265
10432
  return {};
9266
10433
  }
9267
10434
  }
9268
- function writeJsonFile(path, data) {
10435
+ function writeJsonFile2(path, data) {
9269
10436
  const dir = dirname3(path);
9270
- if (!existsSync4(dir))
10437
+ if (!existsSync7(dir))
9271
10438
  mkdirSync3(dir, { recursive: true });
9272
10439
  writeFileSync2(path, JSON.stringify(data, null, 2) + `
9273
10440
  `);
9274
10441
  }
9275
10442
  function readTomlFile(path) {
9276
- if (!existsSync4(path))
10443
+ if (!existsSync7(path))
9277
10444
  return "";
9278
10445
  return readFileSync3(path, "utf-8");
9279
10446
  }
9280
10447
  function writeTomlFile(path, content) {
9281
10448
  const dir = dirname3(path);
9282
- if (!existsSync4(dir))
10449
+ if (!existsSync7(dir))
9283
10450
  mkdirSync3(dir, { recursive: true });
9284
10451
  writeFileSync2(path, content);
9285
10452
  }
9286
10453
  function registerClaude(binPath, global) {
9287
- const configPath = global ? join4(HOME2, ".claude", ".mcp.json") : join4(process.cwd(), ".mcp.json");
9288
- const config = readJsonFile(configPath);
10454
+ const configPath = global ? join7(HOME2, ".claude", ".mcp.json") : join7(process.cwd(), ".mcp.json");
10455
+ const config = readJsonFile2(configPath);
9289
10456
  if (!config["mcpServers"]) {
9290
10457
  config["mcpServers"] = {};
9291
10458
  }
@@ -9294,25 +10461,25 @@ function registerClaude(binPath, global) {
9294
10461
  command: binPath,
9295
10462
  args: []
9296
10463
  };
9297
- writeJsonFile(configPath, config);
10464
+ writeJsonFile2(configPath, config);
9298
10465
  const scope = global ? "global" : "project";
9299
10466
  console.log(chalk.green(`Claude Code (${scope}): registered in ${configPath}`));
9300
10467
  }
9301
10468
  function unregisterClaude(global) {
9302
- const configPath = global ? join4(HOME2, ".claude", ".mcp.json") : join4(process.cwd(), ".mcp.json");
9303
- const config = readJsonFile(configPath);
10469
+ const configPath = global ? join7(HOME2, ".claude", ".mcp.json") : join7(process.cwd(), ".mcp.json");
10470
+ const config = readJsonFile2(configPath);
9304
10471
  const servers = config["mcpServers"];
9305
10472
  if (!servers || !("todos" in servers)) {
9306
10473
  console.log(chalk.dim(`Claude Code: todos not found in ${configPath}`));
9307
10474
  return;
9308
10475
  }
9309
10476
  delete servers["todos"];
9310
- writeJsonFile(configPath, config);
10477
+ writeJsonFile2(configPath, config);
9311
10478
  const scope = global ? "global" : "project";
9312
10479
  console.log(chalk.green(`Claude Code (${scope}): unregistered from ${configPath}`));
9313
10480
  }
9314
10481
  function registerCodex(binPath) {
9315
- const configPath = join4(HOME2, ".codex", "config.toml");
10482
+ const configPath = join7(HOME2, ".codex", "config.toml");
9316
10483
  let content = readTomlFile(configPath);
9317
10484
  content = removeTomlBlock(content, "mcp_servers.todos");
9318
10485
  const block = `
@@ -9326,7 +10493,7 @@ args = []
9326
10493
  console.log(chalk.green(`Codex CLI: registered in ${configPath}`));
9327
10494
  }
9328
10495
  function unregisterCodex() {
9329
- const configPath = join4(HOME2, ".codex", "config.toml");
10496
+ const configPath = join7(HOME2, ".codex", "config.toml");
9330
10497
  let content = readTomlFile(configPath);
9331
10498
  if (!content.includes("[mcp_servers.todos]")) {
9332
10499
  console.log(chalk.dim(`Codex CLI: todos not found in ${configPath}`));
@@ -9359,8 +10526,8 @@ function removeTomlBlock(content, blockName) {
9359
10526
  `);
9360
10527
  }
9361
10528
  function registerGemini(binPath) {
9362
- const configPath = join4(HOME2, ".gemini", "settings.json");
9363
- const config = readJsonFile(configPath);
10529
+ const configPath = join7(HOME2, ".gemini", "settings.json");
10530
+ const config = readJsonFile2(configPath);
9364
10531
  if (!config["mcpServers"]) {
9365
10532
  config["mcpServers"] = {};
9366
10533
  }
@@ -9369,19 +10536,19 @@ function registerGemini(binPath) {
9369
10536
  command: binPath,
9370
10537
  args: []
9371
10538
  };
9372
- writeJsonFile(configPath, config);
10539
+ writeJsonFile2(configPath, config);
9373
10540
  console.log(chalk.green(`Gemini CLI: registered in ${configPath}`));
9374
10541
  }
9375
10542
  function unregisterGemini() {
9376
- const configPath = join4(HOME2, ".gemini", "settings.json");
9377
- const config = readJsonFile(configPath);
10543
+ const configPath = join7(HOME2, ".gemini", "settings.json");
10544
+ const config = readJsonFile2(configPath);
9378
10545
  const servers = config["mcpServers"];
9379
10546
  if (!servers || !("todos" in servers)) {
9380
10547
  console.log(chalk.dim(`Gemini CLI: todos not found in ${configPath}`));
9381
10548
  return;
9382
10549
  }
9383
10550
  delete servers["todos"];
9384
- writeJsonFile(configPath, config);
10551
+ writeJsonFile2(configPath, config);
9385
10552
  console.log(chalk.green(`Gemini CLI: unregistered from ${configPath}`));
9386
10553
  }
9387
10554
  function registerMcp(agent, global) {