@hasna/conversations 0.1.31 → 0.1.33

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/README.md CHANGED
@@ -10,10 +10,18 @@ Real-time messaging for AI agents on the same machine. Send direct messages betw
10
10
  - **Sessions** -- derived automatically from messages, no manual session management
11
11
  - **Priorities** -- four levels: `low`, `normal`, `high`, `urgent`
12
12
  - **Read tracking** -- per-message read receipts with bulk mark-read support
13
+ - **Message threading** -- `reply_to` field links replies to parent messages; `getThreadReplies()` fetches full threads
14
+ - **Emoji reactions** -- add/remove emoji reactions on any message; `getReactionSummary()` aggregates counts
15
+ - **Message pinning** -- pin important messages per space or session
16
+ - **Agent presence** -- heartbeat, online/offline status, 30-min conflict detection for concurrent sessions
17
+ - **Resource locks** -- advisory and exclusive locks with configurable TTL for coordination
18
+ - **Focus mode** -- scope an agent session to a project; all read tools auto-filter by project
19
+ - **Webhooks** -- async POST notifications to Slack/Discord/email on DM, blocker, space, or @mention
13
20
  - **200ms polling** -- near-instant message delivery via indexed SQLite queries
14
- - **Three surfaces** -- CLI, MCP server (16 tools), and TypeScript library
21
+ - **Three surfaces** -- CLI, MCP server (37 tools), and TypeScript library
15
22
  - **Interactive TUI** -- Ink-based terminal UI for browsing sessions and chatting
16
23
  - **Web dashboard** -- built-in HTTP dashboard for browser-based monitoring
24
+ - **Health check** -- `conversations doctor` validates setup and catches common issues
17
25
  - **Self-updating** -- `conversations update` checks npm and installs the latest version
18
26
 
19
27
  ## Installation
@@ -337,6 +345,140 @@ const spaceMessages = useSpaceMessages("deployments");
337
345
  - **Sessions derived from messages** -- no separate sessions table
338
346
  - **Agent identity resolution**: explicit `--from` flag > `CONVERSATIONS_AGENT_ID` env var > `"user"` fallback
339
347
 
348
+ ## Emoji Reactions
349
+
350
+ Add emoji reactions to any message. Reactions are aggregated by emoji with agent lists.
351
+
352
+ ```bash
353
+ # Add a reaction
354
+ conversations react 42 👍
355
+
356
+ # Remove a reaction
357
+ conversations unreact 42 👍
358
+
359
+ # Show reaction counts for a message
360
+ conversations reactions 42
361
+
362
+ # TypeScript library
363
+ import { addReaction, removeReaction, getReactionSummary } from "@hasna/conversations";
364
+
365
+ addReaction(42, "codex", "✅");
366
+ const summary = getReactionSummary(42);
367
+ // [{ emoji: "✅", count: 1, agents: ["codex"] }]
368
+ ```
369
+
370
+ ## Message Threading
371
+
372
+ Replies link to a parent message via `reply_to`. Use `getThreadReplies()` to fetch all replies.
373
+
374
+ ```bash
375
+ # Reply to message #42 (CLI)
376
+ conversations reply --to 42 "Got it!"
377
+
378
+ # MCP tool
379
+ { "tool": "reply", "message_id": 42, "content": "Got it!" }
380
+ { "tool": "get_thread_replies", "message_id": 42 }
381
+ ```
382
+
383
+ ```typescript
384
+ import { readMessages, getThreadReplies } from "@hasna/conversations";
385
+
386
+ const replies = getThreadReplies(42);
387
+ ```
388
+
389
+ ## Agent Presence
390
+
391
+ Agents announce their presence via heartbeat. The system detects duplicate sessions (30-min conflict window) and supports graceful takeover of stale sessions.
392
+
393
+ ```bash
394
+ # Register agent (with conflict detection)
395
+ conversations agents list
396
+ conversations agents list --online
397
+
398
+ # MCP tools
399
+ { "tool": "register_agent", "name": "codex", "session_id": "sess-abc123", "role": "agent" }
400
+ { "tool": "heartbeat", "from": "codex", "status": "working on auth module" }
401
+ { "tool": "list_agents" }
402
+ ```
403
+
404
+ ```typescript
405
+ import { registerAgent, heartbeat, listAgents, isAgentConflict } from "@hasna/conversations";
406
+
407
+ const result = registerAgent("codex", "sess-abc123", "agent");
408
+ if (isAgentConflict(result)) {
409
+ console.log(`Conflict: ${result.existing_session_id} active since ${result.last_seen_at}`);
410
+ }
411
+ ```
412
+
413
+ ## Resource Locks
414
+
415
+ Coordinate concurrent agent access with advisory or exclusive locks. Locks expire automatically (default 5 minutes).
416
+
417
+ ```typescript
418
+ import { acquireLock, releaseLock, checkLock } from "@hasna/conversations";
419
+
420
+ // Advisory lock (multiple readers allowed, writers coordinate)
421
+ const result = acquireLock("space", "deployments", "codex", "advisory");
422
+ if (!result.acquired) {
423
+ console.log(`Locked by ${result.held_by}`);
424
+ }
425
+
426
+ // Exclusive lock (only one writer)
427
+ acquireLock("pinned_message", "42", "codex", "exclusive", 60_000); // 1 min TTL
428
+ releaseLock("pinned_message", "42", "codex");
429
+ ```
430
+
431
+ ```bash
432
+ # MCP tools
433
+ { "tool": "acquire_lock", "resource_type": "space", "resource_id": "deployments", "lock_type": "advisory" }
434
+ { "tool": "check_lock", "resource_type": "space", "resource_id": "deployments" }
435
+ { "tool": "release_lock", "resource_type": "space", "resource_id": "deployments" }
436
+ { "tool": "list_locks" }
437
+ ```
438
+
439
+ ## Focus Mode
440
+
441
+ Scope an agent session to a project. All read-heavy MCP tools auto-filter to the focused project.
442
+
443
+ ```bash
444
+ # MCP tools
445
+ { "tool": "set_focus", "project_id": "proj-abc123", "from": "codex" }
446
+ { "tool": "get_focus", "from": "codex" }
447
+ { "tool": "unfocus", "from": "codex" }
448
+ ```
449
+
450
+ Priority: explicit `project_id` param > session focus > `register_agent` project > no filter.
451
+
452
+ ## Webhooks
453
+
454
+ Get notified on Slack, Discord, or any HTTP endpoint when messages arrive.
455
+
456
+ Create `~/.conversations/config.json`:
457
+
458
+ ```json
459
+ {
460
+ "webhooks": [
461
+ {
462
+ "url": "https://hooks.slack.com/services/...",
463
+ "events": ["dm", "blocker", "space", "mention"],
464
+ "agent": "andrei"
465
+ }
466
+ ]
467
+ }
468
+ ```
469
+
470
+ Event types: `dm` (direct messages), `blocker` (blocking messages), `space` (space messages), `mention` (@name matches).
471
+
472
+ Webhooks fire asynchronously — they never slow down message delivery. Failed deliveries are silently ignored.
473
+
474
+ ## Health Check
475
+
476
+ ```bash
477
+ conversations doctor
478
+ ```
479
+
480
+ Checks: database accessibility, WAL mode, MCP binary on PATH, npm version, webhook config validity.
481
+
340
482
  ## CLI Commands
341
483
 
342
484
  | Command | Description |
@@ -345,9 +487,19 @@ const spaceMessages = useSpaceMessages("deployments");
345
487
  | `conversations send` | Send a direct message |
346
488
  | `conversations read` | Read messages with filters |
347
489
  | `conversations reply` | Reply to a message by ID |
490
+ | `conversations search <query>` | Full-text search across messages |
491
+ | `conversations since <duration>` | Activity feed since 30m/2h/1d ago |
492
+ | `conversations context` | Session boot context for agents |
348
493
  | `conversations sessions` | List conversation sessions |
349
494
  | `conversations mark-read` | Mark messages as read |
495
+ | `conversations pin <id>` | Pin a message |
496
+ | `conversations unpin <id>` | Unpin a message |
497
+ | `conversations pinned` | List pinned messages |
498
+ | `conversations react <id> <emoji>` | Add emoji reaction |
499
+ | `conversations unreact <id> <emoji>` | Remove emoji reaction |
500
+ | `conversations reactions <id>` | Show reaction summary |
350
501
  | `conversations status` | Show database stats |
502
+ | `conversations doctor` | Health check |
351
503
  | `conversations update` | Check for and install updates |
352
504
  | `conversations space create` | Create a new space |
353
505
  | `conversations space list` | List all spaces |
@@ -361,6 +513,7 @@ const spaceMessages = useSpaceMessages("deployments");
361
513
  | `conversations project get` | Get project details |
362
514
  | `conversations project update` | Update a project |
363
515
  | `conversations project delete` | Delete a project |
516
+ | `conversations agents list` | List agents with presence |
364
517
  | `conversations mcp` | Start MCP server on stdio |
365
518
  | `conversations dashboard` | Start web dashboard |
366
519
 
package/bin/index.js CHANGED
@@ -2498,6 +2498,11 @@ function getUnreadBlockers(agent) {
2498
2498
  `).all(agent, agent);
2499
2499
  return rows.map(parseMessage);
2500
2500
  }
2501
+ function getThreadReplies(messageId) {
2502
+ const db2 = getDb();
2503
+ const rows = db2.prepare("SELECT * FROM messages WHERE reply_to = ? ORDER BY created_at ASC, id ASC").all(messageId);
2504
+ return rows.map(parseMessage);
2505
+ }
2501
2506
  function searchMessages(opts) {
2502
2507
  const db2 = getDb();
2503
2508
  const limit = Number.isFinite(opts.limit) && opts.limit > 0 ? Math.floor(opts.limit) : 20;
@@ -3478,6 +3483,48 @@ var init_presence = __esm(() => {
3478
3483
  CONFLICT_THRESHOLD_SECONDS = 30 * 60;
3479
3484
  });
3480
3485
 
3486
+ // src/lib/reactions.ts
3487
+ function addReaction(messageId, agent, emoji) {
3488
+ const db2 = getDb();
3489
+ const stmt = db2.prepare(`
3490
+ INSERT INTO reactions (message_id, agent, emoji)
3491
+ VALUES (?, ?, ?)
3492
+ ON CONFLICT (message_id, agent, emoji) DO UPDATE SET agent = agent
3493
+ RETURNING *
3494
+ `);
3495
+ const row = stmt.get(messageId, agent, emoji);
3496
+ return row;
3497
+ }
3498
+ function removeReaction(messageId, agent, emoji) {
3499
+ const db2 = getDb();
3500
+ const stmt = db2.prepare("DELETE FROM reactions WHERE message_id = ? AND agent = ? AND emoji = ?");
3501
+ const result = stmt.run(messageId, agent, emoji);
3502
+ return result.changes > 0;
3503
+ }
3504
+ function getReactions(messageId) {
3505
+ const db2 = getDb();
3506
+ const rows = db2.prepare("SELECT * FROM reactions WHERE message_id = ? ORDER BY created_at ASC, id ASC").all(messageId);
3507
+ return rows;
3508
+ }
3509
+ function getReactionSummary(messageId) {
3510
+ const db2 = getDb();
3511
+ const rows = db2.prepare(`
3512
+ SELECT emoji, GROUP_CONCAT(agent) as agents, COUNT(*) as count
3513
+ FROM reactions
3514
+ WHERE message_id = ?
3515
+ GROUP BY emoji
3516
+ ORDER BY count DESC, MIN(created_at) ASC
3517
+ `).all(messageId);
3518
+ return rows.map((row) => ({
3519
+ emoji: row.emoji,
3520
+ count: row.count,
3521
+ agents: row.agents.split(",")
3522
+ }));
3523
+ }
3524
+ var init_reactions = __esm(() => {
3525
+ init_db();
3526
+ });
3527
+
3481
3528
  // src/lib/terminal-markdown.ts
3482
3529
  var exports_terminal_markdown = {};
3483
3530
  __export(exports_terminal_markdown, {
@@ -3622,7 +3669,7 @@ var init_poll = __esm(() => {
3622
3669
  var require_package = __commonJS((exports, module) => {
3623
3670
  module.exports = {
3624
3671
  name: "@hasna/conversations",
3625
- version: "0.1.31",
3672
+ version: "0.1.33",
3626
3673
  description: "Real-time CLI messaging for AI agents",
3627
3674
  type: "module",
3628
3675
  bin: {
@@ -32566,17 +32613,107 @@ var init_stdio2 = __esm(() => {
32566
32613
  init_stdio();
32567
32614
  });
32568
32615
 
32616
+ // src/lib/locks.ts
32617
+ function acquireLock(resourceType, resourceId, agentId, lockType = "advisory", expiryMs = DEFAULT_LOCK_EXPIRY_MS) {
32618
+ const db2 = getDb();
32619
+ return db2.transaction(() => {
32620
+ cleanExpiredLocks();
32621
+ const existing = db2.prepare(`
32622
+ SELECT * FROM resource_locks
32623
+ WHERE resource_type = ? AND resource_id = ? AND lock_type = ?
32624
+ `).get(resourceType, resourceId, lockType);
32625
+ if (existing) {
32626
+ if (existing.agent_id !== agentId) {
32627
+ return { acquired: false, lock: null, held_by: existing.agent_id };
32628
+ }
32629
+ const expiresAt = new Date(Date.now() + expiryMs).toISOString().replace("T", "T").replace("Z", "");
32630
+ db2.prepare(`
32631
+ UPDATE resource_locks SET expires_at = ?, locked_at = strftime('%Y-%m-%dT%H:%M:%f', 'now')
32632
+ WHERE resource_type = ? AND resource_id = ? AND lock_type = ?
32633
+ `).run(expiresAt, resourceType, resourceId, lockType);
32634
+ } else {
32635
+ const expiresAt = new Date(Date.now() + expiryMs).toISOString().slice(0, -1);
32636
+ db2.prepare(`
32637
+ INSERT INTO resource_locks (resource_type, resource_id, agent_id, lock_type, locked_at, expires_at)
32638
+ VALUES (?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%f', 'now'), ?)
32639
+ `).run(resourceType, resourceId, agentId, lockType, expiresAt);
32640
+ }
32641
+ const lock = db2.prepare(`
32642
+ SELECT * FROM resource_locks WHERE resource_type = ? AND resource_id = ? AND lock_type = ?
32643
+ `).get(resourceType, resourceId, lockType);
32644
+ return { acquired: true, lock };
32645
+ }).immediate();
32646
+ }
32647
+ function releaseLock(resourceType, resourceId, agentId) {
32648
+ const db2 = getDb();
32649
+ const result = db2.prepare(`
32650
+ DELETE FROM resource_locks
32651
+ WHERE resource_type = ? AND resource_id = ? AND agent_id = ?
32652
+ `).run(resourceType, resourceId, agentId);
32653
+ return result.changes > 0;
32654
+ }
32655
+ function checkLock(resourceType, resourceId) {
32656
+ const db2 = getDb();
32657
+ cleanExpiredLocks();
32658
+ return db2.prepare(`
32659
+ SELECT * FROM resource_locks
32660
+ WHERE resource_type = ? AND resource_id = ?
32661
+ ORDER BY locked_at ASC
32662
+ LIMIT 1
32663
+ `).get(resourceType, resourceId);
32664
+ }
32665
+ function cleanExpiredLocks() {
32666
+ const db2 = getDb();
32667
+ const result = db2.prepare(`
32668
+ DELETE FROM resource_locks WHERE expires_at < strftime('%Y-%m-%dT%H:%M:%f', 'now')
32669
+ `).run();
32670
+ return result.changes;
32671
+ }
32672
+ function listLocks(opts) {
32673
+ const db2 = getDb();
32674
+ cleanExpiredLocks();
32675
+ let query = "SELECT * FROM resource_locks WHERE 1=1";
32676
+ const params = [];
32677
+ if (opts?.resource_type) {
32678
+ query += " AND resource_type = ?";
32679
+ params.push(opts.resource_type);
32680
+ }
32681
+ if (opts?.agent_id) {
32682
+ query += " AND agent_id = ?";
32683
+ params.push(opts.agent_id);
32684
+ }
32685
+ query += " ORDER BY locked_at ASC";
32686
+ return db2.prepare(query).all(...params);
32687
+ }
32688
+ var DEFAULT_LOCK_EXPIRY_MS;
32689
+ var init_locks = __esm(() => {
32690
+ init_db();
32691
+ DEFAULT_LOCK_EXPIRY_MS = 5 * 60 * 1000;
32692
+ });
32693
+
32569
32694
  // src/mcp/index.ts
32570
32695
  var exports_mcp = {};
32571
32696
  __export(exports_mcp, {
32572
32697
  startMcpServer: () => startMcpServer,
32573
32698
  server: () => server
32574
32699
  });
32700
+ function getAgentFocus(agentId) {
32701
+ if (agentFocus.has(agentId))
32702
+ return agentFocus.get(agentId).project_id;
32703
+ const presence = getPresence(agentId);
32704
+ return presence?.project_id ?? null;
32705
+ }
32706
+ function resolveProjectId(explicitProjectId, agentId) {
32707
+ if (explicitProjectId)
32708
+ return explicitProjectId;
32709
+ const focused = getAgentFocus(agentId);
32710
+ return focused ?? undefined;
32711
+ }
32575
32712
  async function startMcpServer() {
32576
32713
  const transport = new StdioServerTransport;
32577
32714
  await server.connect(transport);
32578
32715
  }
32579
- var import__package, server, isDirectRun;
32716
+ var import__package, server, agentFocus, isDirectRun;
32580
32717
  var init_mcp2 = __esm(() => {
32581
32718
  init_mcp();
32582
32719
  init_stdio2();
@@ -32587,11 +32724,14 @@ var init_mcp2 = __esm(() => {
32587
32724
  init_projects();
32588
32725
  init_identity();
32589
32726
  init_presence();
32727
+ init_reactions();
32728
+ init_locks();
32590
32729
  import__package = __toESM(require_package(), 1);
32591
32730
  server = new McpServer({
32592
32731
  name: "conversations",
32593
32732
  version: import__package.default.version
32594
32733
  });
32734
+ agentFocus = new Map;
32595
32735
  server.registerTool("send_message", {
32596
32736
  description: "Send a DM to an agent.",
32597
32737
  inputSchema: {
@@ -32630,7 +32770,11 @@ var init_mcp2 = __esm(() => {
32630
32770
  unread_only: exports_external.coerce.boolean().optional()
32631
32771
  }
32632
32772
  }, async (args) => {
32633
- const messages = readMessages(args);
32773
+ const agent = resolveIdentity(args.from);
32774
+ const messages = readMessages({
32775
+ ...args,
32776
+ project_id: args.project_id ?? resolveProjectId(undefined, agent)
32777
+ });
32634
32778
  return {
32635
32779
  content: [{ type: "text", text: JSON.stringify(messages) }]
32636
32780
  };
@@ -33222,6 +33366,159 @@ var init_mcp2 = __esm(() => {
33222
33366
  content: [{ type: "text", text: JSON.stringify(messages) }]
33223
33367
  };
33224
33368
  });
33369
+ server.registerTool("add_reaction", {
33370
+ description: "Add an emoji reaction to a message.",
33371
+ inputSchema: {
33372
+ message_id: exports_external.coerce.number(),
33373
+ emoji: exports_external.string(),
33374
+ from: exports_external.string().optional()
33375
+ }
33376
+ }, async (args) => {
33377
+ const { message_id, emoji: emoji3, from: fromParam } = args;
33378
+ const agent = resolveIdentity(fromParam);
33379
+ const reaction = addReaction(message_id, agent, emoji3);
33380
+ return { content: [{ type: "text", text: JSON.stringify(reaction) }] };
33381
+ });
33382
+ server.registerTool("remove_reaction", {
33383
+ description: "Remove an emoji reaction from a message.",
33384
+ inputSchema: {
33385
+ message_id: exports_external.coerce.number(),
33386
+ emoji: exports_external.string(),
33387
+ from: exports_external.string().optional()
33388
+ }
33389
+ }, async (args) => {
33390
+ const { message_id, emoji: emoji3, from: fromParam } = args;
33391
+ const agent = resolveIdentity(fromParam);
33392
+ const removed = removeReaction(message_id, agent, emoji3);
33393
+ return { content: [{ type: "text", text: JSON.stringify({ removed }) }] };
33394
+ });
33395
+ server.registerTool("get_reactions", {
33396
+ description: "Get all reactions for a message.",
33397
+ inputSchema: {
33398
+ message_id: exports_external.coerce.number()
33399
+ }
33400
+ }, async (args) => {
33401
+ const reactions = getReactions(args.message_id);
33402
+ return { content: [{ type: "text", text: JSON.stringify(reactions) }] };
33403
+ });
33404
+ server.registerTool("get_reaction_summary", {
33405
+ description: "Get emoji reaction counts and agent lists for a message.",
33406
+ inputSchema: {
33407
+ message_id: exports_external.coerce.number()
33408
+ }
33409
+ }, async (args) => {
33410
+ const summary = getReactionSummary(args.message_id);
33411
+ return { content: [{ type: "text", text: JSON.stringify(summary) }] };
33412
+ });
33413
+ server.registerTool("acquire_lock", {
33414
+ description: "Acquire an advisory or exclusive lock on a resource. Returns conflict info if another agent holds the lock.",
33415
+ inputSchema: {
33416
+ resource_type: exports_external.string(),
33417
+ resource_id: exports_external.string(),
33418
+ lock_type: exports_external.enum(["advisory", "exclusive"]).optional(),
33419
+ expiry_ms: exports_external.coerce.number().optional(),
33420
+ from: exports_external.string().optional()
33421
+ }
33422
+ }, async (args) => {
33423
+ const { resource_type, resource_id, lock_type, expiry_ms, from: fromParam } = args;
33424
+ const agent = resolveIdentity(fromParam);
33425
+ const result = acquireLock(resource_type, resource_id, agent, lock_type ?? "advisory", expiry_ms);
33426
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
33427
+ });
33428
+ server.registerTool("release_lock", {
33429
+ description: "Release a lock held by the agent on a resource.",
33430
+ inputSchema: {
33431
+ resource_type: exports_external.string(),
33432
+ resource_id: exports_external.string(),
33433
+ from: exports_external.string().optional()
33434
+ }
33435
+ }, async (args) => {
33436
+ const { resource_type, resource_id, from: fromParam } = args;
33437
+ const agent = resolveIdentity(fromParam);
33438
+ const released = releaseLock(resource_type, resource_id, agent);
33439
+ return { content: [{ type: "text", text: JSON.stringify({ released }) }] };
33440
+ });
33441
+ server.registerTool("check_lock", {
33442
+ description: "Check if a resource is currently locked and who holds it.",
33443
+ inputSchema: {
33444
+ resource_type: exports_external.string(),
33445
+ resource_id: exports_external.string()
33446
+ }
33447
+ }, async (args) => {
33448
+ const lock = checkLock(args.resource_type, args.resource_id);
33449
+ return { content: [{ type: "text", text: JSON.stringify(lock ?? { locked: false }) }] };
33450
+ });
33451
+ server.registerTool("list_locks", {
33452
+ description: "List all active (non-expired) locks. Filter by resource_type or agent.",
33453
+ inputSchema: {
33454
+ resource_type: exports_external.string().optional(),
33455
+ agent_id: exports_external.string().optional()
33456
+ }
33457
+ }, async (args) => {
33458
+ const locks = listLocks({ resource_type: args.resource_type, agent_id: args.agent_id });
33459
+ return { content: [{ type: "text", text: JSON.stringify(locks) }] };
33460
+ });
33461
+ server.registerTool("get_thread_replies", {
33462
+ description: "Get all replies in a thread for a given parent message ID.",
33463
+ inputSchema: {
33464
+ message_id: exports_external.coerce.number()
33465
+ }
33466
+ }, async (args) => {
33467
+ const replies = getThreadReplies(args.message_id);
33468
+ return { content: [{ type: "text", text: JSON.stringify(replies) }] };
33469
+ });
33470
+ server.registerTool("set_focus", {
33471
+ description: "Set agent focus to a project. All read-heavy tools will default to this project scope. Stores in MCP session memory AND updates agent_presence.project_id in DB.",
33472
+ inputSchema: {
33473
+ project_id: exports_external.string(),
33474
+ from: exports_external.string().optional()
33475
+ }
33476
+ }, async (args) => {
33477
+ const { project_id, from: fromParam } = args;
33478
+ const agent = resolveIdentity(fromParam);
33479
+ agentFocus.set(agent, { project_id });
33480
+ const db2 = (await Promise.resolve().then(() => (init_db(), exports_db))).getDb();
33481
+ db2.prepare("UPDATE agent_presence SET project_id = ? WHERE agent = ?").run(project_id, agent);
33482
+ return {
33483
+ content: [{ type: "text", text: JSON.stringify({ agent, focused: true, project_id }) }]
33484
+ };
33485
+ });
33486
+ server.registerTool("get_focus", {
33487
+ description: "Get the current focus state for an agent. Returns session focus, DB project_id, and effective project_id used for filtering.",
33488
+ inputSchema: {
33489
+ from: exports_external.string().optional()
33490
+ }
33491
+ }, async (args) => {
33492
+ const agent = resolveIdentity(args.from);
33493
+ const sessionFocus = agentFocus.get(agent) ?? null;
33494
+ const presence = getPresence(agent);
33495
+ const effective = getAgentFocus(agent);
33496
+ return {
33497
+ content: [{
33498
+ type: "text",
33499
+ text: JSON.stringify({
33500
+ agent,
33501
+ session_focus: sessionFocus?.project_id ?? null,
33502
+ db_project_id: presence?.project_id ?? null,
33503
+ effective_project_id: effective
33504
+ })
33505
+ }]
33506
+ };
33507
+ });
33508
+ server.registerTool("unfocus", {
33509
+ description: "Clear agent focus. Removes session focus and clears agent_presence.project_id in DB.",
33510
+ inputSchema: {
33511
+ from: exports_external.string().optional()
33512
+ }
33513
+ }, async (args) => {
33514
+ const agent = resolveIdentity(args.from);
33515
+ agentFocus.delete(agent);
33516
+ const db2 = (await Promise.resolve().then(() => (init_db(), exports_db))).getDb();
33517
+ db2.prepare("UPDATE agent_presence SET project_id = NULL WHERE agent = ?").run(agent);
33518
+ return {
33519
+ content: [{ type: "text", text: JSON.stringify({ agent, focused: false, project_id: null }) }]
33520
+ };
33521
+ });
33225
33522
  server.registerTool("register_agent", {
33226
33523
  description: "Register an agent with conflict detection. Returns AgentConflictError if another active session exists (active = heartbeat within last 30 min). Optional project_id locks agent to a project for the session.",
33227
33524
  inputSchema: {
@@ -33368,6 +33665,18 @@ var init_mcp2 = __esm(() => {
33368
33665
  "pin_message",
33369
33666
  "unpin_message",
33370
33667
  "get_pinned_messages",
33668
+ "add_reaction",
33669
+ "remove_reaction",
33670
+ "get_reactions",
33671
+ "get_reaction_summary",
33672
+ "acquire_lock",
33673
+ "release_lock",
33674
+ "check_lock",
33675
+ "list_locks",
33676
+ "get_thread_replies",
33677
+ "set_focus",
33678
+ "get_focus",
33679
+ "unfocus",
33371
33680
  "register_agent",
33372
33681
  "heartbeat",
33373
33682
  "list_agents",
@@ -33414,6 +33723,18 @@ var init_mcp2 = __esm(() => {
33414
33723
  pin_message: "Pin a message. Required: id",
33415
33724
  unpin_message: "Unpin a message. Required: id",
33416
33725
  get_pinned_messages: "Get pinned messages. Optional: space?, session_id?, limit?",
33726
+ add_reaction: "Add emoji reaction to a message. Required: message_id, emoji. Optional: from?",
33727
+ remove_reaction: "Remove emoji reaction from a message. Required: message_id, emoji. Optional: from?",
33728
+ get_reactions: "Get all reactions for a message. Required: message_id",
33729
+ get_reaction_summary: "Get emoji counts + agent lists for a message. Required: message_id",
33730
+ acquire_lock: "Acquire advisory/exclusive lock on a resource. Required: resource_type, resource_id. Optional: lock_type?(advisory|exclusive), expiry_ms?, from?",
33731
+ release_lock: "Release lock held by agent. Required: resource_type, resource_id. Optional: from?",
33732
+ check_lock: "Check if resource is locked and who holds it. Required: resource_type, resource_id",
33733
+ list_locks: "List active locks. Optional: resource_type?, agent_id?",
33734
+ get_thread_replies: "Get all replies in a thread. Required: message_id. Optional: limit?",
33735
+ set_focus: "Set agent focus to a project. All read tools default to this scope. Required: project_id. Optional: from?",
33736
+ get_focus: "Get current focus: session focus, DB project_id, effective project_id. Optional: from?",
33737
+ unfocus: "Clear agent focus (session + DB). Optional: from?",
33417
33738
  register_agent: "Register agent with conflict detection (30min active window). Required: name, session_id. Optional: role?. Returns AgentConflictError if another session is active.",
33418
33739
  heartbeat: "Register/refresh agent presence. Optional: from?, status?(online|busy|idle, default: online)",
33419
33740
  list_agents: "List agents with presence timestamps. Optional: online_only?(only agents seen in last 60s)",
@@ -33869,6 +34190,23 @@ function startDashboardServer(port = 0, host) {
33869
34190
  const agents = listAgents({ online_only: onlineOnly });
33870
34191
  return jsonResponse(applyFields(agents, url2.searchParams.get("fields")));
33871
34192
  }
34193
+ if (path === "/api/reactions" && req.method === "GET") {
34194
+ const messageIdStr = url2.searchParams.get("message_id");
34195
+ if (!messageIdStr)
34196
+ return jsonResponse({ error: "message_id required" }, 400);
34197
+ const messageId = parseInt(messageIdStr);
34198
+ if (isNaN(messageId))
34199
+ return jsonResponse({ error: "message_id must be a number" }, 400);
34200
+ const summary = url2.searchParams.get("summary") === "true";
34201
+ const result = summary ? getReactionSummary(messageId) : getReactions(messageId);
34202
+ return jsonResponse(result);
34203
+ }
34204
+ if (path === "/api/locks" && req.method === "GET") {
34205
+ const resource_type = url2.searchParams.get("resource_type") ?? undefined;
34206
+ const agent_id = url2.searchParams.get("agent_id") ?? undefined;
34207
+ const locks = listLocks({ resource_type, agent_id });
34208
+ return jsonResponse(locks);
34209
+ }
33872
34210
  if (path === "/api/version" && req.method === "GET") {
33873
34211
  try {
33874
34212
  const pkg2 = await Promise.resolve().then(() => __toESM(require_package(), 1));
@@ -33949,6 +34287,8 @@ var init_serve = __esm(() => {
33949
34287
  init_projects();
33950
34288
  init_db();
33951
34289
  init_presence();
34290
+ init_reactions();
34291
+ init_locks();
33952
34292
  isDirectRun2 = import.meta.url === `file://${process.argv[1]}` || process.argv[1]?.endsWith("serve.ts") || process.argv[1]?.endsWith("serve.js");
33953
34293
  if (isDirectRun2) {
33954
34294
  const port = normalizePort(process.env.PORT, 0);
@@ -33980,6 +34320,7 @@ init_projects();
33980
34320
  init_db();
33981
34321
  init_identity();
33982
34322
  init_presence();
34323
+ init_reactions();
33983
34324
  init_terminal_markdown();
33984
34325
  import chalk3 from "chalk";
33985
34326
  import { render } from "ink";
@@ -35045,6 +35386,87 @@ program2.command("search").description("Search messages by content").argument("<
35045
35386
  }
35046
35387
  closeDb();
35047
35388
  });
35389
+ program2.command("since").description("Show all activity (DMs + spaces) since a duration ago").argument("<duration>", "Duration: e.g. 30m, 2h, 1d").option("--json", "Output as JSON").action((duration3, opts) => {
35390
+ const match = duration3.match(/^(\d+)([mhd])$/);
35391
+ if (!match) {
35392
+ console.error(chalk3.red(`Invalid duration "${duration3}". Use format: 30m, 2h, 1d`));
35393
+ process.exit(1);
35394
+ }
35395
+ const value = parseInt(match[1]);
35396
+ const unit = match[2];
35397
+ const msMap = { m: 60000, h: 3600000, d: 86400000 };
35398
+ const since = new Date(Date.now() - value * msMap[unit]).toISOString().replace("T", "T").slice(0, 23);
35399
+ const messages = readMessages({ since, order: "asc", limit: 200 });
35400
+ if (opts.json) {
35401
+ console.log(JSON.stringify(messages, null, 2));
35402
+ } else {
35403
+ if (messages.length === 0) {
35404
+ console.log(chalk3.dim(`No activity in the last ${duration3}.`));
35405
+ } else {
35406
+ console.log(chalk3.bold(`Activity since ${duration3} ago (${messages.length} message(s)):
35407
+ `));
35408
+ for (const msg of messages) {
35409
+ const time3 = chalk3.dim(msg.created_at.slice(11, 19));
35410
+ const from = chalk3.cyan(msg.from_agent);
35411
+ const where = msg.space ? chalk3.magenta(`#${msg.space}`) : chalk3.yellow(`\u2192 ${msg.to_agent}`);
35412
+ const priority = msg.priority !== "normal" ? chalk3.red(` [${msg.priority}]`) : "";
35413
+ const unread = !msg.read_at ? chalk3.green(" \u2022") : "";
35414
+ const content = renderContent(msg.content);
35415
+ console.log(`${time3} ${from} ${where}${priority}${unread}`);
35416
+ console.log(` ${content}
35417
+ `);
35418
+ }
35419
+ }
35420
+ }
35421
+ closeDb();
35422
+ });
35423
+ program2.command("context").description("One-shot session boot context for agents: online agents, unread DMs, spaces, recent activity").option("--json", "Output as JSON").action((opts) => {
35424
+ const agent = resolveIdentity();
35425
+ heartbeat(agent);
35426
+ const db2 = getDb();
35427
+ const onlineAgents = listAgents({ online_only: true });
35428
+ const unreadDMs = readMessages({ to: agent, unread_only: true, limit: 5 });
35429
+ const mySpaces = db2.prepare(`
35430
+ SELECT s.name, s.description,
35431
+ (SELECT COUNT(*) FROM messages m WHERE m.space = s.name AND m.read_at IS NULL) as unread
35432
+ FROM spaces s
35433
+ JOIN space_members sm ON sm.space = s.name
35434
+ WHERE sm.agent = ?
35435
+ ORDER BY s.name
35436
+ `).all(agent);
35437
+ const recentDMs = readMessages({ to: agent, limit: 3 });
35438
+ const context = { agent, online_agents: onlineAgents, unread_dms: unreadDMs, spaces: mySpaces, recent_dms: recentDMs };
35439
+ if (opts.json) {
35440
+ console.log(JSON.stringify(context, null, 2));
35441
+ } else {
35442
+ console.log(chalk3.bold(`Context for ${chalk3.cyan(agent)}
35443
+ `));
35444
+ if (onlineAgents.length > 0) {
35445
+ const names = onlineAgents.map((a) => chalk3.green(a.agent)).join(", ");
35446
+ console.log(`${chalk3.bold("Online agents:")} ${names}`);
35447
+ } else {
35448
+ console.log(`${chalk3.bold("Online agents:")} ${chalk3.dim("none")}`);
35449
+ }
35450
+ if (unreadDMs.length > 0) {
35451
+ console.log(`${chalk3.bold("Unread DMs:")} ${chalk3.yellow(unreadDMs.length + " message(s)")}`);
35452
+ for (const msg of unreadDMs.slice(0, 3)) {
35453
+ console.log(` ${chalk3.dim(msg.created_at.slice(11, 16))} ${chalk3.cyan(msg.from_agent)}: ${msg.content.slice(0, 80)}`);
35454
+ }
35455
+ } else {
35456
+ console.log(`${chalk3.bold("Unread DMs:")} ${chalk3.dim("none")}`);
35457
+ }
35458
+ if (mySpaces.length > 0) {
35459
+ console.log(`${chalk3.bold("My spaces:")}`);
35460
+ for (const sp of mySpaces) {
35461
+ const unread = sp.unread > 0 ? chalk3.yellow(` (${sp.unread} unread)`) : "";
35462
+ console.log(` ${chalk3.magenta("#" + sp.name)}${unread}`);
35463
+ }
35464
+ } else {
35465
+ console.log(`${chalk3.bold("My spaces:")} ${chalk3.dim("none")}`);
35466
+ }
35467
+ }
35468
+ closeDb();
35469
+ });
35048
35470
  program2.command("sessions").description("List conversation sessions").option("--agent <id>", "Filter sessions involving this agent").option("--json", "Output as JSON").action((opts) => {
35049
35471
  const sessions = listSessions(opts.agent);
35050
35472
  if (opts.json) {
@@ -35159,6 +35581,79 @@ program2.command("status").description("Show database stats").option("--json", "
35159
35581
  }
35160
35582
  closeDb();
35161
35583
  });
35584
+ program2.command("doctor").description("Check conversations setup and health").option("--json", "Output as JSON").action(async (opts) => {
35585
+ const checks4 = [];
35586
+ try {
35587
+ const db2 = getDb();
35588
+ db2.prepare("SELECT 1").get();
35589
+ const dbPath = getDbPath();
35590
+ checks4.push({ name: "Database", ok: true, message: `OK \u2014 ${dbPath}` });
35591
+ } catch (e) {
35592
+ checks4.push({ name: "Database", ok: false, message: `Cannot open DB: ${e.message}` });
35593
+ }
35594
+ try {
35595
+ const db2 = getDb();
35596
+ const mode = db2.prepare("PRAGMA journal_mode").get();
35597
+ const isWal = mode.journal_mode === "wal";
35598
+ checks4.push({ name: "WAL mode", ok: isWal, message: isWal ? "OK \u2014 WAL mode enabled" : `WARNING \u2014 journal_mode is ${mode.journal_mode}` });
35599
+ } catch {
35600
+ checks4.push({ name: "WAL mode", ok: false, message: "Could not check WAL mode" });
35601
+ }
35602
+ try {
35603
+ const proc = Bun.spawn(["which", "conversations-mcp"], { stdout: "pipe", stderr: "pipe" });
35604
+ const exit = await proc.exited;
35605
+ const path = await new Response(proc.stdout).text();
35606
+ checks4.push({ name: "MCP binary", ok: exit === 0, message: exit === 0 ? `OK \u2014 ${path.trim()}` : "conversations-mcp not found in PATH \u2014 run: bun install -g @hasna/conversations" });
35607
+ } catch {
35608
+ checks4.push({ name: "MCP binary", ok: false, message: "Could not check MCP binary" });
35609
+ }
35610
+ try {
35611
+ const current = import__package2.default.version;
35612
+ const res = await fetch("https://registry.npmjs.org/@hasna/conversations/latest");
35613
+ const data = await res.json();
35614
+ const latest = data.version;
35615
+ const upToDate = current === latest;
35616
+ checks4.push({ name: "npm version", ok: upToDate, message: upToDate ? `OK \u2014 v${current} (latest)` : `Update available: v${current} \u2192 v${latest} \u2014 run: bun install -g @hasna/conversations@latest` });
35617
+ } catch {
35618
+ checks4.push({ name: "npm version", ok: true, message: "Could not check npm registry (offline?)" });
35619
+ }
35620
+ const { homedir: homedir5 } = await import("os");
35621
+ const { existsSync: existsSync2 } = await import("fs");
35622
+ const { join: join6 } = await import("path");
35623
+ const configPath = process.env.CONVERSATIONS_CONFIG_PATH ?? join6(homedir5(), ".conversations", "config.json");
35624
+ if (existsSync2(configPath)) {
35625
+ try {
35626
+ const { readFileSync: readFileSync3 } = await import("fs");
35627
+ JSON.parse(readFileSync3(configPath, "utf8"));
35628
+ checks4.push({ name: "Webhook config", ok: true, message: `OK \u2014 ${configPath}` });
35629
+ } catch (e) {
35630
+ checks4.push({ name: "Webhook config", ok: false, message: `Invalid JSON at ${configPath}: ${e.message}` });
35631
+ }
35632
+ } else {
35633
+ checks4.push({ name: "Webhook config", ok: true, message: "No webhook config (optional)" });
35634
+ }
35635
+ closeDb();
35636
+ const allOk = checks4.every((c) => c.ok);
35637
+ if (opts.json) {
35638
+ console.log(JSON.stringify({ ok: allOk, checks: checks4 }, null, 2));
35639
+ } else {
35640
+ console.log(chalk3.bold(`Conversations Doctor
35641
+ `));
35642
+ for (const check2 of checks4) {
35643
+ const icon = check2.ok ? chalk3.green("\u2713") : chalk3.red("\u2717");
35644
+ const label = chalk3.bold(check2.name.padEnd(16));
35645
+ console.log(` ${icon} ${label} ${check2.message}`);
35646
+ }
35647
+ console.log();
35648
+ if (allOk) {
35649
+ console.log(chalk3.green("All checks passed."));
35650
+ } else {
35651
+ const failed = checks4.filter((c) => !c.ok).length;
35652
+ console.log(chalk3.red(`${failed} check(s) failed.`));
35653
+ process.exit(1);
35654
+ }
35655
+ }
35656
+ });
35162
35657
  program2.command("update").description("Check for and install updates").option("--check", "Only check for updates, don't install").option("--json", "Output as JSON").action(async (opts) => {
35163
35658
  const pkg3 = await Promise.resolve().then(() => __toESM(require_package(), 1));
35164
35659
  const current = pkg3.version;
@@ -35685,6 +36180,64 @@ program2.command("unpin").description("Unpin a message").argument("<id>", "Messa
35685
36180
  }
35686
36181
  closeDb();
35687
36182
  });
36183
+ program2.command("pinned").description("List pinned messages").option("--space <name>", "Filter by space").option("--session <id>", "Filter by session ID").option("--limit <n>", "Max results", parseInt).option("--json", "Output as JSON").action((opts) => {
36184
+ const messages = getPinnedMessages({ space: opts.space, session_id: opts.session, limit: opts.limit });
36185
+ if (opts.json) {
36186
+ console.log(JSON.stringify(messages, null, 2));
36187
+ } else {
36188
+ if (messages.length === 0) {
36189
+ console.log(chalk3.dim("No pinned messages."));
36190
+ } else {
36191
+ console.log(chalk3.dim(`${messages.length} pinned message(s):
36192
+ `));
36193
+ for (const msg of messages) {
36194
+ const time3 = chalk3.dim(msg.created_at.slice(11, 19));
36195
+ const from = chalk3.cyan(msg.from_agent);
36196
+ const where = msg.space ? chalk3.magenta(`#${msg.space}`) : chalk3.yellow(msg.to_agent);
36197
+ console.log(`${chalk3.yellow("\uD83D\uDCCC")} [#${msg.id}] ${time3} ${from} \u2192 ${where}: ${msg.content}`);
36198
+ }
36199
+ }
36200
+ }
36201
+ closeDb();
36202
+ });
36203
+ program2.command("react").description("Add an emoji reaction to a message").argument("<id>", "Message ID", parseInt).argument("<emoji>", "Emoji to react with").option("--from <agent>", "Agent identity override").option("--json", "Output as JSON").action((id, emoji3, opts) => {
36204
+ const agent = resolveIdentity(opts.from);
36205
+ const reaction = addReaction(id, agent, emoji3);
36206
+ if (opts.json) {
36207
+ console.log(JSON.stringify(reaction, null, 2));
36208
+ } else {
36209
+ console.log(chalk3.green(`${emoji3} reaction added to message #${id}`));
36210
+ }
36211
+ closeDb();
36212
+ });
36213
+ program2.command("unreact").description("Remove an emoji reaction from a message").argument("<id>", "Message ID", parseInt).argument("<emoji>", "Emoji to remove").option("--from <agent>", "Agent identity override").option("--json", "Output as JSON").action((id, emoji3, opts) => {
36214
+ const agent = resolveIdentity(opts.from);
36215
+ const removed = removeReaction(id, agent, emoji3);
36216
+ if (opts.json) {
36217
+ console.log(JSON.stringify({ removed }, null, 2));
36218
+ } else {
36219
+ if (removed) {
36220
+ console.log(chalk3.green(`${emoji3} reaction removed from message #${id}`));
36221
+ } else {
36222
+ console.log(chalk3.dim(`No ${emoji3} reaction found on message #${id}`));
36223
+ }
36224
+ }
36225
+ closeDb();
36226
+ });
36227
+ program2.command("reactions").description("Show emoji reactions on a message").argument("<id>", "Message ID", parseInt).option("--json", "Output as JSON").action((id, opts) => {
36228
+ const summary = getReactionSummary(id);
36229
+ if (opts.json) {
36230
+ console.log(JSON.stringify(summary, null, 2));
36231
+ } else {
36232
+ if (summary.length === 0) {
36233
+ console.log(chalk3.dim(`No reactions on message #${id}`));
36234
+ } else {
36235
+ const parts = summary.map((r) => `${r.emoji} ${r.count}`).join(" ");
36236
+ console.log(`Message #${id}: ${parts}`);
36237
+ }
36238
+ }
36239
+ closeDb();
36240
+ });
35688
36241
  var agents = program2.command("agents").description("Manage agents");
35689
36242
  agents.command("list").description("List all agents with their presence status").option("--online", "Only show online agents").option("--json", "Output as JSON").action((opts) => {
35690
36243
  const agent = resolveIdentity();
package/bin/mcp.js CHANGED
@@ -28939,6 +28939,11 @@ function getUnreadBlockers(agent) {
28939
28939
  `).all(agent, agent);
28940
28940
  return rows.map(parseMessage);
28941
28941
  }
28942
+ function getThreadReplies(messageId) {
28943
+ const db2 = getDb();
28944
+ const rows = db2.prepare("SELECT * FROM messages WHERE reply_to = ? ORDER BY created_at ASC, id ASC").all(messageId);
28945
+ return rows.map(parseMessage);
28946
+ }
28942
28947
  function searchMessages(opts) {
28943
28948
  const db2 = getDb();
28944
28949
  const limit = Number.isFinite(opts.limit) && opts.limit > 0 ? Math.floor(opts.limit) : 20;
@@ -29863,6 +29868,12 @@ function heartbeat(agent, status, metadata, sessionId) {
29863
29868
  metadata = excluded.metadata
29864
29869
  `).run(id, normalizedAgent, sessionId ?? null, resolvedStatus, metadataJson);
29865
29870
  }
29871
+ function getPresence(agent) {
29872
+ const db2 = getDb();
29873
+ const normalizedAgent = agent.trim().toLowerCase();
29874
+ const row = db2.prepare("SELECT * FROM agent_presence WHERE LOWER(agent) = ?").get(normalizedAgent);
29875
+ return row ? parsePresence(row) : null;
29876
+ }
29866
29877
  function listAgents(opts) {
29867
29878
  const db2 = getDb();
29868
29879
  let query = "SELECT * FROM agent_presence";
@@ -29892,10 +29903,125 @@ function renameAgent(oldName, newName) {
29892
29903
  db2.prepare("UPDATE agent_presence SET agent = ? WHERE LOWER(agent) = ?").run(normalizedNew, normalizedOld);
29893
29904
  return true;
29894
29905
  }
29906
+
29907
+ // src/lib/reactions.ts
29908
+ init_db();
29909
+ function addReaction(messageId, agent, emoji3) {
29910
+ const db2 = getDb();
29911
+ const stmt = db2.prepare(`
29912
+ INSERT INTO reactions (message_id, agent, emoji)
29913
+ VALUES (?, ?, ?)
29914
+ ON CONFLICT (message_id, agent, emoji) DO UPDATE SET agent = agent
29915
+ RETURNING *
29916
+ `);
29917
+ const row = stmt.get(messageId, agent, emoji3);
29918
+ return row;
29919
+ }
29920
+ function removeReaction(messageId, agent, emoji3) {
29921
+ const db2 = getDb();
29922
+ const stmt = db2.prepare("DELETE FROM reactions WHERE message_id = ? AND agent = ? AND emoji = ?");
29923
+ const result = stmt.run(messageId, agent, emoji3);
29924
+ return result.changes > 0;
29925
+ }
29926
+ function getReactions(messageId) {
29927
+ const db2 = getDb();
29928
+ const rows = db2.prepare("SELECT * FROM reactions WHERE message_id = ? ORDER BY created_at ASC, id ASC").all(messageId);
29929
+ return rows;
29930
+ }
29931
+ function getReactionSummary(messageId) {
29932
+ const db2 = getDb();
29933
+ const rows = db2.prepare(`
29934
+ SELECT emoji, GROUP_CONCAT(agent) as agents, COUNT(*) as count
29935
+ FROM reactions
29936
+ WHERE message_id = ?
29937
+ GROUP BY emoji
29938
+ ORDER BY count DESC, MIN(created_at) ASC
29939
+ `).all(messageId);
29940
+ return rows.map((row) => ({
29941
+ emoji: row.emoji,
29942
+ count: row.count,
29943
+ agents: row.agents.split(",")
29944
+ }));
29945
+ }
29946
+
29947
+ // src/lib/locks.ts
29948
+ init_db();
29949
+ var DEFAULT_LOCK_EXPIRY_MS = 5 * 60 * 1000;
29950
+ function acquireLock(resourceType, resourceId, agentId, lockType = "advisory", expiryMs = DEFAULT_LOCK_EXPIRY_MS) {
29951
+ const db2 = getDb();
29952
+ return db2.transaction(() => {
29953
+ cleanExpiredLocks();
29954
+ const existing = db2.prepare(`
29955
+ SELECT * FROM resource_locks
29956
+ WHERE resource_type = ? AND resource_id = ? AND lock_type = ?
29957
+ `).get(resourceType, resourceId, lockType);
29958
+ if (existing) {
29959
+ if (existing.agent_id !== agentId) {
29960
+ return { acquired: false, lock: null, held_by: existing.agent_id };
29961
+ }
29962
+ const expiresAt = new Date(Date.now() + expiryMs).toISOString().replace("T", "T").replace("Z", "");
29963
+ db2.prepare(`
29964
+ UPDATE resource_locks SET expires_at = ?, locked_at = strftime('%Y-%m-%dT%H:%M:%f', 'now')
29965
+ WHERE resource_type = ? AND resource_id = ? AND lock_type = ?
29966
+ `).run(expiresAt, resourceType, resourceId, lockType);
29967
+ } else {
29968
+ const expiresAt = new Date(Date.now() + expiryMs).toISOString().slice(0, -1);
29969
+ db2.prepare(`
29970
+ INSERT INTO resource_locks (resource_type, resource_id, agent_id, lock_type, locked_at, expires_at)
29971
+ VALUES (?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%f', 'now'), ?)
29972
+ `).run(resourceType, resourceId, agentId, lockType, expiresAt);
29973
+ }
29974
+ const lock = db2.prepare(`
29975
+ SELECT * FROM resource_locks WHERE resource_type = ? AND resource_id = ? AND lock_type = ?
29976
+ `).get(resourceType, resourceId, lockType);
29977
+ return { acquired: true, lock };
29978
+ }).immediate();
29979
+ }
29980
+ function releaseLock(resourceType, resourceId, agentId) {
29981
+ const db2 = getDb();
29982
+ const result = db2.prepare(`
29983
+ DELETE FROM resource_locks
29984
+ WHERE resource_type = ? AND resource_id = ? AND agent_id = ?
29985
+ `).run(resourceType, resourceId, agentId);
29986
+ return result.changes > 0;
29987
+ }
29988
+ function checkLock(resourceType, resourceId) {
29989
+ const db2 = getDb();
29990
+ cleanExpiredLocks();
29991
+ return db2.prepare(`
29992
+ SELECT * FROM resource_locks
29993
+ WHERE resource_type = ? AND resource_id = ?
29994
+ ORDER BY locked_at ASC
29995
+ LIMIT 1
29996
+ `).get(resourceType, resourceId);
29997
+ }
29998
+ function cleanExpiredLocks() {
29999
+ const db2 = getDb();
30000
+ const result = db2.prepare(`
30001
+ DELETE FROM resource_locks WHERE expires_at < strftime('%Y-%m-%dT%H:%M:%f', 'now')
30002
+ `).run();
30003
+ return result.changes;
30004
+ }
30005
+ function listLocks(opts) {
30006
+ const db2 = getDb();
30007
+ cleanExpiredLocks();
30008
+ let query = "SELECT * FROM resource_locks WHERE 1=1";
30009
+ const params = [];
30010
+ if (opts?.resource_type) {
30011
+ query += " AND resource_type = ?";
30012
+ params.push(opts.resource_type);
30013
+ }
30014
+ if (opts?.agent_id) {
30015
+ query += " AND agent_id = ?";
30016
+ params.push(opts.agent_id);
30017
+ }
30018
+ query += " ORDER BY locked_at ASC";
30019
+ return db2.prepare(query).all(...params);
30020
+ }
29895
30021
  // package.json
29896
30022
  var package_default = {
29897
30023
  name: "@hasna/conversations",
29898
- version: "0.1.31",
30024
+ version: "0.1.33",
29899
30025
  description: "Real-time CLI messaging for AI agents",
29900
30026
  type: "module",
29901
30027
  bin: {
@@ -29977,6 +30103,19 @@ var server = new McpServer({
29977
30103
  name: "conversations",
29978
30104
  version: package_default.version
29979
30105
  });
30106
+ var agentFocus = new Map;
30107
+ function getAgentFocus(agentId) {
30108
+ if (agentFocus.has(agentId))
30109
+ return agentFocus.get(agentId).project_id;
30110
+ const presence = getPresence(agentId);
30111
+ return presence?.project_id ?? null;
30112
+ }
30113
+ function resolveProjectId(explicitProjectId, agentId) {
30114
+ if (explicitProjectId)
30115
+ return explicitProjectId;
30116
+ const focused = getAgentFocus(agentId);
30117
+ return focused ?? undefined;
30118
+ }
29980
30119
  server.registerTool("send_message", {
29981
30120
  description: "Send a DM to an agent.",
29982
30121
  inputSchema: {
@@ -30015,7 +30154,11 @@ server.registerTool("read_messages", {
30015
30154
  unread_only: exports_external.coerce.boolean().optional()
30016
30155
  }
30017
30156
  }, async (args) => {
30018
- const messages = readMessages(args);
30157
+ const agent = resolveIdentity(args.from);
30158
+ const messages = readMessages({
30159
+ ...args,
30160
+ project_id: args.project_id ?? resolveProjectId(undefined, agent)
30161
+ });
30019
30162
  return {
30020
30163
  content: [{ type: "text", text: JSON.stringify(messages) }]
30021
30164
  };
@@ -30607,6 +30750,159 @@ server.registerTool("get_pinned_messages", {
30607
30750
  content: [{ type: "text", text: JSON.stringify(messages) }]
30608
30751
  };
30609
30752
  });
30753
+ server.registerTool("add_reaction", {
30754
+ description: "Add an emoji reaction to a message.",
30755
+ inputSchema: {
30756
+ message_id: exports_external.coerce.number(),
30757
+ emoji: exports_external.string(),
30758
+ from: exports_external.string().optional()
30759
+ }
30760
+ }, async (args) => {
30761
+ const { message_id, emoji: emoji3, from: fromParam } = args;
30762
+ const agent = resolveIdentity(fromParam);
30763
+ const reaction = addReaction(message_id, agent, emoji3);
30764
+ return { content: [{ type: "text", text: JSON.stringify(reaction) }] };
30765
+ });
30766
+ server.registerTool("remove_reaction", {
30767
+ description: "Remove an emoji reaction from a message.",
30768
+ inputSchema: {
30769
+ message_id: exports_external.coerce.number(),
30770
+ emoji: exports_external.string(),
30771
+ from: exports_external.string().optional()
30772
+ }
30773
+ }, async (args) => {
30774
+ const { message_id, emoji: emoji3, from: fromParam } = args;
30775
+ const agent = resolveIdentity(fromParam);
30776
+ const removed = removeReaction(message_id, agent, emoji3);
30777
+ return { content: [{ type: "text", text: JSON.stringify({ removed }) }] };
30778
+ });
30779
+ server.registerTool("get_reactions", {
30780
+ description: "Get all reactions for a message.",
30781
+ inputSchema: {
30782
+ message_id: exports_external.coerce.number()
30783
+ }
30784
+ }, async (args) => {
30785
+ const reactions = getReactions(args.message_id);
30786
+ return { content: [{ type: "text", text: JSON.stringify(reactions) }] };
30787
+ });
30788
+ server.registerTool("get_reaction_summary", {
30789
+ description: "Get emoji reaction counts and agent lists for a message.",
30790
+ inputSchema: {
30791
+ message_id: exports_external.coerce.number()
30792
+ }
30793
+ }, async (args) => {
30794
+ const summary = getReactionSummary(args.message_id);
30795
+ return { content: [{ type: "text", text: JSON.stringify(summary) }] };
30796
+ });
30797
+ server.registerTool("acquire_lock", {
30798
+ description: "Acquire an advisory or exclusive lock on a resource. Returns conflict info if another agent holds the lock.",
30799
+ inputSchema: {
30800
+ resource_type: exports_external.string(),
30801
+ resource_id: exports_external.string(),
30802
+ lock_type: exports_external.enum(["advisory", "exclusive"]).optional(),
30803
+ expiry_ms: exports_external.coerce.number().optional(),
30804
+ from: exports_external.string().optional()
30805
+ }
30806
+ }, async (args) => {
30807
+ const { resource_type, resource_id, lock_type, expiry_ms, from: fromParam } = args;
30808
+ const agent = resolveIdentity(fromParam);
30809
+ const result = acquireLock(resource_type, resource_id, agent, lock_type ?? "advisory", expiry_ms);
30810
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
30811
+ });
30812
+ server.registerTool("release_lock", {
30813
+ description: "Release a lock held by the agent on a resource.",
30814
+ inputSchema: {
30815
+ resource_type: exports_external.string(),
30816
+ resource_id: exports_external.string(),
30817
+ from: exports_external.string().optional()
30818
+ }
30819
+ }, async (args) => {
30820
+ const { resource_type, resource_id, from: fromParam } = args;
30821
+ const agent = resolveIdentity(fromParam);
30822
+ const released = releaseLock(resource_type, resource_id, agent);
30823
+ return { content: [{ type: "text", text: JSON.stringify({ released }) }] };
30824
+ });
30825
+ server.registerTool("check_lock", {
30826
+ description: "Check if a resource is currently locked and who holds it.",
30827
+ inputSchema: {
30828
+ resource_type: exports_external.string(),
30829
+ resource_id: exports_external.string()
30830
+ }
30831
+ }, async (args) => {
30832
+ const lock = checkLock(args.resource_type, args.resource_id);
30833
+ return { content: [{ type: "text", text: JSON.stringify(lock ?? { locked: false }) }] };
30834
+ });
30835
+ server.registerTool("list_locks", {
30836
+ description: "List all active (non-expired) locks. Filter by resource_type or agent.",
30837
+ inputSchema: {
30838
+ resource_type: exports_external.string().optional(),
30839
+ agent_id: exports_external.string().optional()
30840
+ }
30841
+ }, async (args) => {
30842
+ const locks = listLocks({ resource_type: args.resource_type, agent_id: args.agent_id });
30843
+ return { content: [{ type: "text", text: JSON.stringify(locks) }] };
30844
+ });
30845
+ server.registerTool("get_thread_replies", {
30846
+ description: "Get all replies in a thread for a given parent message ID.",
30847
+ inputSchema: {
30848
+ message_id: exports_external.coerce.number()
30849
+ }
30850
+ }, async (args) => {
30851
+ const replies = getThreadReplies(args.message_id);
30852
+ return { content: [{ type: "text", text: JSON.stringify(replies) }] };
30853
+ });
30854
+ server.registerTool("set_focus", {
30855
+ description: "Set agent focus to a project. All read-heavy tools will default to this project scope. Stores in MCP session memory AND updates agent_presence.project_id in DB.",
30856
+ inputSchema: {
30857
+ project_id: exports_external.string(),
30858
+ from: exports_external.string().optional()
30859
+ }
30860
+ }, async (args) => {
30861
+ const { project_id, from: fromParam } = args;
30862
+ const agent = resolveIdentity(fromParam);
30863
+ agentFocus.set(agent, { project_id });
30864
+ const db2 = (await Promise.resolve().then(() => (init_db(), exports_db))).getDb();
30865
+ db2.prepare("UPDATE agent_presence SET project_id = ? WHERE agent = ?").run(project_id, agent);
30866
+ return {
30867
+ content: [{ type: "text", text: JSON.stringify({ agent, focused: true, project_id }) }]
30868
+ };
30869
+ });
30870
+ server.registerTool("get_focus", {
30871
+ description: "Get the current focus state for an agent. Returns session focus, DB project_id, and effective project_id used for filtering.",
30872
+ inputSchema: {
30873
+ from: exports_external.string().optional()
30874
+ }
30875
+ }, async (args) => {
30876
+ const agent = resolveIdentity(args.from);
30877
+ const sessionFocus = agentFocus.get(agent) ?? null;
30878
+ const presence = getPresence(agent);
30879
+ const effective = getAgentFocus(agent);
30880
+ return {
30881
+ content: [{
30882
+ type: "text",
30883
+ text: JSON.stringify({
30884
+ agent,
30885
+ session_focus: sessionFocus?.project_id ?? null,
30886
+ db_project_id: presence?.project_id ?? null,
30887
+ effective_project_id: effective
30888
+ })
30889
+ }]
30890
+ };
30891
+ });
30892
+ server.registerTool("unfocus", {
30893
+ description: "Clear agent focus. Removes session focus and clears agent_presence.project_id in DB.",
30894
+ inputSchema: {
30895
+ from: exports_external.string().optional()
30896
+ }
30897
+ }, async (args) => {
30898
+ const agent = resolveIdentity(args.from);
30899
+ agentFocus.delete(agent);
30900
+ const db2 = (await Promise.resolve().then(() => (init_db(), exports_db))).getDb();
30901
+ db2.prepare("UPDATE agent_presence SET project_id = NULL WHERE agent = ?").run(agent);
30902
+ return {
30903
+ content: [{ type: "text", text: JSON.stringify({ agent, focused: false, project_id: null }) }]
30904
+ };
30905
+ });
30610
30906
  server.registerTool("register_agent", {
30611
30907
  description: "Register an agent with conflict detection. Returns AgentConflictError if another active session exists (active = heartbeat within last 30 min). Optional project_id locks agent to a project for the session.",
30612
30908
  inputSchema: {
@@ -30753,6 +31049,18 @@ server.registerTool("search_tools", {
30753
31049
  "pin_message",
30754
31050
  "unpin_message",
30755
31051
  "get_pinned_messages",
31052
+ "add_reaction",
31053
+ "remove_reaction",
31054
+ "get_reactions",
31055
+ "get_reaction_summary",
31056
+ "acquire_lock",
31057
+ "release_lock",
31058
+ "check_lock",
31059
+ "list_locks",
31060
+ "get_thread_replies",
31061
+ "set_focus",
31062
+ "get_focus",
31063
+ "unfocus",
30756
31064
  "register_agent",
30757
31065
  "heartbeat",
30758
31066
  "list_agents",
@@ -30799,6 +31107,18 @@ server.registerTool("describe_tools", {
30799
31107
  pin_message: "Pin a message. Required: id",
30800
31108
  unpin_message: "Unpin a message. Required: id",
30801
31109
  get_pinned_messages: "Get pinned messages. Optional: space?, session_id?, limit?",
31110
+ add_reaction: "Add emoji reaction to a message. Required: message_id, emoji. Optional: from?",
31111
+ remove_reaction: "Remove emoji reaction from a message. Required: message_id, emoji. Optional: from?",
31112
+ get_reactions: "Get all reactions for a message. Required: message_id",
31113
+ get_reaction_summary: "Get emoji counts + agent lists for a message. Required: message_id",
31114
+ acquire_lock: "Acquire advisory/exclusive lock on a resource. Required: resource_type, resource_id. Optional: lock_type?(advisory|exclusive), expiry_ms?, from?",
31115
+ release_lock: "Release lock held by agent. Required: resource_type, resource_id. Optional: from?",
31116
+ check_lock: "Check if resource is locked and who holds it. Required: resource_type, resource_id",
31117
+ list_locks: "List active locks. Optional: resource_type?, agent_id?",
31118
+ get_thread_replies: "Get all replies in a thread. Required: message_id. Optional: limit?",
31119
+ set_focus: "Set agent focus to a project. All read tools default to this scope. Required: project_id. Optional: from?",
31120
+ get_focus: "Get current focus: session focus, DB project_id, effective project_id. Optional: from?",
31121
+ unfocus: "Clear agent focus (session + DB). Optional: from?",
30802
31122
  register_agent: "Register agent with conflict detection (30min active window). Required: name, session_id. Optional: role?. Returns AgentConflictError if another session is active.",
30803
31123
  heartbeat: "Register/refresh agent presence. Optional: from?, status?(online|busy|idle, default: online)",
30804
31124
  list_agents: "List agents with presence timestamps. Optional: online_only?(only agents seen in last 60s)",
package/dist/index.d.ts CHANGED
@@ -14,9 +14,10 @@ export { listSessions, getSession, } from "./lib/sessions.js";
14
14
  export { createSpace, updateSpace, archiveSpace, unarchiveSpace, listSpaces, getSpace, joinSpace, leaveSpace, getSpaceMembers, isSpaceMember, getSpaceDepth, } from "./lib/spaces.js";
15
15
  export { createProject, listProjects, getProject, getProjectByName, updateProject, deleteProject, } from "./lib/projects.js";
16
16
  export { getDb, getDbPath, closeDb, } from "./lib/db.js";
17
- export { startPolling, useSpaceMessages, } from "./lib/poll.js";
17
+ export { startPolling, useMessages, useSpaceMessages, } from "./lib/poll.js";
18
18
  export { resolveIdentity, requireIdentity, } from "./lib/identity.js";
19
19
  export { addReaction, removeReaction, getReactions, getReactionSummary, } from "./lib/reactions.js";
20
+ export type { ReactionSummary } from "./lib/reactions.js";
20
21
  export { fireWebhooks, } from "./lib/webhooks.js";
21
22
  export { heartbeat, registerAgent, isAgentConflict, getPresence, listAgents, removePresence, renameAgent, } from "./lib/presence.js";
22
23
  export { acquireLock, releaseLock, checkLock, cleanExpiredLocks, listLocks, } from "./lib/locks.js";
package/dist/index.js CHANGED
@@ -2983,6 +2983,22 @@ function startPolling(opts) {
2983
2983
  }
2984
2984
  };
2985
2985
  }
2986
+ function useMessages(sessionId, agent) {
2987
+ const [messages, setMessages] = import_react.useState([]);
2988
+ import_react.useEffect(() => {
2989
+ const existing = readMessages({ session_id: sessionId });
2990
+ setMessages(existing);
2991
+ const { stop } = startPolling({
2992
+ session_id: sessionId,
2993
+ interval_ms: 200,
2994
+ on_messages: (newMessages) => {
2995
+ setMessages((prev) => [...prev, ...newMessages]);
2996
+ }
2997
+ });
2998
+ return stop;
2999
+ }, [sessionId, agent]);
3000
+ return messages;
3001
+ }
2986
3002
  function useSpaceMessages(spaceName) {
2987
3003
  const [messages, setMessages] = import_react.useState([]);
2988
3004
  import_react.useEffect(() => {
@@ -3656,6 +3672,7 @@ function listLocks(opts) {
3656
3672
  }
3657
3673
  export {
3658
3674
  useSpaceMessages,
3675
+ useMessages,
3659
3676
  updateSpace,
3660
3677
  updateProject,
3661
3678
  unpinMessage,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/conversations",
3
- "version": "0.1.31",
3
+ "version": "0.1.33",
4
4
  "description": "Real-time CLI messaging for AI agents",
5
5
  "type": "module",
6
6
  "bin": {