@hasna/todos 0.11.34 → 0.11.35

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,224 @@ 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 suggestAgentNames(existingNames = []) {
7560
+ const existing = new Set([...existingNames].map(normalizeAgentNameInput));
7561
+ return PREFERRED_AGENT_NAMES.filter((name) => !existing.has(name));
7562
+ }
7563
+ function validateAgentName(name, existingNames = []) {
7564
+ const normalized = normalizeAgentNameInput(name);
7565
+ const suggestions = suggestAgentNames(existingNames).slice(0, 5);
7566
+ if (!normalized) {
7567
+ throw new InvalidAgentNameError(name, "choose a real one-word name instead of an empty value", suggestions);
7568
+ }
7569
+ if (/\s/.test(normalized)) {
7570
+ throw new InvalidAgentNameError(name, "use a single word, preferably a Roman or Greek name", suggestions);
7571
+ }
7572
+ if (normalized.length < 3) {
7573
+ throw new InvalidAgentNameError(name, "use a more distinctive name with at least three characters", suggestions);
7574
+ }
7575
+ if (isGenericAgentName(normalized)) {
7576
+ throw new InvalidAgentNameError(name, "generic names like agent, agent-1, assistant, or worker-2 are reserved", suggestions);
7577
+ }
7578
+ if (hasGeneratedNumericSuffix(normalized)) {
7579
+ throw new InvalidAgentNameError(name, "numbered suffix names are not allowed; pick a distinct human-readable name", suggestions);
7580
+ }
7581
+ if (!ONE_WORD_NAME_RE.test(normalized)) {
7582
+ throw new InvalidAgentNameError(name, "use one word made of letters only, preferably a Roman or Greek name", suggestions);
7583
+ }
7584
+ return normalized;
7585
+ }
7586
+ function tableHasColumn(db, table, column) {
7587
+ try {
7588
+ return db.query(`PRAGMA table_info(${table})`).all().some((row) => row.name === column);
7589
+ } catch {
7590
+ return false;
7591
+ }
7592
+ }
7593
+ function updateReferences(db, oldName, newName) {
7594
+ const refs = [
7595
+ ["tasks", "assigned_to"],
7596
+ ["tasks", "agent_id"],
7597
+ ["tasks", "locked_by"],
7598
+ ["tasks", "assigned_by"],
7599
+ ["plans", "agent_id"],
7600
+ ["sessions", "agent_id"],
7601
+ ["task_comments", "agent_id"],
7602
+ ["task_history", "agent_id"],
7603
+ ["webhooks", "agent_id"],
7604
+ ["task_files", "agent_id"],
7605
+ ["task_time_logs", "agent_id"],
7606
+ ["task_watchers", "agent_id"],
7607
+ ["task_checkpoints", "agent_id"],
7608
+ ["task_heartbeats", "agent_id"],
7609
+ ["project_agent_roles", "agent_id"]
7610
+ ];
7611
+ let changed = 0;
7612
+ for (const [table, column] of refs) {
7613
+ if (!tableHasColumn(db, table, column))
7614
+ continue;
7615
+ try {
7616
+ changed += db.run(`UPDATE ${table} SET ${column} = ? WHERE LOWER(${column}) = ?`, [newName, oldName]).changes;
7617
+ } catch {}
7618
+ }
7619
+ return changed;
7620
+ }
7621
+ function normalizeGeneratedAgentNames(db) {
7622
+ const rows = db.query("SELECT * FROM agents ORDER BY created_at, id").all();
7623
+ const existing = new Set(rows.map((agent) => normalizeAgentNameInput(agent.name)));
7624
+ const renamed = [];
7625
+ for (const agent of rows) {
7626
+ const oldName = normalizeAgentNameInput(agent.name);
7627
+ if (!isBlockedAgentName(oldName))
7628
+ continue;
7629
+ const candidates = suggestAgentNames(existing);
7630
+ const replacement = candidates[0];
7631
+ if (!replacement) {
7632
+ throw new Error("No safe agent names are available for normalization");
7633
+ }
7634
+ existing.delete(oldName);
7635
+ existing.add(replacement);
7636
+ db.run("UPDATE agents SET name = ?, last_seen_at = ? WHERE id = ?", [replacement, now(), agent.id]);
7637
+ const referenceUpdates = updateReferences(db, oldName, replacement);
7638
+ renamed.push({
7639
+ id: agent.id,
7640
+ old_name: oldName,
7641
+ new_name: replacement,
7642
+ reference_updates: referenceUpdates
7643
+ });
7644
+ }
7645
+ return renamed;
7646
+ }
7647
+ var InvalidAgentNameError, ROMAN_AGENT_NAMES, GREEK_AGENT_NAMES, NICE_AGENT_NAMES, PREFERRED_AGENT_NAMES, RESERVED_GENERIC_NAMES, NUMERIC_SUFFIX_RE, ONE_WORD_NAME_RE;
7648
+ var init_agent_names = __esm(() => {
7649
+ init_database();
7650
+ InvalidAgentNameError = class InvalidAgentNameError extends Error {
7651
+ suggestions;
7652
+ constructor(name, reason, suggestions = []) {
7653
+ super(`Invalid agent name "${name}": ${reason}${suggestions.length > 0 ? `. Try: ${suggestions.join(", ")}` : ""}`);
7654
+ this.name = "InvalidAgentNameError";
7655
+ this.suggestions = suggestions;
7656
+ }
7657
+ };
7658
+ ROMAN_AGENT_NAMES = [
7659
+ "caesar",
7660
+ "augustus",
7661
+ "marcus",
7662
+ "brutus",
7663
+ "cicero",
7664
+ "cato",
7665
+ "nero",
7666
+ "claudius",
7667
+ "tiberius",
7668
+ "hadrian",
7669
+ "trajan",
7670
+ "vespasian",
7671
+ "domitian",
7672
+ "caligula",
7673
+ "commodus",
7674
+ "livia",
7675
+ "julia",
7676
+ "octavia",
7677
+ "claudia",
7678
+ "agrippina",
7679
+ "cornelia",
7680
+ "valeria",
7681
+ "fulvia",
7682
+ "hortensia",
7683
+ "fabia"
7684
+ ];
7685
+ GREEK_AGENT_NAMES = [
7686
+ "athena",
7687
+ "apollo",
7688
+ "artemis",
7689
+ "hera",
7690
+ "iris",
7691
+ "hector",
7692
+ "achilles",
7693
+ "odysseus",
7694
+ "theseus",
7695
+ "pericles",
7696
+ "solon",
7697
+ "sophia",
7698
+ "thalia",
7699
+ "calliope",
7700
+ "clio",
7701
+ "phoebe",
7702
+ "daphne",
7703
+ "leonidas",
7704
+ "andromeda",
7705
+ "cassander"
7706
+ ];
7707
+ NICE_AGENT_NAMES = [
7708
+ "atlas",
7709
+ "aurora",
7710
+ "ember",
7711
+ "nova",
7712
+ "orion",
7713
+ "rhea",
7714
+ "selene",
7715
+ "sirius",
7716
+ "vesper",
7717
+ "zephyr"
7718
+ ];
7719
+ PREFERRED_AGENT_NAMES = [
7720
+ ...ROMAN_AGENT_NAMES,
7721
+ ...GREEK_AGENT_NAMES,
7722
+ ...NICE_AGENT_NAMES
7723
+ ];
7724
+ RESERVED_GENERIC_NAMES = new Set([
7725
+ "agent",
7726
+ "agents",
7727
+ "ai",
7728
+ "assistant",
7729
+ "bot",
7730
+ "coder",
7731
+ "default",
7732
+ "helper",
7733
+ "model",
7734
+ "system",
7735
+ "user",
7736
+ "worker"
7737
+ ]);
7738
+ NUMERIC_SUFFIX_RE = /[-_]\d+$/;
7739
+ ONE_WORD_NAME_RE = /^[a-z]+$/;
7740
+ });
7741
+
7504
7742
  // src/db/agents.ts
7505
7743
  var exports_agents = {};
7506
7744
  __export(exports_agents, {
7507
7745
  updateAgentActivity: () => updateAgentActivity,
7508
7746
  updateAgent: () => updateAgent,
7509
7747
  unarchiveAgent: () => unarchiveAgent,
7748
+ suggestAgentNames: () => suggestAgentNames,
7510
7749
  releaseAgent: () => releaseAgent,
7511
7750
  registerAgent: () => registerAgent,
7751
+ normalizeGeneratedAgentNames: () => normalizeGeneratedAgentNames,
7512
7752
  matchCapabilities: () => matchCapabilities,
7513
7753
  listAgents: () => listAgents,
7514
7754
  isAgentConflict: () => isAgentConflict,
@@ -7520,7 +7760,8 @@ __export(exports_agents, {
7520
7760
  getAgent: () => getAgent,
7521
7761
  deleteAgent: () => deleteAgent,
7522
7762
  autoReleaseStaleAgents: () => autoReleaseStaleAgents,
7523
- archiveAgent: () => archiveAgent
7763
+ archiveAgent: () => archiveAgent,
7764
+ InvalidAgentNameError: () => InvalidAgentNameError
7524
7765
  });
7525
7766
  function getActiveWindowMs() {
7526
7767
  const env = process.env["TODOS_AGENT_TIMEOUT_MS"];
@@ -7543,7 +7784,7 @@ function getAvailableNamesFromPool(pool, db) {
7543
7784
  autoReleaseStaleAgents(db);
7544
7785
  const cutoff = new Date(Date.now() - getActiveWindowMs()).toISOString();
7545
7786
  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()));
7787
+ return pool.map(normalizeAgentNameInput).filter((name) => !activeNames.has(name));
7547
7788
  }
7548
7789
  function shortUuid() {
7549
7790
  return crypto.randomUUID().slice(0, 8);
@@ -7559,7 +7800,8 @@ function rowToAgent(row) {
7559
7800
  }
7560
7801
  function registerAgent(input, db) {
7561
7802
  const d = db || getDatabase();
7562
- const normalizedName = input.name.trim().toLowerCase();
7803
+ const existingNames = d.query("SELECT name FROM agents").all().map((row) => row.name);
7804
+ const normalizedName = validateAgentName(input.name, existingNames);
7563
7805
  const existing = getAgentByName(normalizedName, d);
7564
7806
  if (existing) {
7565
7807
  const lastSeenMs = new Date(existing.last_seen_at).getTime();
@@ -7692,7 +7934,8 @@ function updateAgent(id, input, db) {
7692
7934
  const sets = ["last_seen_at = ?"];
7693
7935
  const params = [now()];
7694
7936
  if (input.name !== undefined) {
7695
- const newName = input.name.trim().toLowerCase();
7937
+ const existingNames = d.query("SELECT name FROM agents WHERE id != ?").all(id).map((row) => row.name);
7938
+ const newName = validateAgentName(input.name, existingNames);
7696
7939
  const holder = getAgentByName(newName, d);
7697
7940
  if (holder && holder.id !== id) {
7698
7941
  const lastSeenMs = new Date(holder.last_seen_at).getTime();
@@ -7803,6 +8046,7 @@ function getCapableAgents(capabilities, opts, db) {
7803
8046
  }
7804
8047
  var init_agents = __esm(() => {
7805
8048
  init_database();
8049
+ init_agent_names();
7806
8050
  });
7807
8051
 
7808
8052
  // src/db/task-lists.ts
@@ -7872,6 +8116,90 @@ var init_task_lists = __esm(() => {
7872
8116
  init_projects();
7873
8117
  });
7874
8118
 
8119
+ // src/db/api-keys.ts
8120
+ import { createHash, randomBytes, timingSafeEqual } from "crypto";
8121
+ function rowToRecord(row) {
8122
+ return {
8123
+ id: row.id,
8124
+ name: row.name,
8125
+ prefix: row.prefix,
8126
+ permissions: JSON.parse(row.permissions || '["*"]'),
8127
+ created_at: row.created_at,
8128
+ last_used_at: row.last_used_at,
8129
+ expires_at: row.expires_at,
8130
+ revoked_at: row.revoked_at
8131
+ };
8132
+ }
8133
+ function hashApiKey(key) {
8134
+ return createHash("sha256").update(key).digest("hex");
8135
+ }
8136
+ function safeEqualHex(a, b) {
8137
+ if (a.length !== b.length)
8138
+ return false;
8139
+ return timingSafeEqual(Buffer.from(a, "hex"), Buffer.from(b, "hex"));
8140
+ }
8141
+ function generatePlaintextKey() {
8142
+ return `tdos_${randomBytes(32).toString("base64url")}`;
8143
+ }
8144
+ function createApiKey(input, db) {
8145
+ const d = db || getDatabase();
8146
+ const name = input.name.trim();
8147
+ if (!name)
8148
+ throw new Error("API key name is required");
8149
+ const key = generatePlaintextKey();
8150
+ const timestamp = now();
8151
+ const id = uuid();
8152
+ const prefix = key.slice(0, 12);
8153
+ d.run(`INSERT INTO api_keys (id, name, key_hash, prefix, permissions, created_at, expires_at)
8154
+ VALUES (?, ?, ?, ?, ?, ?, ?)`, [
8155
+ id,
8156
+ name,
8157
+ hashApiKey(key),
8158
+ prefix,
8159
+ JSON.stringify(input.permissions?.length ? input.permissions : ["*"]),
8160
+ timestamp,
8161
+ input.expires_at || null
8162
+ ]);
8163
+ const row = d.query("SELECT * FROM api_keys WHERE id = ?").get(id);
8164
+ return { key, record: rowToRecord(row) };
8165
+ }
8166
+ function listApiKeys(opts, db) {
8167
+ const d = db || getDatabase();
8168
+ const includeRevoked = opts?.include_revoked ?? false;
8169
+ 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";
8170
+ return d.query(sql).all().map(rowToRecord);
8171
+ }
8172
+ function hasActiveApiKeys(db) {
8173
+ const d = db || getDatabase();
8174
+ 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());
8175
+ return (row?.count ?? 0) > 0;
8176
+ }
8177
+ function verifyApiKey(key, db) {
8178
+ const d = db || getDatabase();
8179
+ const candidateHash = hashApiKey(key);
8180
+ const rows = d.query("SELECT * FROM api_keys WHERE revoked_at IS NULL AND (expires_at IS NULL OR expires_at > ?)").all(now());
8181
+ for (const row of rows) {
8182
+ if (!safeEqualHex(candidateHash, row.key_hash))
8183
+ continue;
8184
+ d.run("UPDATE api_keys SET last_used_at = ? WHERE id = ?", [now(), row.id]);
8185
+ return rowToRecord({ ...row, last_used_at: now() });
8186
+ }
8187
+ return null;
8188
+ }
8189
+ function revokeApiKey(idOrPrefix, db) {
8190
+ const d = db || getDatabase();
8191
+ const identifier = idOrPrefix.trim();
8192
+ const row = d.query("SELECT * FROM api_keys WHERE id = ? OR prefix = ?").get(identifier, identifier);
8193
+ if (!row)
8194
+ return null;
8195
+ d.run("UPDATE api_keys SET revoked_at = ? WHERE id = ?", [now(), row.id]);
8196
+ const updated = d.query("SELECT * FROM api_keys WHERE id = ?").get(row.id);
8197
+ return rowToRecord(updated);
8198
+ }
8199
+ var init_api_keys = __esm(() => {
8200
+ init_database();
8201
+ });
8202
+
7875
8203
  // src/db/orgs.ts
7876
8204
  function rowToOrg(row) {
7877
8205
  return { ...row, metadata: JSON.parse(row.metadata || "{}") };
@@ -8317,31 +8645,37 @@ function handleDeleteProject(id, _ctx, json2) {
8317
8645
  return json2({ success: true });
8318
8646
  }
8319
8647
  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
- });
8648
+ try {
8649
+ const name = url.searchParams.get("name");
8650
+ if (!name)
8651
+ return json2({ error: "Missing name param" }, 400);
8652
+ const agentResult = registerAgent({ name });
8653
+ if (isAgentConflict(agentResult))
8654
+ return json2({ error: agentResult.message, conflict: true }, 409);
8655
+ const agent = agentResult;
8656
+ const tasks = listTasks({ assigned_to: agent.name });
8657
+ const agentIdTasks = listTasks({ agent_id: agent.id });
8658
+ const allTasks = [...tasks, ...agentIdTasks.filter((t) => !tasks.some((tt) => tt.id === t.id))];
8659
+ const pending = allTasks.filter((t) => t.status === "pending");
8660
+ const inProgress = allTasks.filter((t) => t.status === "in_progress");
8661
+ const completed = allTasks.filter((t) => t.status === "completed");
8662
+ return json2({
8663
+ agent,
8664
+ pending_tasks: pending.map((t) => taskToSummary2(t)),
8665
+ in_progress_tasks: inProgress.map((t) => taskToSummary2(t)),
8666
+ stats: {
8667
+ total: allTasks.length,
8668
+ pending: pending.length,
8669
+ in_progress: inProgress.length,
8670
+ completed: completed.length,
8671
+ completion_rate: allTasks.length > 0 ? Math.round(completed.length / allTasks.length * 100) : 0
8672
+ }
8673
+ });
8674
+ } catch (e) {
8675
+ if (e instanceof InvalidAgentNameError)
8676
+ return json2({ error: e.message, suggestions: e.suggestions }, 400);
8677
+ return json2({ error: e instanceof Error ? e.message : "Failed to get agent profile" }, 500);
8678
+ }
8345
8679
  }
8346
8680
  function handleAgentQueue(agentId, _ctx, json2, taskToSummary2) {
8347
8681
  const pending = listTasks({ status: "pending" });
@@ -8407,6 +8741,8 @@ async function handleRegisterAgent(req, _ctx, json2) {
8407
8741
  return json2({ error: result.message, conflict: true }, 409);
8408
8742
  return json2(result, 201);
8409
8743
  } catch (e) {
8744
+ if (e instanceof InvalidAgentNameError)
8745
+ return json2({ error: e.message, suggestions: e.suggestions }, 400);
8410
8746
  return json2({ error: e instanceof Error ? e.message : "Failed to register agent" }, 500);
8411
8747
  }
8412
8748
  }
@@ -8416,6 +8752,8 @@ async function handleUpdateAgent(id, req, _ctx, json2) {
8416
8752
  const agent = updateAgent(id, body);
8417
8753
  return json2(agent);
8418
8754
  } catch (e) {
8755
+ if (e instanceof InvalidAgentNameError)
8756
+ return json2({ error: e.message, suggestions: e.suggestions }, 400);
8419
8757
  return json2({ error: e instanceof Error ? e.message : "Failed to update agent" }, 500);
8420
8758
  }
8421
8759
  }
@@ -8652,14 +8990,26 @@ function resolveDashboardDir() {
8652
8990
  }
8653
8991
  return join9(process.cwd(), "dashboard", "dist");
8654
8992
  }
8993
+ function getProvidedApiKey(req) {
8994
+ const headerKey = req.headers.get("x-api-key");
8995
+ if (headerKey)
8996
+ return headerKey.trim();
8997
+ const auth = req.headers.get("authorization");
8998
+ if (!auth)
8999
+ return null;
9000
+ return auth.replace(/^Bearer\s+/i, "").trim() || null;
9001
+ }
8655
9002
  function checkAuth(req, apiKey) {
8656
- if (!apiKey)
9003
+ const generatedKeysEnabled = hasActiveApiKeys();
9004
+ if (!apiKey && !generatedKeysEnabled)
8657
9005
  return null;
8658
- const provided = req.headers.get("x-api-key") || req.headers.get("authorization")?.replace("Bearer ", "");
8659
- if (!provided || provided !== apiKey) {
9006
+ const provided = getProvidedApiKey(req);
9007
+ const matchesEnvKey = Boolean(apiKey && provided && provided === apiKey);
9008
+ const matchesGeneratedKey = Boolean(provided && verifyApiKey(provided));
9009
+ if (!matchesEnvKey && !matchesGeneratedKey) {
8660
9010
  return new Response(JSON.stringify({ error: "Unauthorized" }), {
8661
9011
  status: 401,
8662
- headers: { "Content-Type": "application/json", ...SECURITY_HEADERS }
9012
+ headers: { "Content-Type": "application/json", "WWW-Authenticate": "Bearer", ...SECURITY_HEADERS }
8663
9013
  });
8664
9014
  }
8665
9015
  return null;
@@ -9041,6 +9391,7 @@ Dashboard not found at: ${dashboardDir}`);
9041
9391
  var MIME_TYPES, SECURITY_HEADERS, rateLimitMap, RATE_LIMIT_WINDOW_MS = 60000, RATE_LIMIT_MAX = 120;
9042
9392
  var init_serve = __esm(() => {
9043
9393
  init_database();
9394
+ init_api_keys();
9044
9395
  init_routes();
9045
9396
  MIME_TYPES = {
9046
9397
  ".html": "text/html; charset=utf-8",
@@ -14768,7 +15119,7 @@ var init_dist = __esm(() => {
14768
15119
  var nodeCrypto = __require2("crypto");
14769
15120
  module.exports = {
14770
15121
  postgresMd5PasswordHash,
14771
- randomBytes,
15122
+ randomBytes: randomBytes2,
14772
15123
  deriveKey,
14773
15124
  sha256,
14774
15125
  hashByName,
@@ -14778,7 +15129,7 @@ var init_dist = __esm(() => {
14778
15129
  var webCrypto = nodeCrypto.webcrypto || globalThis.crypto;
14779
15130
  var subtleCrypto = webCrypto.subtle;
14780
15131
  var textEncoder = new TextEncoder;
14781
- function randomBytes(length) {
15132
+ function randomBytes2(length) {
14782
15133
  return webCrypto.getRandomValues(Buffer.alloc(length));
14783
15134
  }
14784
15135
  async function md5(string) {
@@ -30986,8 +31337,8 @@ function registerAgentTools(server, { shouldRegisterTool, resolveId, formatError
30986
31337
  });
30987
31338
  }
30988
31339
  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."),
31340
+ 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.", {
31341
+ 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
31342
  description: exports_external2.string().optional(),
30992
31343
  role: exports_external2.string().optional(),
30993
31344
  title: exports_external2.string().optional(),
@@ -31031,7 +31382,7 @@ Last seen: ${agent.last_seen_at}`
31031
31382
  });
31032
31383
  }
31033
31384
  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.", {
31385
+ server.tool("suggest_agent_name", "Get available Roman/Greek-style agent names for a project. Use these instead of generic generated names.", {
31035
31386
  working_dir: exports_external2.string().optional().describe("Your working directory \u2014 used to look up the project's allowed name pool from config")
31036
31387
  }, async ({ working_dir }) => {
31037
31388
  try {
@@ -31039,8 +31390,30 @@ Last seen: ${agent.last_seen_at}`
31039
31390
  const cutoff = new Date(Date.now() - 30 * 60 * 1000).toISOString();
31040
31391
  const allActive = listAgents().filter((a) => a.last_seen_at > cutoff);
31041
31392
  if (!pool) {
31393
+ const suggestions = getAvailableNamesFromPool([
31394
+ "caesar",
31395
+ "augustus",
31396
+ "marcus",
31397
+ "brutus",
31398
+ "cicero",
31399
+ "cato",
31400
+ "nero",
31401
+ "claudius",
31402
+ "tiberius",
31403
+ "hadrian",
31404
+ "athena",
31405
+ "apollo",
31406
+ "artemis",
31407
+ "iris",
31408
+ "hector",
31409
+ "sophia",
31410
+ "thalia",
31411
+ "phoebe",
31412
+ "daphne"
31413
+ ], getDatabase());
31042
31414
  const lines2 = [
31043
- "No agent pool configured \u2014 any name is allowed.",
31415
+ "No project pool configured. Use a distinctive one-word name; generic generated names are blocked.",
31416
+ `Suggested names: ${suggestions.slice(0, 8).join(", ")}`,
31044
31417
  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
31418
  `
31046
31419
  To restrict names, configure agent_pool or project_pools in ~/.hasna/todos/config.json`
@@ -33644,6 +34017,27 @@ Use ${chalk5.cyan(`--agent ${result.id}`)} on future commands.`);
33644
34017
  handleError(e);
33645
34018
  }
33646
34019
  });
34020
+ 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 () => {
34021
+ const globalOpts = program2.opts();
34022
+ try {
34023
+ const db = getDatabase();
34024
+ const renamed = normalizeGeneratedAgentNames(db);
34025
+ if (globalOpts.json) {
34026
+ output({ renamed, suggestions: suggestAgentNames(listAgents().map((agent) => agent.name)).slice(0, 5) }, true);
34027
+ return;
34028
+ }
34029
+ if (renamed.length === 0) {
34030
+ console.log(chalk5.green("No invalid or generated agent names found."));
34031
+ return;
34032
+ }
34033
+ console.log(chalk5.green(`Normalized ${renamed.length} agent name(s):`));
34034
+ for (const item of renamed) {
34035
+ 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)`)}`);
34036
+ }
34037
+ } catch (e) {
34038
+ handleError(e);
34039
+ }
34040
+ });
33647
34041
  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
34042
  const globalOpts = program2.opts();
33649
34043
  try {
@@ -33944,7 +34338,7 @@ function registerConfigServeCommands(program2) {
33944
34338
  console.log(JSON.stringify(config, null, 2));
33945
34339
  }
33946
34340
  });
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) => {
34341
+ 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
34342
  const { startServer: startServer2 } = await Promise.resolve().then(() => (init_serve(), exports_serve));
33949
34343
  const requestedPort = parseInt(opts.port, 10);
33950
34344
  let port = requestedPort;
@@ -33959,7 +34353,7 @@ function registerConfigServeCommands(program2) {
33959
34353
  if (port !== requestedPort) {
33960
34354
  console.log(`Port ${requestedPort} in use, using ${port}`);
33961
34355
  }
33962
- await startServer2(port, { open: opts.open !== false, host: opts.host });
34356
+ await startServer2(port, { open: opts.open !== false, host: opts.host, apiKey: opts.apiKey });
33963
34357
  });
33964
34358
  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
34359
  const globalOpts = program2.opts();
@@ -36388,6 +36782,91 @@ Sync complete: ${totalPulled} task(s) pulled.`));
36388
36782
  });
36389
36783
  }
36390
36784
 
36785
+ // src/cli/commands/api-key-commands.ts
36786
+ init_api_keys();
36787
+ init_helpers();
36788
+ import chalk12 from "chalk";
36789
+ function registerApiKeyCommands(program2) {
36790
+ const apiKeys = program2.command("api-keys").alias("api-key").description("Generate, list, and revoke API keys for secured app/API access");
36791
+ 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) => {
36792
+ const globalOpts = program2.opts();
36793
+ try {
36794
+ const permissions = opts.permissions ? opts.permissions.split(",").map((item) => item.trim()).filter(Boolean) : undefined;
36795
+ const created = createApiKey({ name, permissions, expires_at: opts.expiresAt || null });
36796
+ if (globalOpts.json) {
36797
+ output(created, true);
36798
+ return;
36799
+ }
36800
+ console.log(chalk12.green("API key generated:"));
36801
+ console.log(` ${chalk12.dim("ID:")} ${created.record.id}`);
36802
+ console.log(` ${chalk12.dim("Name:")} ${created.record.name}`);
36803
+ console.log(` ${chalk12.dim("Prefix:")} ${created.record.prefix}`);
36804
+ console.log();
36805
+ console.log(chalk12.yellow("Copy this key now. It will not be shown again:"));
36806
+ console.log(created.key);
36807
+ } catch (e) {
36808
+ handleError(e);
36809
+ }
36810
+ });
36811
+ apiKeys.command("list").description("List API keys without showing plaintext secrets").option("--include-revoked", "Include revoked keys").action((opts) => {
36812
+ const globalOpts = program2.opts();
36813
+ try {
36814
+ const keys = listApiKeys({ include_revoked: opts.includeRevoked ?? false });
36815
+ if (globalOpts.json) {
36816
+ output(keys, true);
36817
+ return;
36818
+ }
36819
+ if (keys.length === 0) {
36820
+ console.log(chalk12.dim("No API keys found."));
36821
+ return;
36822
+ }
36823
+ for (const key of keys) {
36824
+ const state = key.revoked_at ? chalk12.red("revoked") : key.expires_at && key.expires_at < new Date().toISOString() ? chalk12.yellow("expired") : chalk12.green("active");
36825
+ console.log(`${chalk12.cyan(key.id)} ${chalk12.bold(key.name)} ${chalk12.dim(key.prefix)} ${state}`);
36826
+ if (key.last_used_at)
36827
+ console.log(chalk12.dim(` last used: ${key.last_used_at}`));
36828
+ if (key.expires_at)
36829
+ console.log(chalk12.dim(` expires: ${key.expires_at}`));
36830
+ }
36831
+ } catch (e) {
36832
+ handleError(e);
36833
+ }
36834
+ });
36835
+ apiKeys.command("revoke <id-or-prefix>").description("Revoke an API key by id or prefix").action((idOrPrefix) => {
36836
+ const globalOpts = program2.opts();
36837
+ try {
36838
+ const revoked = revokeApiKey(idOrPrefix);
36839
+ if (!revoked) {
36840
+ throw new Error(`API key not found: ${idOrPrefix}`);
36841
+ }
36842
+ if (globalOpts.json) {
36843
+ output(revoked, true);
36844
+ return;
36845
+ }
36846
+ console.log(chalk12.green(`Revoked API key: ${revoked.name} (${revoked.prefix})`));
36847
+ } catch (e) {
36848
+ handleError(e);
36849
+ }
36850
+ });
36851
+ apiKeys.command("verify <key>").description("Verify an API key locally without printing stored hashes").action((key) => {
36852
+ const globalOpts = program2.opts();
36853
+ try {
36854
+ const record = verifyApiKey(key);
36855
+ if (globalOpts.json) {
36856
+ output({ valid: Boolean(record), key: record }, true);
36857
+ return;
36858
+ }
36859
+ if (!record) {
36860
+ console.error(chalk12.red("API key is invalid, revoked, or expired."));
36861
+ process.exit(1);
36862
+ }
36863
+ console.log(chalk12.green(`API key valid: ${record.name} (${record.prefix})`));
36864
+ } catch (e) {
36865
+ handleError(e);
36866
+ }
36867
+ });
36868
+ }
36869
+
36391
36870
  // src/cli/index.tsx
36392
36871
  var program2 = new Command;
36393
36872
  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 +36880,5 @@ registerCloudCommands2(program2);
36401
36880
  registerMcpHooksCommands(program2);
36402
36881
  registerDispatchCommands(program2);
36403
36882
  registerMachineCommands(program2);
36883
+ registerApiKeyCommands(program2);
36404
36884
  program2.parse();