letagents 0.12.8 → 0.12.10

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.
Files changed (116) hide show
  1. package/README.md +16 -15
  2. package/dist/mcp/agent-prompt-delivery.js +20 -0
  3. package/dist/mcp/codex-session/app-server.js +89 -0
  4. package/dist/mcp/codex-session/index.js +10 -0
  5. package/dist/mcp/codex-session/rpc-client.js +85 -0
  6. package/dist/mcp/codex-session/runtime-bridge.js +254 -0
  7. package/dist/mcp/codex-session/runtime-summary.js +220 -0
  8. package/dist/mcp/codex-session/session-inspection.js +100 -0
  9. package/dist/mcp/codex-session/session-mapper.js +51 -0
  10. package/dist/mcp/codex-session/session-start.js +182 -0
  11. package/dist/mcp/codex-session/session-status.js +77 -0
  12. package/dist/mcp/codex-session/session-stop.js +43 -0
  13. package/dist/mcp/codex-session/session-supervisor.js +111 -0
  14. package/dist/mcp/codex-session/start-prompt.js +49 -0
  15. package/dist/mcp/codex-session/types.js +1 -0
  16. package/dist/mcp/codex-session.js +1 -500
  17. package/dist/mcp/config-reader.js +10 -2
  18. package/dist/mcp/git-remote.js +90 -14
  19. package/dist/mcp/local-state/agent-identity.js +26 -0
  20. package/dist/mcp/local-state/agent-sessions.js +66 -0
  21. package/dist/mcp/local-state/auth.js +57 -0
  22. package/dist/mcp/local-state/codex-live-sessions.js +59 -0
  23. package/dist/mcp/local-state/index.js +8 -0
  24. package/dist/mcp/local-state/local-chat.js +808 -0
  25. package/dist/mcp/local-state/room-sessions.js +51 -0
  26. package/dist/mcp/local-state/storage.js +95 -0
  27. package/dist/mcp/local-state/types.js +1 -0
  28. package/dist/mcp/local-state.js +1 -286
  29. package/dist/mcp/managed-agent-providers.js +65 -0
  30. package/dist/mcp/rental-tools/activity.js +37 -0
  31. package/dist/mcp/rental-tools/budget.js +36 -0
  32. package/dist/mcp/rental-tools/context.js +62 -0
  33. package/dist/mcp/rental-tools/lifecycle.js +47 -0
  34. package/dist/mcp/rental-tools/metering.js +73 -0
  35. package/dist/mcp/rental-tools/patches.js +100 -0
  36. package/dist/mcp/rental-tools/provider.js +117 -0
  37. package/dist/mcp/rental-tools/shared.js +19 -0
  38. package/dist/mcp/rental-tools/types.js +1 -0
  39. package/dist/mcp/rental-tools.js +7 -0
  40. package/dist/mcp/repo-visibility.js +8 -6
  41. package/dist/mcp/server/register-tools.js +19 -0
  42. package/dist/mcp/server/repo-context.js +28 -0
  43. package/dist/mcp/server/resources.js +77 -0
  44. package/dist/mcp/server/runtime/agent-sessions.js +134 -0
  45. package/dist/mcp/server/runtime/api.js +82 -0
  46. package/dist/mcp/server/runtime/auth-cache.js +20 -0
  47. package/dist/mcp/server/runtime/device-auth.js +48 -0
  48. package/dist/mcp/server/runtime/identity/config.js +58 -0
  49. package/dist/mcp/server/runtime/identity/directory.js +87 -0
  50. package/dist/mcp/server/runtime/identity/liveness.js +32 -0
  51. package/dist/mcp/server/runtime/identity/names.js +74 -0
  52. package/dist/mcp/server/runtime/identity/public.js +19 -0
  53. package/dist/mcp/server/runtime/identity/state.js +32 -0
  54. package/dist/mcp/server/runtime/identity.js +96 -0
  55. package/dist/mcp/server/runtime/messages.js +160 -0
  56. package/dist/mcp/server/runtime/presence.js +52 -0
  57. package/dist/mcp/server/runtime/room-api.js +42 -0
  58. package/dist/mcp/server/runtime/room-state.js +122 -0
  59. package/dist/mcp/server/runtime/rooms.js +318 -0
  60. package/dist/mcp/server/runtime.js +14 -0
  61. package/dist/mcp/server/tools/agent-sessions.js +374 -0
  62. package/dist/mcp/server/tools/messages/index.js +14 -0
  63. package/dist/mcp/server/tools/messages/read-tool.js +176 -0
  64. package/dist/mcp/server/tools/messages/reasoning-tool.js +179 -0
  65. package/dist/mcp/server/tools/messages/response.js +17 -0
  66. package/dist/mcp/server/tools/messages/send-tool.js +203 -0
  67. package/dist/mcp/server/tools/messages/status-tool.js +87 -0
  68. package/dist/mcp/server/tools/messages/wait-tool.js +333 -0
  69. package/dist/mcp/server/tools/messages.js +1 -0
  70. package/dist/mcp/server/tools/onboarding/device-auth-tools.js +148 -0
  71. package/dist/mcp/server/tools/onboarding/name-tool.js +89 -0
  72. package/dist/mcp/server/tools/onboarding/responses.js +10 -0
  73. package/dist/mcp/server/tools/onboarding/status-tool.js +108 -0
  74. package/dist/mcp/server/tools/onboarding.js +8 -0
  75. package/dist/mcp/server/tools/rental/activity-tools.js +43 -0
  76. package/dist/mcp/server/tools/rental/context-tools.js +35 -0
  77. package/dist/mcp/server/tools/rental/index.js +17 -0
  78. package/dist/mcp/server/tools/rental/metering-tools.js +39 -0
  79. package/dist/mcp/server/tools/rental/patch-tools.js +53 -0
  80. package/dist/mcp/server/tools/rental/provider-tools.js +44 -0
  81. package/dist/mcp/server/tools/rental/response.js +10 -0
  82. package/dist/mcp/server/tools/rental/types.js +1 -0
  83. package/dist/mcp/server/tools/rental.js +1 -0
  84. package/dist/mcp/server/tools/rooms/index.js +5 -0
  85. package/dist/mcp/server/tools/rooms/inspection-tools.js +124 -0
  86. package/dist/mcp/server/tools/rooms/join-tools.js +37 -0
  87. package/dist/mcp/server/tools/rooms/repo-initialization-tool.js +113 -0
  88. package/dist/mcp/server/tools/rooms/repo-visibility-tool.js +20 -0
  89. package/dist/mcp/server/tools/rooms/response.js +10 -0
  90. package/dist/mcp/server/tools/rooms/resume-tool.js +48 -0
  91. package/dist/mcp/server/tools/rooms.js +1 -0
  92. package/dist/mcp/server/tools/tasks/api.js +222 -0
  93. package/dist/mcp/server/tools/tasks/artifact-tools.js +75 -0
  94. package/dist/mcp/server/tools/tasks/board-intent-tools.js +321 -0
  95. package/dist/mcp/server/tools/tasks/board-tools.js +73 -0
  96. package/dist/mcp/server/tools/tasks/context.js +35 -0
  97. package/dist/mcp/server/tools/tasks/event-tools.js +40 -0
  98. package/dist/mcp/server/tools/tasks/index.js +14 -0
  99. package/dist/mcp/server/tools/tasks/lease-tools.js +145 -0
  100. package/dist/mcp/server/tools/tasks/mutation-tools.js +121 -0
  101. package/dist/mcp/server/tools/tasks/response.js +13 -0
  102. package/dist/mcp/server/tools/tasks/schemas.js +68 -0
  103. package/dist/mcp/server/tools/tasks.js +1 -0
  104. package/dist/mcp/server.js +10 -2540
  105. package/dist/mcp/sse-client.js +22 -16
  106. package/dist/shared/activation-routing.js +263 -0
  107. package/dist/shared/agent-identity.js +21 -5
  108. package/dist/shared/agent-presence.js +24 -0
  109. package/dist/shared/handoff.js +145 -0
  110. package/dist/shared/rental/lrt.js +214 -0
  111. package/dist/shared/rental/meter-types.js +14 -0
  112. package/dist/shared/request-headers.js +3 -0
  113. package/dist/shared/room-agent-activity.js +41 -0
  114. package/dist/shared/room-agent-prompts.js +14 -1
  115. package/package.json +15 -5
  116. /package/dist/{mcp → shared}/codenames.js +0 -0
@@ -0,0 +1,808 @@
1
+ import { createRequire } from "node:module";
2
+ import { mkdir, readFile } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+ const require = createRequire(import.meta.url);
6
+ const chatStorageSettingsPath = process.env.LETAGENTS_CHAT_STORAGE_SETTINGS_PATH?.trim() ||
7
+ join(homedir(), ".letagents", "chat-storage.json");
8
+ const localChatDatabasePath = process.env.LETAGENTS_LOCAL_CHAT_DB?.trim() ||
9
+ join(homedir(), ".letagents", "local-chat.sqlite");
10
+ let db = null;
11
+ const validLocalTaskTransitions = {
12
+ proposed: ["accepted", "cancelled"],
13
+ accepted: ["assigned", "cancelled"],
14
+ assigned: ["in_progress", "in_review", "cancelled"],
15
+ in_progress: ["blocked", "in_review", "done", "cancelled"],
16
+ blocked: ["in_progress", "in_review", "cancelled"],
17
+ in_review: ["merged", "in_progress", "blocked", "done", "cancelled"],
18
+ merged: ["done", "accepted"],
19
+ done: ["accepted"],
20
+ cancelled: ["accepted"],
21
+ };
22
+ function formatMessageId(number) {
23
+ return `msg_${number}`;
24
+ }
25
+ function parseMessageNumber(messageId) {
26
+ if (!messageId)
27
+ return null;
28
+ const match = /^msg_(\d+)$/.exec(messageId.trim());
29
+ if (!match)
30
+ return null;
31
+ const parsed = Number(match[1]);
32
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
33
+ }
34
+ function clampLimit(limit) {
35
+ if (!Number.isFinite(limit))
36
+ return 150;
37
+ return Math.max(1, Math.min(500, Math.floor(Number(limit))));
38
+ }
39
+ function mapRow(row) {
40
+ return {
41
+ room_id: String(row.room_id || ""),
42
+ number: Number(row.number || 0),
43
+ reply_to_number: row.reply_to_number === null || row.reply_to_number === undefined
44
+ ? null
45
+ : Number(row.reply_to_number),
46
+ thread_root_number: row.thread_root_number === null || row.thread_root_number === undefined
47
+ ? null
48
+ : Number(row.thread_root_number),
49
+ sender: String(row.sender || ""),
50
+ text: String(row.text || ""),
51
+ agent_prompt_kind: typeof row.agent_prompt_kind === "string" ? row.agent_prompt_kind : null,
52
+ source: typeof row.source === "string" ? row.source : null,
53
+ timestamp: String(row.timestamp || ""),
54
+ sync_key: typeof row.sync_key === "string" ? row.sync_key : null,
55
+ sync_started_at: typeof row.sync_started_at === "string" ? row.sync_started_at : null,
56
+ };
57
+ }
58
+ function getLocalMessageRow(database, roomId, number) {
59
+ const row = database
60
+ .prepare("SELECT * FROM local_chat_messages WHERE room_id = ? AND number = ?")
61
+ .get(roomId, number);
62
+ return row ? mapRow(row) : null;
63
+ }
64
+ function mapAttachmentRow(row) {
65
+ return {
66
+ attachment_id: String(row.attachment_id || ""),
67
+ file_name: typeof row.file_name === "string" ? row.file_name : null,
68
+ mime_type: typeof row.mime_type === "string" ? row.mime_type : null,
69
+ size_bytes: row.size_bytes === null || row.size_bytes === undefined
70
+ ? null
71
+ : Number(row.size_bytes),
72
+ url: typeof row.url === "string" ? row.url : null,
73
+ download_url: typeof row.download_url === "string" ? row.download_url : null,
74
+ data_url: typeof row.data_url === "string" ? row.data_url : null,
75
+ content_base64: typeof row.content_base64 === "string" ? row.content_base64 : null,
76
+ };
77
+ }
78
+ function visibleMessageClause(includePromptOnly) {
79
+ return includePromptOnly
80
+ ? "1 = 1"
81
+ : "NOT (agent_prompt_kind = 'auto' AND TRIM(text) = '')";
82
+ }
83
+ function toMessage(row, replyTo, attachments = []) {
84
+ return {
85
+ id: formatMessageId(row.number),
86
+ sender: row.sender,
87
+ text: row.text,
88
+ agent_prompt_kind: row.agent_prompt_kind,
89
+ source: row.source,
90
+ timestamp: row.timestamp,
91
+ attachments: attachments.map((attachment) => ({
92
+ id: attachment.attachment_id,
93
+ file_name: attachment.file_name,
94
+ mime_type: attachment.mime_type,
95
+ size_bytes: attachment.size_bytes,
96
+ url: attachment.url,
97
+ download_url: attachment.download_url,
98
+ data_url: attachment.data_url,
99
+ content_base64: attachment.content_base64,
100
+ })),
101
+ thread_root_id: formatMessageId(row.thread_root_number ?? row.number),
102
+ thread_reply_to_id: row.reply_to_number ? formatMessageId(row.reply_to_number) : null,
103
+ reply_to: replyTo
104
+ ? {
105
+ id: formatMessageId(replyTo.number),
106
+ sender: replyTo.sender,
107
+ text: replyTo.text,
108
+ source: replyTo.source,
109
+ timestamp: replyTo.timestamp,
110
+ }
111
+ : null,
112
+ };
113
+ }
114
+ async function getDb() {
115
+ if (db)
116
+ return db;
117
+ await mkdir(dirname(localChatDatabasePath), { recursive: true });
118
+ const { DatabaseSync } = require("node:sqlite");
119
+ db = new DatabaseSync(localChatDatabasePath);
120
+ db.exec("PRAGMA journal_mode = WAL");
121
+ db.exec("PRAGMA foreign_keys = ON");
122
+ db.exec("PRAGMA busy_timeout = 5000");
123
+ db.exec(`
124
+ CREATE TABLE IF NOT EXISTS local_chat_room_sequences (
125
+ room_id TEXT PRIMARY KEY,
126
+ next_number INTEGER NOT NULL
127
+ );
128
+ CREATE TABLE IF NOT EXISTS local_chat_messages (
129
+ room_id TEXT NOT NULL,
130
+ number INTEGER NOT NULL,
131
+ reply_to_number INTEGER,
132
+ thread_root_number INTEGER,
133
+ sender TEXT NOT NULL,
134
+ text TEXT NOT NULL,
135
+ agent_prompt_kind TEXT,
136
+ source TEXT,
137
+ timestamp TEXT NOT NULL,
138
+ synced_cloud_id TEXT,
139
+ synced_at TEXT,
140
+ sync_key TEXT,
141
+ sync_started_at TEXT,
142
+ PRIMARY KEY (room_id, number)
143
+ );
144
+ CREATE INDEX IF NOT EXISTS local_chat_messages_room_time_idx
145
+ ON local_chat_messages (room_id, timestamp);
146
+ CREATE INDEX IF NOT EXISTS local_chat_messages_sync_idx
147
+ ON local_chat_messages (room_id, synced_cloud_id);
148
+ CREATE TABLE IF NOT EXISTS local_chat_attachments (
149
+ room_id TEXT NOT NULL,
150
+ message_number INTEGER NOT NULL,
151
+ attachment_id TEXT NOT NULL,
152
+ file_name TEXT,
153
+ mime_type TEXT,
154
+ size_bytes INTEGER,
155
+ url TEXT,
156
+ download_url TEXT,
157
+ data_url TEXT,
158
+ content_base64 TEXT,
159
+ created_at TEXT NOT NULL,
160
+ PRIMARY KEY (room_id, message_number, attachment_id)
161
+ );
162
+ CREATE INDEX IF NOT EXISTS local_chat_attachments_message_idx
163
+ ON local_chat_attachments (room_id, message_number);
164
+ CREATE TABLE IF NOT EXISTS local_rooms (
165
+ room_id TEXT PRIMARY KEY,
166
+ display_name TEXT NOT NULL,
167
+ cloud_room_id TEXT,
168
+ created_at TEXT NOT NULL,
169
+ updated_at TEXT NOT NULL,
170
+ published_at TEXT,
171
+ archived_at TEXT,
172
+ pinned_at TEXT
173
+ );
174
+ CREATE TABLE IF NOT EXISTS local_task_room_sequences (
175
+ room_id TEXT PRIMARY KEY,
176
+ next_number INTEGER NOT NULL
177
+ );
178
+ CREATE TABLE IF NOT EXISTS local_tasks (
179
+ room_id TEXT NOT NULL,
180
+ task_id TEXT NOT NULL,
181
+ title TEXT NOT NULL,
182
+ description TEXT,
183
+ status TEXT NOT NULL,
184
+ assignee TEXT,
185
+ assignee_agent_key TEXT,
186
+ assignee_agent_instance_id TEXT,
187
+ assignee_agent_session_id TEXT,
188
+ created_by TEXT,
189
+ pr_url TEXT,
190
+ workflow_artifacts_json TEXT,
191
+ workflow_refs_json TEXT,
192
+ synced_cloud_id TEXT,
193
+ sync_key TEXT,
194
+ sync_started_at TEXT,
195
+ sync_dirty INTEGER NOT NULL DEFAULT 0,
196
+ review_lease_id TEXT,
197
+ review_holder_label TEXT,
198
+ review_agent_key TEXT,
199
+ review_agent_session_id TEXT,
200
+ review_updated_at TEXT,
201
+ created_at TEXT NOT NULL,
202
+ updated_at TEXT NOT NULL,
203
+ PRIMARY KEY (room_id, task_id)
204
+ );
205
+ `);
206
+ addColumnIfMissing(db, "local_chat_messages", "sync_key", "TEXT");
207
+ addColumnIfMissing(db, "local_chat_messages", "sync_started_at", "TEXT");
208
+ addColumnIfMissing(db, "local_chat_messages", "thread_root_number", "INTEGER");
209
+ addColumnIfMissing(db, "local_rooms", "pinned_at", "TEXT");
210
+ addColumnIfMissing(db, "local_tasks", "assignee_agent_key", "TEXT");
211
+ addColumnIfMissing(db, "local_tasks", "assignee_agent_instance_id", "TEXT");
212
+ addColumnIfMissing(db, "local_tasks", "assignee_agent_session_id", "TEXT");
213
+ addColumnIfMissing(db, "local_tasks", "workflow_artifacts_json", "TEXT");
214
+ addColumnIfMissing(db, "local_tasks", "workflow_refs_json", "TEXT");
215
+ addColumnIfMissing(db, "local_tasks", "sync_started_at", "TEXT");
216
+ addColumnIfMissing(db, "local_tasks", "sync_dirty", "INTEGER NOT NULL DEFAULT 0");
217
+ addColumnIfMissing(db, "local_tasks", "review_lease_id", "TEXT");
218
+ addColumnIfMissing(db, "local_tasks", "review_holder_label", "TEXT");
219
+ addColumnIfMissing(db, "local_tasks", "review_agent_key", "TEXT");
220
+ addColumnIfMissing(db, "local_tasks", "review_agent_session_id", "TEXT");
221
+ addColumnIfMissing(db, "local_tasks", "review_updated_at", "TEXT");
222
+ db.exec(`
223
+ CREATE UNIQUE INDEX IF NOT EXISTS local_chat_messages_sync_key_idx
224
+ ON local_chat_messages (room_id, sync_key)
225
+ WHERE sync_key IS NOT NULL;
226
+ CREATE INDEX IF NOT EXISTS local_chat_messages_sync_started_idx
227
+ ON local_chat_messages (room_id, sync_started_at);
228
+ CREATE INDEX IF NOT EXISTS local_chat_messages_thread_root_idx
229
+ ON local_chat_messages (room_id, thread_root_number);
230
+ `);
231
+ return db;
232
+ }
233
+ function addColumnIfMissing(database, tableName, columnName, definition) {
234
+ try {
235
+ database.exec(`ALTER TABLE ${tableName} ADD COLUMN ${columnName} ${definition}`);
236
+ }
237
+ catch (error) {
238
+ if (!String(error).toLowerCase().includes("duplicate column")) {
239
+ throw error;
240
+ }
241
+ }
242
+ }
243
+ function resolveLocalTaskStatus(fromStatus, toStatus) {
244
+ if (typeof toStatus !== "string" || !toStatus.trim())
245
+ return fromStatus;
246
+ const nextStatus = toStatus.trim();
247
+ if (!validLocalTaskTransitions[fromStatus]?.includes(nextStatus)) {
248
+ throw new Error(`Invalid transition: ${fromStatus} -> ${nextStatus}. ` +
249
+ `Allowed: ${validLocalTaskTransitions[fromStatus]?.join(", ") || "none"}`);
250
+ }
251
+ return nextStatus;
252
+ }
253
+ async function hydrateRows(database, rows) {
254
+ if (rows.length === 0)
255
+ return [];
256
+ const roomId = rows[0]?.room_id || "";
257
+ const replies = new Map();
258
+ const attachmentsByMessageNumber = new Map();
259
+ const replyNumbers = [
260
+ ...new Set(rows
261
+ .map((row) => row.reply_to_number)
262
+ .filter((value) => value !== null && Number.isInteger(value) && value > 0)),
263
+ ];
264
+ for (const number of replyNumbers) {
265
+ const reply = getLocalMessageRow(database, roomId, number);
266
+ if (reply) {
267
+ replies.set(reply.number, reply);
268
+ }
269
+ }
270
+ for (const number of rows.map((row) => row.number)) {
271
+ const attachments = database
272
+ .prepare(`
273
+ SELECT *
274
+ FROM local_chat_attachments
275
+ WHERE room_id = ? AND message_number = ?
276
+ ORDER BY created_at ASC
277
+ `)
278
+ .all(roomId, number)
279
+ .map(mapAttachmentRow);
280
+ attachmentsByMessageNumber.set(number, attachments);
281
+ }
282
+ return rows.map((row) => toMessage(row, row.reply_to_number ? replies.get(row.reply_to_number) ?? null : null, attachmentsByMessageNumber.get(row.number) || []));
283
+ }
284
+ function beginImmediate(database) {
285
+ database.exec("BEGIN IMMEDIATE");
286
+ }
287
+ function rollback(database) {
288
+ try {
289
+ database.exec("ROLLBACK");
290
+ }
291
+ catch {
292
+ // The transaction may already be closed by SQLite after an error.
293
+ }
294
+ }
295
+ function allocateLocalMessageNumber(database, roomId) {
296
+ database
297
+ .prepare(`
298
+ INSERT INTO local_chat_room_sequences (room_id, next_number)
299
+ SELECT ?, COALESCE(MAX(number), 0) + 1
300
+ FROM local_chat_messages
301
+ WHERE room_id = ?
302
+ ON CONFLICT(room_id) DO NOTHING
303
+ `)
304
+ .run(roomId, roomId);
305
+ const row = database
306
+ .prepare("SELECT next_number FROM local_chat_room_sequences WHERE room_id = ?")
307
+ .get(roomId);
308
+ const number = Number(row?.next_number || 0);
309
+ if (!Number.isInteger(number) || number <= 0) {
310
+ throw new Error("Local chat message sequence could not be allocated.");
311
+ }
312
+ database
313
+ .prepare("UPDATE local_chat_room_sequences SET next_number = next_number + 1 WHERE room_id = ?")
314
+ .run(roomId);
315
+ return number;
316
+ }
317
+ export async function isLocalChatStorageEnabled() {
318
+ const envMode = process.env.LETAGENTS_CHAT_STORAGE?.trim().toLowerCase();
319
+ if (envMode === "local")
320
+ return true;
321
+ if (envMode === "cloud")
322
+ return false;
323
+ try {
324
+ const raw = await readFile(chatStorageSettingsPath, "utf8");
325
+ const parsed = JSON.parse(raw);
326
+ return parsed.mode === "local";
327
+ }
328
+ catch {
329
+ return false;
330
+ }
331
+ }
332
+ export async function isLocalRoomStorageEnabled(roomId) {
333
+ const envMode = process.env.LETAGENTS_CHAT_STORAGE?.trim().toLowerCase();
334
+ if (envMode === "local")
335
+ return true;
336
+ if (envMode === "cloud")
337
+ return false;
338
+ const normalizedRoomId = roomId?.trim() || "";
339
+ let localRoom;
340
+ if (normalizedRoomId) {
341
+ try {
342
+ const database = await getDb();
343
+ localRoom = database
344
+ .prepare("SELECT room_id, cloud_room_id FROM local_rooms WHERE (room_id = ? OR cloud_room_id = ?) AND archived_at IS NULL LIMIT 1")
345
+ .get(normalizedRoomId, normalizedRoomId);
346
+ }
347
+ catch {
348
+ localRoom = undefined;
349
+ }
350
+ }
351
+ try {
352
+ const raw = await readFile(chatStorageSettingsPath, "utf8");
353
+ const parsed = JSON.parse(raw);
354
+ const overrideKeys = [
355
+ normalizedRoomId,
356
+ typeof localRoom?.cloud_room_id === "string" ? localRoom.cloud_room_id : "",
357
+ typeof localRoom?.room_id === "string" ? localRoom.room_id : "",
358
+ ].filter((value, index, values) => Boolean(value && values.indexOf(value) === index));
359
+ const override = parsed.roomOverrides
360
+ ? overrideKeys
361
+ .map((key) => parsed.roomOverrides?.[key])
362
+ .find((value) => value === "local" || value === "cloud")
363
+ : null;
364
+ if (override === "local")
365
+ return true;
366
+ if (override === "cloud") {
367
+ const localOnlyRoom = Boolean(localRoom && !localRoom.cloud_room_id);
368
+ return localOnlyRoom;
369
+ }
370
+ const requestedLocalRoom = Boolean(normalizedRoomId &&
371
+ typeof localRoom?.room_id === "string" &&
372
+ localRoom.room_id === normalizedRoomId);
373
+ const localOnlyRoom = Boolean(localRoom && !localRoom.cloud_room_id);
374
+ if (localOnlyRoom || requestedLocalRoom)
375
+ return true;
376
+ return (parsed.defaultMode ?? parsed.mode) === "local";
377
+ }
378
+ catch {
379
+ const requestedLocalRoom = Boolean(normalizedRoomId &&
380
+ typeof localRoom?.room_id === "string" &&
381
+ localRoom.room_id === normalizedRoomId);
382
+ const localOnlyRoom = Boolean(localRoom && !localRoom.cloud_room_id);
383
+ return localOnlyRoom || requestedLocalRoom;
384
+ }
385
+ }
386
+ export async function resolveLocalRoomStorageIdentifiers(roomId) {
387
+ const normalizedRoomId = roomId?.trim() || "";
388
+ if (!normalizedRoomId)
389
+ return { localRoomId: null, cloudRoomId: null };
390
+ try {
391
+ const database = await getDb();
392
+ const row = database
393
+ .prepare(`
394
+ SELECT room_id, cloud_room_id
395
+ FROM local_rooms
396
+ WHERE (room_id = ? OR cloud_room_id = ?) AND archived_at IS NULL
397
+ ORDER BY updated_at DESC
398
+ LIMIT 1
399
+ `)
400
+ .get(normalizedRoomId, normalizedRoomId);
401
+ return {
402
+ localRoomId: typeof row?.room_id === "string" && row.room_id.trim()
403
+ ? row.room_id
404
+ : normalizedRoomId,
405
+ cloudRoomId: typeof row?.cloud_room_id === "string" && row.cloud_room_id.trim()
406
+ ? row.cloud_room_id
407
+ : normalizedRoomId,
408
+ };
409
+ }
410
+ catch {
411
+ return { localRoomId: normalizedRoomId, cloudRoomId: normalizedRoomId };
412
+ }
413
+ }
414
+ export async function addLocalChatMessage(roomId, input) {
415
+ const trimmedRoomId = roomId.trim();
416
+ const sender = input.sender.trim();
417
+ if (!trimmedRoomId)
418
+ throw new Error("No room is available for this request.");
419
+ if (!sender)
420
+ throw new Error("Message sender is required.");
421
+ const database = await getDb();
422
+ const replyToNumber = parseMessageNumber(input.reply_to);
423
+ const explicitThreadRootNumber = parseMessageNumber(input.thread_root_id);
424
+ let replyTarget = null;
425
+ if (input.reply_to && !replyToNumber) {
426
+ throw new Error("reply_to must be a valid local message id.");
427
+ }
428
+ if (input.thread_root_id && !explicitThreadRootNumber) {
429
+ throw new Error("thread_root_id must be a valid local message id.");
430
+ }
431
+ let replyTargetRootNumber = null;
432
+ if (replyToNumber) {
433
+ replyTarget = getLocalMessageRow(database, trimmedRoomId, replyToNumber);
434
+ if (!replyTarget) {
435
+ throw new Error("reply_to must reference an existing local message in this room.");
436
+ }
437
+ replyTargetRootNumber = replyTarget.thread_root_number ?? replyTarget.number;
438
+ }
439
+ let threadRootNumber = explicitThreadRootNumber;
440
+ if (explicitThreadRootNumber) {
441
+ const rootTarget = getLocalMessageRow(database, trimmedRoomId, explicitThreadRootNumber);
442
+ if (!rootTarget) {
443
+ throw new Error("thread_root_id must reference an existing local message in this room.");
444
+ }
445
+ threadRootNumber = rootTarget.thread_root_number ?? rootTarget.number;
446
+ if (replyTargetRootNumber && replyTargetRootNumber !== threadRootNumber) {
447
+ throw new Error("reply_to must belong to the requested local thread.");
448
+ }
449
+ }
450
+ const timestamp = new Date().toISOString();
451
+ let row;
452
+ beginImmediate(database);
453
+ try {
454
+ const number = allocateLocalMessageNumber(database, trimmedRoomId);
455
+ row = {
456
+ room_id: trimmedRoomId,
457
+ number,
458
+ reply_to_number: replyToNumber,
459
+ thread_root_number: threadRootNumber,
460
+ sender,
461
+ text: input.text,
462
+ agent_prompt_kind: input.agent_prompt_kind || null,
463
+ source: input.source || null,
464
+ timestamp,
465
+ sync_key: null,
466
+ sync_started_at: null,
467
+ };
468
+ database
469
+ .prepare(`
470
+ INSERT INTO local_chat_messages (
471
+ room_id, number, reply_to_number, thread_root_number, sender, text, agent_prompt_kind, source,
472
+ timestamp, synced_cloud_id, synced_at, sync_key, sync_started_at
473
+ )
474
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, NULL, NULL)
475
+ `)
476
+ .run(row.room_id, row.number, row.reply_to_number, row.thread_root_number, row.sender, row.text, row.agent_prompt_kind, row.source, row.timestamp);
477
+ database.exec("COMMIT");
478
+ }
479
+ catch (error) {
480
+ rollback(database);
481
+ throw error;
482
+ }
483
+ return {
484
+ room_id: trimmedRoomId,
485
+ ...toMessage(row, replyTarget),
486
+ };
487
+ }
488
+ export async function getLocalChatMessages(roomId, options) {
489
+ const limit = clampLimit(options?.limit);
490
+ const afterNumber = parseMessageNumber(options?.after);
491
+ const database = await getDb();
492
+ const rows = database
493
+ .prepare(`
494
+ SELECT * FROM local_chat_messages
495
+ WHERE room_id = ?
496
+ AND ${afterNumber ? "number > ?" : "1 = 1"}
497
+ AND ${visibleMessageClause(options?.include_prompt_only)}
498
+ ORDER BY number ASC
499
+ LIMIT ?
500
+ `)
501
+ .all(...(afterNumber ? [roomId, afterNumber, limit + 1] : [roomId, limit + 1]))
502
+ .map(mapRow);
503
+ const hasMore = rows.length > limit;
504
+ const bounded = hasMore ? rows.slice(0, limit) : rows;
505
+ return {
506
+ room_id: roomId,
507
+ messages: await hydrateRows(database, bounded),
508
+ has_more: hasMore,
509
+ };
510
+ }
511
+ export async function getLatestLocalChatMessages(roomId, options) {
512
+ const limit = clampLimit(options?.limit);
513
+ const database = await getDb();
514
+ const rows = database
515
+ .prepare(`
516
+ SELECT * FROM local_chat_messages
517
+ WHERE room_id = ?
518
+ AND ${visibleMessageClause(options?.include_prompt_only)}
519
+ ORDER BY number DESC
520
+ LIMIT ?
521
+ `)
522
+ .all(roomId, limit + 1)
523
+ .map(mapRow);
524
+ const hasMore = rows.length > limit;
525
+ const bounded = (hasMore ? rows.slice(0, limit) : rows).reverse();
526
+ return {
527
+ room_id: roomId,
528
+ messages: await hydrateRows(database, bounded),
529
+ has_more: hasMore,
530
+ };
531
+ }
532
+ export async function waitForLocalChatMessages(roomId, options) {
533
+ const deadline = Date.now() + Math.max(0, options.timeoutMs);
534
+ for (;;) {
535
+ const page = await getLocalChatMessages(roomId, {
536
+ after: options.after,
537
+ limit: options.limit,
538
+ include_prompt_only: options.include_prompt_only,
539
+ });
540
+ if (page.messages.length > 0 || Date.now() >= deadline) {
541
+ return page;
542
+ }
543
+ await new Promise((resolve) => setTimeout(resolve, Math.min(500, deadline - Date.now())));
544
+ }
545
+ }
546
+ function allocateLocalTaskId(database, roomId) {
547
+ database
548
+ .prepare(`
549
+ INSERT INTO local_task_room_sequences (room_id, next_number)
550
+ SELECT ?, COALESCE(MAX(CAST(SUBSTR(task_id, 6) AS INTEGER)), 0) + 1
551
+ FROM local_tasks
552
+ WHERE room_id = ? AND task_id GLOB 'task_[0-9]*'
553
+ ON CONFLICT(room_id) DO NOTHING
554
+ `)
555
+ .run(roomId, roomId);
556
+ const row = database
557
+ .prepare("SELECT next_number FROM local_task_room_sequences WHERE room_id = ?")
558
+ .get(roomId);
559
+ const number = Number(row?.next_number || 0);
560
+ if (!Number.isInteger(number) || number <= 0) {
561
+ throw new Error("Local task sequence could not be allocated.");
562
+ }
563
+ database
564
+ .prepare("UPDATE local_task_room_sequences SET next_number = next_number + 1 WHERE room_id = ?")
565
+ .run(roomId);
566
+ return `task_${number}`;
567
+ }
568
+ function mapTaskRow(row) {
569
+ const reviewLeaseId = typeof row.review_lease_id === "string" && row.review_lease_id.trim()
570
+ ? row.review_lease_id
571
+ : null;
572
+ return {
573
+ id: String(row.task_id || ""),
574
+ title: String(row.title || ""),
575
+ description: typeof row.description === "string" ? row.description : null,
576
+ status: String(row.status || "proposed"),
577
+ assignee: typeof row.assignee === "string" ? row.assignee : null,
578
+ assignee_agent_key: typeof row.assignee_agent_key === "string" ? row.assignee_agent_key : null,
579
+ assignee_agent_instance_id: typeof row.assignee_agent_instance_id === "string" ? row.assignee_agent_instance_id : null,
580
+ assignee_agent_session_id: typeof row.assignee_agent_session_id === "string" ? row.assignee_agent_session_id : null,
581
+ created_by: typeof row.created_by === "string" ? row.created_by : null,
582
+ pr_url: typeof row.pr_url === "string" ? row.pr_url : null,
583
+ workflow_artifacts: parseJsonArray(row.workflow_artifacts_json, []),
584
+ workflow_refs: parseJsonArray(row.workflow_refs_json, []),
585
+ active_leases: reviewLeaseId
586
+ ? [
587
+ {
588
+ id: reviewLeaseId,
589
+ kind: "review",
590
+ holder_label: typeof row.review_holder_label === "string"
591
+ ? row.review_holder_label
592
+ : null,
593
+ agent_key: typeof row.review_agent_key === "string"
594
+ ? row.review_agent_key
595
+ : null,
596
+ agent_session_id: typeof row.review_agent_session_id === "string"
597
+ ? row.review_agent_session_id
598
+ : null,
599
+ status: "active",
600
+ updated_at: typeof row.review_updated_at === "string"
601
+ ? row.review_updated_at
602
+ : null,
603
+ },
604
+ ]
605
+ : [],
606
+ created_at: String(row.created_at || ""),
607
+ updated_at: String(row.updated_at || ""),
608
+ };
609
+ }
610
+ function parseJsonArray(value, fallback) {
611
+ if (typeof value !== "string" || !value.trim())
612
+ return fallback;
613
+ try {
614
+ const parsed = JSON.parse(value);
615
+ return Array.isArray(parsed) ? parsed : fallback;
616
+ }
617
+ catch {
618
+ return fallback;
619
+ }
620
+ }
621
+ export async function addLocalTask(roomId, input) {
622
+ const trimmedRoomId = roomId.trim();
623
+ const title = input.title.trim();
624
+ if (!trimmedRoomId)
625
+ throw new Error("No room is available for this request.");
626
+ if (!title)
627
+ throw new Error("Task title is required.");
628
+ const database = await getDb();
629
+ const now = new Date().toISOString();
630
+ let taskId = "";
631
+ beginImmediate(database);
632
+ try {
633
+ taskId = allocateLocalTaskId(database, trimmedRoomId);
634
+ database
635
+ .prepare(`
636
+ INSERT INTO local_tasks (
637
+ room_id, task_id, title, description, status, assignee, assignee_agent_key,
638
+ assignee_agent_instance_id, assignee_agent_session_id,
639
+ created_by, pr_url, workflow_artifacts_json, workflow_refs_json,
640
+ synced_cloud_id, sync_key, sync_started_at, sync_dirty, created_at, updated_at
641
+ )
642
+ VALUES (?, ?, ?, ?, 'proposed', NULL, NULL, NULL, NULL, ?, NULL, NULL, NULL, NULL, ?, NULL, 1, ?, ?)
643
+ `)
644
+ .run(trimmedRoomId, taskId, title, input.description?.trim() || null, input.created_by || "agent", `local-task:${trimmedRoomId}:${taskId}`, now, now);
645
+ database.exec("COMMIT");
646
+ }
647
+ catch (error) {
648
+ rollback(database);
649
+ throw error;
650
+ }
651
+ const task = await getLocalTask(trimmedRoomId, taskId);
652
+ if (!task)
653
+ throw new Error("Local task could not be created.");
654
+ return task;
655
+ }
656
+ export async function listLocalTasks(roomId, options = {}) {
657
+ const clauses = ["room_id = ?"];
658
+ const params = [roomId];
659
+ if (options.status) {
660
+ clauses.push("status = ?");
661
+ params.push(options.status);
662
+ }
663
+ if (options.openOnly !== false) {
664
+ clauses.push("status NOT IN ('done', 'cancelled')");
665
+ }
666
+ const database = await getDb();
667
+ const tasks = database
668
+ .prepare(`
669
+ SELECT *
670
+ FROM local_tasks
671
+ WHERE ${clauses.join(" AND ")}
672
+ ORDER BY created_at ASC
673
+ `)
674
+ .all(...params)
675
+ .map(mapTaskRow);
676
+ return { tasks, has_more: false };
677
+ }
678
+ export async function getLocalTask(roomId, taskId) {
679
+ const database = await getDb();
680
+ const row = database
681
+ .prepare("SELECT * FROM local_tasks WHERE room_id = ? AND task_id = ?")
682
+ .get(roomId, taskId);
683
+ return row ? mapTaskRow(row) : null;
684
+ }
685
+ export async function updateLocalTask(roomId, taskId, patch) {
686
+ const current = await getLocalTask(roomId, taskId);
687
+ if (!current)
688
+ throw new Error("Task not found.");
689
+ const nextStatus = patch.skip_transition_validation === true
690
+ ? typeof patch.status === "string" && patch.status.trim()
691
+ ? patch.status.trim()
692
+ : current.status
693
+ : resolveLocalTaskStatus(current.status, patch.status);
694
+ const assigneeAgentKey = patch.assignee_agent_key === undefined
695
+ ? current.assignee_agent_key
696
+ : typeof patch.assignee_agent_key === "string"
697
+ ? patch.assignee_agent_key
698
+ : null;
699
+ const assigneeAgentInstanceId = patch.assignee_agent_key === undefined
700
+ ? current.assignee_agent_instance_id
701
+ : assigneeAgentKey && typeof patch.assignee_agent_instance_id === "string"
702
+ ? patch.assignee_agent_instance_id
703
+ : assigneeAgentKey && typeof patch.actor_instance_id === "string"
704
+ ? patch.actor_instance_id
705
+ : null;
706
+ const assigneeAgentSessionId = patch.assignee_agent_key === undefined
707
+ ? current.assignee_agent_session_id
708
+ : assigneeAgentKey && typeof patch.assignee_agent_session_id === "string"
709
+ ? patch.assignee_agent_session_id
710
+ : assigneeAgentKey && typeof patch.agent_session_id === "string"
711
+ ? patch.agent_session_id
712
+ : null;
713
+ const workflowArtifacts = patch.workflow_artifacts === undefined
714
+ ? JSON.stringify(current.workflow_artifacts)
715
+ : JSON.stringify(Array.isArray(patch.workflow_artifacts) ? patch.workflow_artifacts : []);
716
+ const now = new Date().toISOString();
717
+ const database = await getDb();
718
+ database
719
+ .prepare(`
720
+ UPDATE local_tasks
721
+ SET status = ?,
722
+ assignee = ?,
723
+ assignee_agent_key = ?,
724
+ assignee_agent_instance_id = ?,
725
+ assignee_agent_session_id = ?,
726
+ pr_url = ?,
727
+ workflow_artifacts_json = ?,
728
+ sync_dirty = 1,
729
+ updated_at = ?
730
+ WHERE room_id = ? AND task_id = ?
731
+ `)
732
+ .run(nextStatus, patch.assignee === undefined ? current.assignee : patch.assignee || null, assigneeAgentKey, assigneeAgentInstanceId, assigneeAgentSessionId, patch.pr_url === undefined ? current.pr_url : patch.pr_url || null, workflowArtifacts, now, roomId, taskId);
733
+ const updated = await getLocalTask(roomId, taskId);
734
+ if (!updated)
735
+ throw new Error("Task not found.");
736
+ return updated;
737
+ }
738
+ export async function claimLocalTaskReviewLease(roomId, taskId, input) {
739
+ const current = await getLocalTask(roomId, taskId);
740
+ if (!current)
741
+ throw new Error("Task not found.");
742
+ const actorKey = input.agent_key?.trim() || null;
743
+ if (actorKey &&
744
+ current.assignee_agent_key &&
745
+ current.assignee_agent_key === actorKey) {
746
+ throw new Error("A worker holding the task cannot also claim review authority.");
747
+ }
748
+ const currentReviewLease = current.active_leases?.find((lease) => lease.kind === "review");
749
+ if (currentReviewLease?.agent_key &&
750
+ actorKey &&
751
+ currentReviewLease.agent_key !== actorKey) {
752
+ throw new Error("Review authority is already held by another local reviewer.");
753
+ }
754
+ const database = await getDb();
755
+ const now = new Date().toISOString();
756
+ const leaseId = currentReviewLease?.id || `local_review_${Date.now()}_${Math.random().toString(36).slice(2)}`;
757
+ database
758
+ .prepare(`
759
+ UPDATE local_tasks
760
+ SET review_lease_id = ?,
761
+ review_holder_label = ?,
762
+ review_agent_key = ?,
763
+ review_agent_session_id = ?,
764
+ review_updated_at = ?,
765
+ updated_at = ?
766
+ WHERE room_id = ? AND task_id = ?
767
+ `)
768
+ .run(leaseId, input.holder_label?.trim() || actorKey || "Local reviewer", actorKey, input.agent_session_id?.trim() || null, now, now, roomId, taskId);
769
+ const task = await getLocalTask(roomId, taskId);
770
+ const lease = task?.active_leases?.find((entry) => entry.id === leaseId);
771
+ if (!task || !lease)
772
+ throw new Error("Review authority could not be claimed.");
773
+ return { task, lease };
774
+ }
775
+ export async function releaseLocalTaskReviewLease(roomId, taskId, input = {}) {
776
+ const current = await getLocalTask(roomId, taskId);
777
+ if (!current)
778
+ throw new Error("Task not found.");
779
+ const currentReviewLease = current.active_leases?.find((lease) => lease.kind === "review") || null;
780
+ if (input.lease_id &&
781
+ currentReviewLease &&
782
+ input.lease_id !== currentReviewLease.id) {
783
+ throw new Error("Review lease id did not match the active local review authority.");
784
+ }
785
+ const database = await getDb();
786
+ const now = new Date().toISOString();
787
+ database
788
+ .prepare(`
789
+ UPDATE local_tasks
790
+ SET review_lease_id = NULL,
791
+ review_holder_label = NULL,
792
+ review_agent_key = NULL,
793
+ review_agent_session_id = NULL,
794
+ review_updated_at = NULL,
795
+ updated_at = ?
796
+ WHERE room_id = ? AND task_id = ?
797
+ `)
798
+ .run(now, roomId, taskId);
799
+ const task = await getLocalTask(roomId, taskId);
800
+ if (!task)
801
+ throw new Error("Task not found.");
802
+ return {
803
+ task,
804
+ released_lease: currentReviewLease
805
+ ? { ...currentReviewLease, status: "released" }
806
+ : null,
807
+ };
808
+ }