@hasna/conversations 0.1.32 → 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.32",
3672
+ version: "0.1.33",
3626
3673
  description: "Real-time CLI messaging for AI agents",
3627
3674
  type: "module",
3628
3675
  bin: {
@@ -32566,6 +32613,84 @@ 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, {
@@ -32599,6 +32724,8 @@ var init_mcp2 = __esm(() => {
32599
32724
  init_projects();
32600
32725
  init_identity();
32601
32726
  init_presence();
32727
+ init_reactions();
32728
+ init_locks();
32602
32729
  import__package = __toESM(require_package(), 1);
32603
32730
  server = new McpServer({
32604
32731
  name: "conversations",
@@ -33239,6 +33366,107 @@ var init_mcp2 = __esm(() => {
33239
33366
  content: [{ type: "text", text: JSON.stringify(messages) }]
33240
33367
  };
33241
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
+ });
33242
33470
  server.registerTool("set_focus", {
33243
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.",
33244
33472
  inputSchema: {
@@ -33437,6 +33665,15 @@ var init_mcp2 = __esm(() => {
33437
33665
  "pin_message",
33438
33666
  "unpin_message",
33439
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",
33440
33677
  "set_focus",
33441
33678
  "get_focus",
33442
33679
  "unfocus",
@@ -33486,6 +33723,15 @@ var init_mcp2 = __esm(() => {
33486
33723
  pin_message: "Pin a message. Required: id",
33487
33724
  unpin_message: "Unpin a message. Required: id",
33488
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?",
33489
33735
  set_focus: "Set agent focus to a project. All read tools default to this scope. Required: project_id. Optional: from?",
33490
33736
  get_focus: "Get current focus: session focus, DB project_id, effective project_id. Optional: from?",
33491
33737
  unfocus: "Clear agent focus (session + DB). Optional: from?",
@@ -33944,6 +34190,23 @@ function startDashboardServer(port = 0, host) {
33944
34190
  const agents = listAgents({ online_only: onlineOnly });
33945
34191
  return jsonResponse(applyFields(agents, url2.searchParams.get("fields")));
33946
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
+ }
33947
34210
  if (path === "/api/version" && req.method === "GET") {
33948
34211
  try {
33949
34212
  const pkg2 = await Promise.resolve().then(() => __toESM(require_package(), 1));
@@ -34024,6 +34287,8 @@ var init_serve = __esm(() => {
34024
34287
  init_projects();
34025
34288
  init_db();
34026
34289
  init_presence();
34290
+ init_reactions();
34291
+ init_locks();
34027
34292
  isDirectRun2 = import.meta.url === `file://${process.argv[1]}` || process.argv[1]?.endsWith("serve.ts") || process.argv[1]?.endsWith("serve.js");
34028
34293
  if (isDirectRun2) {
34029
34294
  const port = normalizePort(process.env.PORT, 0);
@@ -34055,6 +34320,7 @@ init_projects();
34055
34320
  init_db();
34056
34321
  init_identity();
34057
34322
  init_presence();
34323
+ init_reactions();
34058
34324
  init_terminal_markdown();
34059
34325
  import chalk3 from "chalk";
34060
34326
  import { render } from "ink";
@@ -35120,6 +35386,87 @@ program2.command("search").description("Search messages by content").argument("<
35120
35386
  }
35121
35387
  closeDb();
35122
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
+ });
35123
35470
  program2.command("sessions").description("List conversation sessions").option("--agent <id>", "Filter sessions involving this agent").option("--json", "Output as JSON").action((opts) => {
35124
35471
  const sessions = listSessions(opts.agent);
35125
35472
  if (opts.json) {
@@ -35234,6 +35581,79 @@ program2.command("status").description("Show database stats").option("--json", "
35234
35581
  }
35235
35582
  closeDb();
35236
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
+ });
35237
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) => {
35238
35658
  const pkg3 = await Promise.resolve().then(() => __toESM(require_package(), 1));
35239
35659
  const current = pkg3.version;
@@ -35760,6 +36180,64 @@ program2.command("unpin").description("Unpin a message").argument("<id>", "Messa
35760
36180
  }
35761
36181
  closeDb();
35762
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
+ });
35763
36241
  var agents = program2.command("agents").description("Manage agents");
35764
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) => {
35765
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;
@@ -29898,10 +29903,125 @@ function renameAgent(oldName, newName) {
29898
29903
  db2.prepare("UPDATE agent_presence SET agent = ? WHERE LOWER(agent) = ?").run(normalizedNew, normalizedOld);
29899
29904
  return true;
29900
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
+ }
29901
30021
  // package.json
29902
30022
  var package_default = {
29903
30023
  name: "@hasna/conversations",
29904
- version: "0.1.32",
30024
+ version: "0.1.33",
29905
30025
  description: "Real-time CLI messaging for AI agents",
29906
30026
  type: "module",
29907
30027
  bin: {
@@ -30630,6 +30750,107 @@ server.registerTool("get_pinned_messages", {
30630
30750
  content: [{ type: "text", text: JSON.stringify(messages) }]
30631
30751
  };
30632
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
+ });
30633
30854
  server.registerTool("set_focus", {
30634
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.",
30635
30856
  inputSchema: {
@@ -30828,6 +31049,15 @@ server.registerTool("search_tools", {
30828
31049
  "pin_message",
30829
31050
  "unpin_message",
30830
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",
30831
31061
  "set_focus",
30832
31062
  "get_focus",
30833
31063
  "unfocus",
@@ -30877,6 +31107,15 @@ server.registerTool("describe_tools", {
30877
31107
  pin_message: "Pin a message. Required: id",
30878
31108
  unpin_message: "Unpin a message. Required: id",
30879
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?",
30880
31119
  set_focus: "Set agent focus to a project. All read tools default to this scope. Required: project_id. Optional: from?",
30881
31120
  get_focus: "Get current focus: session focus, DB project_id, effective project_id. Optional: from?",
30882
31121
  unfocus: "Clear agent focus (session + DB). Optional: from?",
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.32",
3
+ "version": "0.1.33",
4
4
  "description": "Real-time CLI messaging for AI agents",
5
5
  "type": "module",
6
6
  "bin": {