@hasna/todos 0.11.34 → 0.11.36

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
@@ -2908,6 +2908,22 @@ var init_migrations = __esm(() => {
2908
2908
  ALTER TABLE tasks ADD COLUMN cycle_id TEXT REFERENCES cycles(id) ON DELETE SET NULL;
2909
2909
  CREATE INDEX IF NOT EXISTS idx_tasks_cycle ON tasks(cycle_id) WHERE cycle_id IS NOT NULL;
2910
2910
  INSERT OR IGNORE INTO _migrations (id) VALUES (49);
2911
+ `,
2912
+ `
2913
+ CREATE TABLE IF NOT EXISTS api_keys (
2914
+ id TEXT PRIMARY KEY,
2915
+ name TEXT NOT NULL,
2916
+ key_hash TEXT NOT NULL UNIQUE,
2917
+ prefix TEXT NOT NULL UNIQUE,
2918
+ permissions TEXT NOT NULL DEFAULT '["*"]',
2919
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
2920
+ last_used_at TEXT,
2921
+ expires_at TEXT,
2922
+ revoked_at TEXT
2923
+ );
2924
+ CREATE INDEX IF NOT EXISTS idx_api_keys_prefix ON api_keys(prefix);
2925
+ CREATE INDEX IF NOT EXISTS idx_api_keys_active ON api_keys(revoked_at, expires_at);
2926
+ INSERT OR IGNORE INTO _migrations (id) VALUES (50);
2911
2927
  `
2912
2928
  ];
2913
2929
  });
@@ -3306,6 +3322,20 @@ function ensureSchema(db) {
3306
3322
  ensureIndex("CREATE INDEX IF NOT EXISTS idx_cycles_dates ON cycles(start_date, end_date)");
3307
3323
  ensureColumn("tasks", "cycle_id", "TEXT REFERENCES cycles(id) ON DELETE SET NULL");
3308
3324
  ensureIndex("CREATE INDEX IF NOT EXISTS idx_tasks_cycle ON tasks(cycle_id) WHERE cycle_id IS NOT NULL");
3325
+ ensureTable("api_keys", `
3326
+ CREATE TABLE api_keys (
3327
+ id TEXT PRIMARY KEY,
3328
+ name TEXT NOT NULL,
3329
+ key_hash TEXT NOT NULL UNIQUE,
3330
+ prefix TEXT NOT NULL UNIQUE,
3331
+ permissions TEXT NOT NULL DEFAULT '["*"]',
3332
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
3333
+ last_used_at TEXT,
3334
+ expires_at TEXT,
3335
+ revoked_at TEXT
3336
+ )`);
3337
+ ensureIndex("CREATE INDEX IF NOT EXISTS idx_api_keys_prefix ON api_keys(prefix)");
3338
+ ensureIndex("CREATE INDEX IF NOT EXISTS idx_api_keys_active ON api_keys(revoked_at, expires_at)");
3309
3339
  }
3310
3340
  function backfillTaskTags(db) {
3311
3341
  try {
@@ -7501,14 +7531,246 @@ var init_extract = __esm(() => {
7501
7531
  ]);
7502
7532
  });
7503
7533
 
7534
+ // src/db/agent-names.ts
7535
+ function normalizeAgentNameInput(name) {
7536
+ return name.trim().toLowerCase();
7537
+ }
7538
+ function hasGeneratedNumericSuffix(name) {
7539
+ return NUMERIC_SUFFIX_RE.test(normalizeAgentNameInput(name));
7540
+ }
7541
+ function isGenericAgentName(name) {
7542
+ const normalized = normalizeAgentNameInput(name);
7543
+ if (RESERVED_GENERIC_NAMES.has(normalized))
7544
+ return true;
7545
+ for (const generic of RESERVED_GENERIC_NAMES) {
7546
+ if (normalized === `${generic}s`)
7547
+ return true;
7548
+ if (normalized.match(new RegExp(`^${generic}\\d+$`)))
7549
+ return true;
7550
+ if (normalized.match(new RegExp(`^${generic}[-_]\\d+$`)))
7551
+ return true;
7552
+ }
7553
+ return false;
7554
+ }
7555
+ function isBlockedAgentName(name) {
7556
+ const normalized = normalizeAgentNameInput(name);
7557
+ return isGenericAgentName(normalized) || hasGeneratedNumericSuffix(normalized) || !ONE_WORD_NAME_RE.test(normalized);
7558
+ }
7559
+ function alphabeticSuffix(index) {
7560
+ const letters = "abcdefghijklmnopqrstuvwxyz";
7561
+ let value = index;
7562
+ let suffix = "";
7563
+ do {
7564
+ suffix = letters[value % letters.length] + suffix;
7565
+ value = Math.floor(value / letters.length) - 1;
7566
+ } while (value >= 0);
7567
+ return suffix;
7568
+ }
7569
+ function suggestAgentNames(existingNames = []) {
7570
+ const existing = new Set([...existingNames].map(normalizeAgentNameInput));
7571
+ const suggestions = PREFERRED_AGENT_NAMES.filter((name) => !existing.has(name));
7572
+ for (let suffixIndex = 0;suggestions.length < 20 && suffixIndex < 1000; suffixIndex++) {
7573
+ const suffix = alphabeticSuffix(suffixIndex);
7574
+ for (const base of PREFERRED_AGENT_NAMES) {
7575
+ const candidate = `${base}${suffix}`;
7576
+ if (existing.has(candidate) || suggestions.includes(candidate))
7577
+ continue;
7578
+ suggestions.push(candidate);
7579
+ if (suggestions.length >= 20)
7580
+ break;
7581
+ }
7582
+ }
7583
+ return suggestions;
7584
+ }
7585
+ function validateAgentName(name, existingNames = []) {
7586
+ const normalized = normalizeAgentNameInput(name);
7587
+ const suggestions = suggestAgentNames(existingNames).slice(0, 5);
7588
+ if (!normalized) {
7589
+ throw new InvalidAgentNameError(name, "choose a real one-word name instead of an empty value", suggestions);
7590
+ }
7591
+ if (/\s/.test(normalized)) {
7592
+ throw new InvalidAgentNameError(name, "use a single word, preferably a Roman or Greek name", suggestions);
7593
+ }
7594
+ if (normalized.length < 3) {
7595
+ throw new InvalidAgentNameError(name, "use a more distinctive name with at least three characters", suggestions);
7596
+ }
7597
+ if (isGenericAgentName(normalized)) {
7598
+ throw new InvalidAgentNameError(name, "generic names like agent, agent-1, assistant, or worker-2 are reserved", suggestions);
7599
+ }
7600
+ if (hasGeneratedNumericSuffix(normalized)) {
7601
+ throw new InvalidAgentNameError(name, "numbered suffix names are not allowed; pick a distinct human-readable name", suggestions);
7602
+ }
7603
+ if (!ONE_WORD_NAME_RE.test(normalized)) {
7604
+ throw new InvalidAgentNameError(name, "use one word made of letters only, preferably a Roman or Greek name", suggestions);
7605
+ }
7606
+ return normalized;
7607
+ }
7608
+ function tableHasColumn(db, table, column) {
7609
+ try {
7610
+ return db.query(`PRAGMA table_info(${table})`).all().some((row) => row.name === column);
7611
+ } catch {
7612
+ return false;
7613
+ }
7614
+ }
7615
+ function updateReferences(db, oldName, newName) {
7616
+ const refs = [
7617
+ ["tasks", "assigned_to"],
7618
+ ["tasks", "agent_id"],
7619
+ ["tasks", "locked_by"],
7620
+ ["tasks", "assigned_by"],
7621
+ ["plans", "agent_id"],
7622
+ ["sessions", "agent_id"],
7623
+ ["task_comments", "agent_id"],
7624
+ ["task_history", "agent_id"],
7625
+ ["webhooks", "agent_id"],
7626
+ ["task_files", "agent_id"],
7627
+ ["task_time_logs", "agent_id"],
7628
+ ["task_watchers", "agent_id"],
7629
+ ["task_checkpoints", "agent_id"],
7630
+ ["task_heartbeats", "agent_id"],
7631
+ ["project_agent_roles", "agent_id"]
7632
+ ];
7633
+ let changed = 0;
7634
+ for (const [table, column] of refs) {
7635
+ if (!tableHasColumn(db, table, column))
7636
+ continue;
7637
+ try {
7638
+ changed += db.run(`UPDATE ${table} SET ${column} = ? WHERE LOWER(${column}) = ?`, [newName, oldName]).changes;
7639
+ } catch {}
7640
+ }
7641
+ return changed;
7642
+ }
7643
+ function normalizeGeneratedAgentNames(db) {
7644
+ const rows = db.query("SELECT * FROM agents ORDER BY created_at, id").all();
7645
+ const existing = new Set(rows.map((agent) => normalizeAgentNameInput(agent.name)));
7646
+ const renamed = [];
7647
+ for (const agent of rows) {
7648
+ const oldName = normalizeAgentNameInput(agent.name);
7649
+ if (!isBlockedAgentName(oldName))
7650
+ continue;
7651
+ const candidates = suggestAgentNames(existing);
7652
+ const replacement = candidates[0];
7653
+ if (!replacement) {
7654
+ throw new Error("No safe agent names are available for normalization");
7655
+ }
7656
+ existing.delete(oldName);
7657
+ existing.add(replacement);
7658
+ db.run("UPDATE agents SET name = ?, last_seen_at = ? WHERE id = ?", [replacement, now(), agent.id]);
7659
+ const referenceUpdates = updateReferences(db, oldName, replacement);
7660
+ renamed.push({
7661
+ id: agent.id,
7662
+ old_name: oldName,
7663
+ new_name: replacement,
7664
+ reference_updates: referenceUpdates
7665
+ });
7666
+ }
7667
+ return renamed;
7668
+ }
7669
+ var InvalidAgentNameError, ROMAN_AGENT_NAMES, GREEK_AGENT_NAMES, NICE_AGENT_NAMES, PREFERRED_AGENT_NAMES, RESERVED_GENERIC_NAMES, NUMERIC_SUFFIX_RE, ONE_WORD_NAME_RE;
7670
+ var init_agent_names = __esm(() => {
7671
+ init_database();
7672
+ InvalidAgentNameError = class InvalidAgentNameError extends Error {
7673
+ suggestions;
7674
+ constructor(name, reason, suggestions = []) {
7675
+ super(`Invalid agent name "${name}": ${reason}${suggestions.length > 0 ? `. Try: ${suggestions.join(", ")}` : ""}`);
7676
+ this.name = "InvalidAgentNameError";
7677
+ this.suggestions = suggestions;
7678
+ }
7679
+ };
7680
+ ROMAN_AGENT_NAMES = [
7681
+ "caesar",
7682
+ "augustus",
7683
+ "marcus",
7684
+ "brutus",
7685
+ "cicero",
7686
+ "cato",
7687
+ "nero",
7688
+ "claudius",
7689
+ "tiberius",
7690
+ "hadrian",
7691
+ "trajan",
7692
+ "vespasian",
7693
+ "domitian",
7694
+ "caligula",
7695
+ "commodus",
7696
+ "livia",
7697
+ "julia",
7698
+ "octavia",
7699
+ "claudia",
7700
+ "agrippina",
7701
+ "cornelia",
7702
+ "valeria",
7703
+ "fulvia",
7704
+ "hortensia",
7705
+ "fabia"
7706
+ ];
7707
+ GREEK_AGENT_NAMES = [
7708
+ "athena",
7709
+ "apollo",
7710
+ "artemis",
7711
+ "hera",
7712
+ "iris",
7713
+ "hector",
7714
+ "achilles",
7715
+ "odysseus",
7716
+ "theseus",
7717
+ "pericles",
7718
+ "solon",
7719
+ "sophia",
7720
+ "thalia",
7721
+ "calliope",
7722
+ "clio",
7723
+ "phoebe",
7724
+ "daphne",
7725
+ "leonidas",
7726
+ "andromeda",
7727
+ "cassander"
7728
+ ];
7729
+ NICE_AGENT_NAMES = [
7730
+ "atlas",
7731
+ "aurora",
7732
+ "ember",
7733
+ "nova",
7734
+ "orion",
7735
+ "rhea",
7736
+ "selene",
7737
+ "sirius",
7738
+ "vesper",
7739
+ "zephyr"
7740
+ ];
7741
+ PREFERRED_AGENT_NAMES = [
7742
+ ...ROMAN_AGENT_NAMES,
7743
+ ...GREEK_AGENT_NAMES,
7744
+ ...NICE_AGENT_NAMES
7745
+ ];
7746
+ RESERVED_GENERIC_NAMES = new Set([
7747
+ "agent",
7748
+ "agents",
7749
+ "ai",
7750
+ "assistant",
7751
+ "bot",
7752
+ "coder",
7753
+ "default",
7754
+ "helper",
7755
+ "model",
7756
+ "system",
7757
+ "user",
7758
+ "worker"
7759
+ ]);
7760
+ NUMERIC_SUFFIX_RE = /[-_]\d+$/;
7761
+ ONE_WORD_NAME_RE = /^[a-z]+$/;
7762
+ });
7763
+
7504
7764
  // src/db/agents.ts
7505
7765
  var exports_agents = {};
7506
7766
  __export(exports_agents, {
7507
7767
  updateAgentActivity: () => updateAgentActivity,
7508
7768
  updateAgent: () => updateAgent,
7509
7769
  unarchiveAgent: () => unarchiveAgent,
7770
+ suggestAgentNames: () => suggestAgentNames,
7510
7771
  releaseAgent: () => releaseAgent,
7511
7772
  registerAgent: () => registerAgent,
7773
+ normalizeGeneratedAgentNames: () => normalizeGeneratedAgentNames,
7512
7774
  matchCapabilities: () => matchCapabilities,
7513
7775
  listAgents: () => listAgents,
7514
7776
  isAgentConflict: () => isAgentConflict,
@@ -7520,7 +7782,8 @@ __export(exports_agents, {
7520
7782
  getAgent: () => getAgent,
7521
7783
  deleteAgent: () => deleteAgent,
7522
7784
  autoReleaseStaleAgents: () => autoReleaseStaleAgents,
7523
- archiveAgent: () => archiveAgent
7785
+ archiveAgent: () => archiveAgent,
7786
+ InvalidAgentNameError: () => InvalidAgentNameError
7524
7787
  });
7525
7788
  function getActiveWindowMs() {
7526
7789
  const env = process.env["TODOS_AGENT_TIMEOUT_MS"];
@@ -7543,7 +7806,7 @@ function getAvailableNamesFromPool(pool, db) {
7543
7806
  autoReleaseStaleAgents(db);
7544
7807
  const cutoff = new Date(Date.now() - getActiveWindowMs()).toISOString();
7545
7808
  const activeNames = new Set(db.query("SELECT name FROM agents WHERE status = 'active' AND last_seen_at > ?").all(cutoff).map((r) => r.name.toLowerCase()));
7546
- return pool.filter((name) => !activeNames.has(name.toLowerCase()));
7809
+ return pool.map(normalizeAgentNameInput).filter((name) => !activeNames.has(name));
7547
7810
  }
7548
7811
  function shortUuid() {
7549
7812
  return crypto.randomUUID().slice(0, 8);
@@ -7559,7 +7822,8 @@ function rowToAgent(row) {
7559
7822
  }
7560
7823
  function registerAgent(input, db) {
7561
7824
  const d = db || getDatabase();
7562
- const normalizedName = input.name.trim().toLowerCase();
7825
+ const existingNames = d.query("SELECT name FROM agents").all().map((row) => row.name);
7826
+ const normalizedName = validateAgentName(input.name, existingNames);
7563
7827
  const existing = getAgentByName(normalizedName, d);
7564
7828
  if (existing) {
7565
7829
  const lastSeenMs = new Date(existing.last_seen_at).getTime();
@@ -7692,7 +7956,8 @@ function updateAgent(id, input, db) {
7692
7956
  const sets = ["last_seen_at = ?"];
7693
7957
  const params = [now()];
7694
7958
  if (input.name !== undefined) {
7695
- const newName = input.name.trim().toLowerCase();
7959
+ const existingNames = d.query("SELECT name FROM agents WHERE id != ?").all(id).map((row) => row.name);
7960
+ const newName = validateAgentName(input.name, existingNames);
7696
7961
  const holder = getAgentByName(newName, d);
7697
7962
  if (holder && holder.id !== id) {
7698
7963
  const lastSeenMs = new Date(holder.last_seen_at).getTime();
@@ -7803,6 +8068,7 @@ function getCapableAgents(capabilities, opts, db) {
7803
8068
  }
7804
8069
  var init_agents = __esm(() => {
7805
8070
  init_database();
8071
+ init_agent_names();
7806
8072
  });
7807
8073
 
7808
8074
  // src/db/task-lists.ts
@@ -7872,6 +8138,90 @@ var init_task_lists = __esm(() => {
7872
8138
  init_projects();
7873
8139
  });
7874
8140
 
8141
+ // src/db/api-keys.ts
8142
+ import { createHash, randomBytes, timingSafeEqual } from "crypto";
8143
+ function rowToRecord(row) {
8144
+ return {
8145
+ id: row.id,
8146
+ name: row.name,
8147
+ prefix: row.prefix,
8148
+ permissions: JSON.parse(row.permissions || '["*"]'),
8149
+ created_at: row.created_at,
8150
+ last_used_at: row.last_used_at,
8151
+ expires_at: row.expires_at,
8152
+ revoked_at: row.revoked_at
8153
+ };
8154
+ }
8155
+ function hashApiKey(key) {
8156
+ return createHash("sha256").update(key).digest("hex");
8157
+ }
8158
+ function safeEqualHex(a, b) {
8159
+ if (a.length !== b.length)
8160
+ return false;
8161
+ return timingSafeEqual(Buffer.from(a, "hex"), Buffer.from(b, "hex"));
8162
+ }
8163
+ function generatePlaintextKey() {
8164
+ return `tdos_${randomBytes(32).toString("base64url")}`;
8165
+ }
8166
+ function createApiKey(input, db) {
8167
+ const d = db || getDatabase();
8168
+ const name = input.name.trim();
8169
+ if (!name)
8170
+ throw new Error("API key name is required");
8171
+ const key = generatePlaintextKey();
8172
+ const timestamp = now();
8173
+ const id = uuid();
8174
+ const prefix = key.slice(0, 12);
8175
+ d.run(`INSERT INTO api_keys (id, name, key_hash, prefix, permissions, created_at, expires_at)
8176
+ VALUES (?, ?, ?, ?, ?, ?, ?)`, [
8177
+ id,
8178
+ name,
8179
+ hashApiKey(key),
8180
+ prefix,
8181
+ JSON.stringify(input.permissions?.length ? input.permissions : ["*"]),
8182
+ timestamp,
8183
+ input.expires_at || null
8184
+ ]);
8185
+ const row = d.query("SELECT * FROM api_keys WHERE id = ?").get(id);
8186
+ return { key, record: rowToRecord(row) };
8187
+ }
8188
+ function listApiKeys(opts, db) {
8189
+ const d = db || getDatabase();
8190
+ const includeRevoked = opts?.include_revoked ?? false;
8191
+ const sql = includeRevoked ? "SELECT * FROM api_keys ORDER BY created_at DESC" : "SELECT * FROM api_keys WHERE revoked_at IS NULL ORDER BY created_at DESC";
8192
+ return d.query(sql).all().map(rowToRecord);
8193
+ }
8194
+ function hasActiveApiKeys(db) {
8195
+ const d = db || getDatabase();
8196
+ const row = d.query("SELECT COUNT(*) AS count FROM api_keys WHERE revoked_at IS NULL AND (expires_at IS NULL OR expires_at > ?)").get(now());
8197
+ return (row?.count ?? 0) > 0;
8198
+ }
8199
+ function verifyApiKey(key, db) {
8200
+ const d = db || getDatabase();
8201
+ const candidateHash = hashApiKey(key);
8202
+ const rows = d.query("SELECT * FROM api_keys WHERE revoked_at IS NULL AND (expires_at IS NULL OR expires_at > ?)").all(now());
8203
+ for (const row of rows) {
8204
+ if (!safeEqualHex(candidateHash, row.key_hash))
8205
+ continue;
8206
+ d.run("UPDATE api_keys SET last_used_at = ? WHERE id = ?", [now(), row.id]);
8207
+ return rowToRecord({ ...row, last_used_at: now() });
8208
+ }
8209
+ return null;
8210
+ }
8211
+ function revokeApiKey(idOrPrefix, db) {
8212
+ const d = db || getDatabase();
8213
+ const identifier = idOrPrefix.trim();
8214
+ const row = d.query("SELECT * FROM api_keys WHERE id = ? OR prefix = ?").get(identifier, identifier);
8215
+ if (!row)
8216
+ return null;
8217
+ d.run("UPDATE api_keys SET revoked_at = ? WHERE id = ?", [now(), row.id]);
8218
+ const updated = d.query("SELECT * FROM api_keys WHERE id = ?").get(row.id);
8219
+ return rowToRecord(updated);
8220
+ }
8221
+ var init_api_keys = __esm(() => {
8222
+ init_database();
8223
+ });
8224
+
7875
8225
  // src/db/orgs.ts
7876
8226
  function rowToOrg(row) {
7877
8227
  return { ...row, metadata: JSON.parse(row.metadata || "{}") };
@@ -8317,31 +8667,37 @@ function handleDeleteProject(id, _ctx, json2) {
8317
8667
  return json2({ success: true });
8318
8668
  }
8319
8669
  async function handleAgentMe(_req, url, _ctx, json2, taskToSummary2) {
8320
- const name = url.searchParams.get("name");
8321
- if (!name)
8322
- return json2({ error: "Missing name param" }, 400);
8323
- const agentResult = registerAgent({ name });
8324
- if (isAgentConflict(agentResult))
8325
- return json2({ error: agentResult.message, conflict: true }, 409);
8326
- const agent = agentResult;
8327
- const tasks = listTasks({ assigned_to: name });
8328
- const agentIdTasks = listTasks({ agent_id: agent.id });
8329
- const allTasks = [...tasks, ...agentIdTasks.filter((t) => !tasks.some((tt) => tt.id === t.id))];
8330
- const pending = allTasks.filter((t) => t.status === "pending");
8331
- const inProgress = allTasks.filter((t) => t.status === "in_progress");
8332
- const completed = allTasks.filter((t) => t.status === "completed");
8333
- return json2({
8334
- agent,
8335
- pending_tasks: pending.map((t) => taskToSummary2(t)),
8336
- in_progress_tasks: inProgress.map((t) => taskToSummary2(t)),
8337
- stats: {
8338
- total: allTasks.length,
8339
- pending: pending.length,
8340
- in_progress: inProgress.length,
8341
- completed: completed.length,
8342
- completion_rate: allTasks.length > 0 ? Math.round(completed.length / allTasks.length * 100) : 0
8343
- }
8344
- });
8670
+ try {
8671
+ const name = url.searchParams.get("name");
8672
+ if (!name)
8673
+ return json2({ error: "Missing name param" }, 400);
8674
+ const agentResult = registerAgent({ name });
8675
+ if (isAgentConflict(agentResult))
8676
+ return json2({ error: agentResult.message, conflict: true }, 409);
8677
+ const agent = agentResult;
8678
+ const tasks = listTasks({ assigned_to: agent.name });
8679
+ const agentIdTasks = listTasks({ agent_id: agent.id });
8680
+ const allTasks = [...tasks, ...agentIdTasks.filter((t) => !tasks.some((tt) => tt.id === t.id))];
8681
+ const pending = allTasks.filter((t) => t.status === "pending");
8682
+ const inProgress = allTasks.filter((t) => t.status === "in_progress");
8683
+ const completed = allTasks.filter((t) => t.status === "completed");
8684
+ return json2({
8685
+ agent,
8686
+ pending_tasks: pending.map((t) => taskToSummary2(t)),
8687
+ in_progress_tasks: inProgress.map((t) => taskToSummary2(t)),
8688
+ stats: {
8689
+ total: allTasks.length,
8690
+ pending: pending.length,
8691
+ in_progress: inProgress.length,
8692
+ completed: completed.length,
8693
+ completion_rate: allTasks.length > 0 ? Math.round(completed.length / allTasks.length * 100) : 0
8694
+ }
8695
+ });
8696
+ } catch (e) {
8697
+ if (e instanceof InvalidAgentNameError)
8698
+ return json2({ error: e.message, suggestions: e.suggestions }, 400);
8699
+ return json2({ error: e instanceof Error ? e.message : "Failed to get agent profile" }, 500);
8700
+ }
8345
8701
  }
8346
8702
  function handleAgentQueue(agentId, _ctx, json2, taskToSummary2) {
8347
8703
  const pending = listTasks({ status: "pending" });
@@ -8407,6 +8763,8 @@ async function handleRegisterAgent(req, _ctx, json2) {
8407
8763
  return json2({ error: result.message, conflict: true }, 409);
8408
8764
  return json2(result, 201);
8409
8765
  } catch (e) {
8766
+ if (e instanceof InvalidAgentNameError)
8767
+ return json2({ error: e.message, suggestions: e.suggestions }, 400);
8410
8768
  return json2({ error: e instanceof Error ? e.message : "Failed to register agent" }, 500);
8411
8769
  }
8412
8770
  }
@@ -8416,6 +8774,8 @@ async function handleUpdateAgent(id, req, _ctx, json2) {
8416
8774
  const agent = updateAgent(id, body);
8417
8775
  return json2(agent);
8418
8776
  } catch (e) {
8777
+ if (e instanceof InvalidAgentNameError)
8778
+ return json2({ error: e.message, suggestions: e.suggestions }, 400);
8419
8779
  return json2({ error: e instanceof Error ? e.message : "Failed to update agent" }, 500);
8420
8780
  }
8421
8781
  }
@@ -8652,14 +9012,26 @@ function resolveDashboardDir() {
8652
9012
  }
8653
9013
  return join9(process.cwd(), "dashboard", "dist");
8654
9014
  }
9015
+ function getProvidedApiKey(req) {
9016
+ const headerKey = req.headers.get("x-api-key");
9017
+ if (headerKey)
9018
+ return headerKey.trim();
9019
+ const auth = req.headers.get("authorization");
9020
+ if (!auth)
9021
+ return null;
9022
+ return auth.replace(/^Bearer\s+/i, "").trim() || null;
9023
+ }
8655
9024
  function checkAuth(req, apiKey) {
8656
- if (!apiKey)
9025
+ const generatedKeysEnabled = hasActiveApiKeys();
9026
+ if (!apiKey && !generatedKeysEnabled)
8657
9027
  return null;
8658
- const provided = req.headers.get("x-api-key") || req.headers.get("authorization")?.replace("Bearer ", "");
8659
- if (!provided || provided !== apiKey) {
9028
+ const provided = getProvidedApiKey(req);
9029
+ const matchesEnvKey = Boolean(apiKey && provided && provided === apiKey);
9030
+ const matchesGeneratedKey = Boolean(provided && verifyApiKey(provided));
9031
+ if (!matchesEnvKey && !matchesGeneratedKey) {
8660
9032
  return new Response(JSON.stringify({ error: "Unauthorized" }), {
8661
9033
  status: 401,
8662
- headers: { "Content-Type": "application/json", ...SECURITY_HEADERS }
9034
+ headers: { "Content-Type": "application/json", "WWW-Authenticate": "Bearer", ...SECURITY_HEADERS }
8663
9035
  });
8664
9036
  }
8665
9037
  return null;
@@ -9041,6 +9413,7 @@ Dashboard not found at: ${dashboardDir}`);
9041
9413
  var MIME_TYPES, SECURITY_HEADERS, rateLimitMap, RATE_LIMIT_WINDOW_MS = 60000, RATE_LIMIT_MAX = 120;
9042
9414
  var init_serve = __esm(() => {
9043
9415
  init_database();
9416
+ init_api_keys();
9044
9417
  init_routes();
9045
9418
  MIME_TYPES = {
9046
9419
  ".html": "text/html; charset=utf-8",
@@ -14768,7 +15141,7 @@ var init_dist = __esm(() => {
14768
15141
  var nodeCrypto = __require2("crypto");
14769
15142
  module.exports = {
14770
15143
  postgresMd5PasswordHash,
14771
- randomBytes,
15144
+ randomBytes: randomBytes2,
14772
15145
  deriveKey,
14773
15146
  sha256,
14774
15147
  hashByName,
@@ -14778,7 +15151,7 @@ var init_dist = __esm(() => {
14778
15151
  var webCrypto = nodeCrypto.webcrypto || globalThis.crypto;
14779
15152
  var subtleCrypto = webCrypto.subtle;
14780
15153
  var textEncoder = new TextEncoder;
14781
- function randomBytes(length) {
15154
+ function randomBytes2(length) {
14782
15155
  return webCrypto.getRandomValues(Buffer.alloc(length));
14783
15156
  }
14784
15157
  async function md5(string) {
@@ -30986,8 +31359,8 @@ function registerAgentTools(server, { shouldRegisterTool, resolveId, formatError
30986
31359
  });
30987
31360
  }
30988
31361
  if (shouldRegisterTool("register_agent")) {
30989
- server.tool("register_agent", "Register an agent. Any name is allowed \u2014 the configured pool is advisory, not enforced. Returns a conflict error if the name is held by a recently-active agent.", {
30990
- name: exports_external2.string().describe("Agent name \u2014 any name is allowed. Use suggest_agent_name to see pool suggestions and avoid conflicts."),
31362
+ server.tool("register_agent", "Register an agent with a distinctive one-word name. Generic/generated names like agent, agent-1, assistant, or worker-2 are rejected. Prefer Roman or Greek names from suggest_agent_name.", {
31363
+ name: exports_external2.string().describe("Distinctive one-word agent name. Use suggest_agent_name first; avoid agent, agent-1, and other numbered generated names."),
30991
31364
  description: exports_external2.string().optional(),
30992
31365
  role: exports_external2.string().optional(),
30993
31366
  title: exports_external2.string().optional(),
@@ -31031,7 +31404,7 @@ Last seen: ${agent.last_seen_at}`
31031
31404
  });
31032
31405
  }
31033
31406
  if (shouldRegisterTool("suggest_agent_name")) {
31034
- server.tool("suggest_agent_name", "Get available agent names for a project. Shows configured pool, active agents, and suggestions. If no pool is configured, any name is allowed.", {
31407
+ server.tool("suggest_agent_name", "Get available Roman/Greek-style agent names for a project. Use these instead of generic generated names.", {
31035
31408
  working_dir: exports_external2.string().optional().describe("Your working directory \u2014 used to look up the project's allowed name pool from config")
31036
31409
  }, async ({ working_dir }) => {
31037
31410
  try {
@@ -31039,8 +31412,30 @@ Last seen: ${agent.last_seen_at}`
31039
31412
  const cutoff = new Date(Date.now() - 30 * 60 * 1000).toISOString();
31040
31413
  const allActive = listAgents().filter((a) => a.last_seen_at > cutoff);
31041
31414
  if (!pool) {
31415
+ const suggestions = getAvailableNamesFromPool([
31416
+ "caesar",
31417
+ "augustus",
31418
+ "marcus",
31419
+ "brutus",
31420
+ "cicero",
31421
+ "cato",
31422
+ "nero",
31423
+ "claudius",
31424
+ "tiberius",
31425
+ "hadrian",
31426
+ "athena",
31427
+ "apollo",
31428
+ "artemis",
31429
+ "iris",
31430
+ "hector",
31431
+ "sophia",
31432
+ "thalia",
31433
+ "phoebe",
31434
+ "daphne"
31435
+ ], getDatabase());
31042
31436
  const lines2 = [
31043
- "No agent pool configured \u2014 any name is allowed.",
31437
+ "No project pool configured. Use a distinctive one-word name; generic generated names are blocked.",
31438
+ `Suggested names: ${suggestions.slice(0, 8).join(", ")}`,
31044
31439
  allActive.length > 0 ? `Active agents (avoid these names): ${allActive.map((a) => `${a.name} (seen ${Math.round((Date.now() - new Date(a.last_seen_at).getTime()) / 60000)}m ago)`).join(", ")}` : "No active agents.",
31045
31440
  `
31046
31441
  To restrict names, configure agent_pool or project_pools in ~/.hasna/todos/config.json`
@@ -33644,6 +34039,27 @@ Use ${chalk5.cyan(`--agent ${result.id}`)} on future commands.`);
33644
34039
  handleError(e);
33645
34040
  }
33646
34041
  });
34042
+ program2.command("agents-normalize").alias("normalize-agents").description("Rename invalid/generated agent names (agent, agent-1, name-2, two-word names) to safe one-word names").action(async () => {
34043
+ const globalOpts = program2.opts();
34044
+ try {
34045
+ const db = getDatabase();
34046
+ const renamed = normalizeGeneratedAgentNames(db);
34047
+ if (globalOpts.json) {
34048
+ output({ renamed, suggestions: suggestAgentNames(listAgents().map((agent) => agent.name)).slice(0, 5) }, true);
34049
+ return;
34050
+ }
34051
+ if (renamed.length === 0) {
34052
+ console.log(chalk5.green("No invalid or generated agent names found."));
34053
+ return;
34054
+ }
34055
+ console.log(chalk5.green(`Normalized ${renamed.length} agent name(s):`));
34056
+ for (const item of renamed) {
34057
+ console.log(` ${chalk5.cyan(item.id)} ${chalk5.red(item.old_name)} ${chalk5.dim("->")} ${chalk5.bold(item.new_name)} ${chalk5.dim(`(${item.reference_updates} reference updates)`)}`);
34058
+ }
34059
+ } catch (e) {
34060
+ handleError(e);
34061
+ }
34062
+ });
33647
34063
  program2.command("agent-update <name>").alias("agents-update").description("Update an agent's description, role, or other fields").option("--description <text>", "New description").option("--role <role>", "New role").option("--title <title>", "New title").action(async (name, opts) => {
33648
34064
  const globalOpts = program2.opts();
33649
34065
  try {
@@ -33944,7 +34360,7 @@ function registerConfigServeCommands(program2) {
33944
34360
  console.log(JSON.stringify(config, null, 2));
33945
34361
  }
33946
34362
  });
33947
- program2.command("serve").description("Start the web dashboard").option("--port <port>", "Port number", "19427").option("--host <host>", "Host to bind (default: 127.0.0.1 localhost only, use 0.0.0.0 for all interfaces)").option("--no-open", "Don't open browser automatically").action(async (opts) => {
34363
+ program2.command("serve").description("Start the web dashboard").option("--port <port>", "Port number", "19427").option("--host <host>", "Host to bind (default: 127.0.0.1 localhost only, use 0.0.0.0 for all interfaces)").option("--api-key <key>", "Require this API key for /api/* requests").option("--no-open", "Don't open browser automatically").action(async (opts) => {
33948
34364
  const { startServer: startServer2 } = await Promise.resolve().then(() => (init_serve(), exports_serve));
33949
34365
  const requestedPort = parseInt(opts.port, 10);
33950
34366
  let port = requestedPort;
@@ -33959,7 +34375,7 @@ function registerConfigServeCommands(program2) {
33959
34375
  if (port !== requestedPort) {
33960
34376
  console.log(`Port ${requestedPort} in use, using ${port}`);
33961
34377
  }
33962
- await startServer2(port, { open: opts.open !== false, host: opts.host });
34378
+ await startServer2(port, { open: opts.open !== false, host: opts.host, apiKey: opts.apiKey });
33963
34379
  });
33964
34380
  program2.command("watch").description("Live-updating task list (refreshes every few seconds)").option("-s, --status <status>", "Filter by status (default: pending,in_progress)").option("-i, --interval <seconds>", "Refresh interval in seconds", "5").action(async (opts) => {
33965
34381
  const globalOpts = program2.opts();
@@ -36388,6 +36804,91 @@ Sync complete: ${totalPulled} task(s) pulled.`));
36388
36804
  });
36389
36805
  }
36390
36806
 
36807
+ // src/cli/commands/api-key-commands.ts
36808
+ init_api_keys();
36809
+ init_helpers();
36810
+ import chalk12 from "chalk";
36811
+ function registerApiKeyCommands(program2) {
36812
+ const apiKeys = program2.command("api-keys").alias("api-key").description("Generate, list, and revoke API keys for secured app/API access");
36813
+ apiKeys.command("create <name>").alias("generate").description("Generate a new API key. The plaintext key is shown once.").option("--expires-at <iso>", "Optional ISO timestamp when this key expires").option("--permissions <list>", "Comma-separated permissions (default: *)").action((name, opts) => {
36814
+ const globalOpts = program2.opts();
36815
+ try {
36816
+ const permissions = opts.permissions ? opts.permissions.split(",").map((item) => item.trim()).filter(Boolean) : undefined;
36817
+ const created = createApiKey({ name, permissions, expires_at: opts.expiresAt || null });
36818
+ if (globalOpts.json) {
36819
+ output(created, true);
36820
+ return;
36821
+ }
36822
+ console.log(chalk12.green("API key generated:"));
36823
+ console.log(` ${chalk12.dim("ID:")} ${created.record.id}`);
36824
+ console.log(` ${chalk12.dim("Name:")} ${created.record.name}`);
36825
+ console.log(` ${chalk12.dim("Prefix:")} ${created.record.prefix}`);
36826
+ console.log();
36827
+ console.log(chalk12.yellow("Copy this key now. It will not be shown again:"));
36828
+ console.log(created.key);
36829
+ } catch (e) {
36830
+ handleError(e);
36831
+ }
36832
+ });
36833
+ apiKeys.command("list").description("List API keys without showing plaintext secrets").option("--include-revoked", "Include revoked keys").action((opts) => {
36834
+ const globalOpts = program2.opts();
36835
+ try {
36836
+ const keys = listApiKeys({ include_revoked: opts.includeRevoked ?? false });
36837
+ if (globalOpts.json) {
36838
+ output(keys, true);
36839
+ return;
36840
+ }
36841
+ if (keys.length === 0) {
36842
+ console.log(chalk12.dim("No API keys found."));
36843
+ return;
36844
+ }
36845
+ for (const key of keys) {
36846
+ const state = key.revoked_at ? chalk12.red("revoked") : key.expires_at && key.expires_at < new Date().toISOString() ? chalk12.yellow("expired") : chalk12.green("active");
36847
+ console.log(`${chalk12.cyan(key.id)} ${chalk12.bold(key.name)} ${chalk12.dim(key.prefix)} ${state}`);
36848
+ if (key.last_used_at)
36849
+ console.log(chalk12.dim(` last used: ${key.last_used_at}`));
36850
+ if (key.expires_at)
36851
+ console.log(chalk12.dim(` expires: ${key.expires_at}`));
36852
+ }
36853
+ } catch (e) {
36854
+ handleError(e);
36855
+ }
36856
+ });
36857
+ apiKeys.command("revoke <id-or-prefix>").description("Revoke an API key by id or prefix").action((idOrPrefix) => {
36858
+ const globalOpts = program2.opts();
36859
+ try {
36860
+ const revoked = revokeApiKey(idOrPrefix);
36861
+ if (!revoked) {
36862
+ throw new Error(`API key not found: ${idOrPrefix}`);
36863
+ }
36864
+ if (globalOpts.json) {
36865
+ output(revoked, true);
36866
+ return;
36867
+ }
36868
+ console.log(chalk12.green(`Revoked API key: ${revoked.name} (${revoked.prefix})`));
36869
+ } catch (e) {
36870
+ handleError(e);
36871
+ }
36872
+ });
36873
+ apiKeys.command("verify <key>").description("Verify an API key locally without printing stored hashes").action((key) => {
36874
+ const globalOpts = program2.opts();
36875
+ try {
36876
+ const record = verifyApiKey(key);
36877
+ if (globalOpts.json) {
36878
+ output({ valid: Boolean(record), key: record }, true);
36879
+ return;
36880
+ }
36881
+ if (!record) {
36882
+ console.error(chalk12.red("API key is invalid, revoked, or expired."));
36883
+ process.exit(1);
36884
+ }
36885
+ console.log(chalk12.green(`API key valid: ${record.name} (${record.prefix})`));
36886
+ } catch (e) {
36887
+ handleError(e);
36888
+ }
36889
+ });
36890
+ }
36891
+
36391
36892
  // src/cli/index.tsx
36392
36893
  var program2 = new Command;
36393
36894
  program2.name("todos").description("Universal task management for AI coding agents").version(getPackageVersion()).option("--project <path>", "Project path").option("-j, --json", "Output as JSON").option("--agent <name>", "Agent name").option("--session <id>", "Session ID");
@@ -36401,4 +36902,5 @@ registerCloudCommands2(program2);
36401
36902
  registerMcpHooksCommands(program2);
36402
36903
  registerDispatchCommands(program2);
36403
36904
  registerMachineCommands(program2);
36905
+ registerApiKeyCommands(program2);
36404
36906
  program2.parse();