assistme 0.1.3 → 0.1.5

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.
@@ -1,5 +1,6 @@
1
1
  // src/db/supabase.ts
2
2
  import { createClient } from "@supabase/supabase-js";
3
+ import { createHash } from "crypto";
3
4
 
4
5
  // src/utils/config.ts
5
6
  import Conf from "conf";
@@ -137,22 +138,6 @@ function writeAuthStore(data) {
137
138
  ensureAuthDir();
138
139
  writeFileSync(AUTH_FILE, JSON.stringify(data, null, 2), { mode: 384 });
139
140
  }
140
- var fileStorage = {
141
- getItem(key) {
142
- const store = readAuthStore();
143
- return store[key] ?? null;
144
- },
145
- setItem(key, value) {
146
- const store = readAuthStore();
147
- store[key] = value;
148
- writeAuthStore(store);
149
- },
150
- removeItem(key) {
151
- const store = readAuthStore();
152
- delete store[key];
153
- writeAuthStore(store);
154
- }
155
- };
156
141
  var supabase = null;
157
142
  function getSupabase() {
158
143
  if (!supabase) {
@@ -163,230 +148,171 @@ function getSupabase() {
163
148
  );
164
149
  }
165
150
  supabase = createClient(config2.supabaseUrl, config2.supabaseAnonKey, {
166
- auth: {
167
- storage: fileStorage,
168
- autoRefreshToken: true,
169
- persistSession: true
170
- }
151
+ auth: { persistSession: false }
171
152
  });
172
153
  }
173
154
  return supabase;
174
155
  }
175
- async function exchangeCliToken(cliToken) {
176
- const config2 = getConfig();
177
- const exchangeUrl = `${config2.supabaseUrl}/functions/v1/cli-token-exchange`;
178
- const res = await fetch(exchangeUrl, {
179
- method: "POST",
180
- headers: {
181
- "Content-Type": "application/json",
182
- apikey: config2.supabaseAnonKey
183
- },
184
- body: JSON.stringify({ token: cliToken })
185
- });
186
- if (!res.ok) {
187
- const body = await res.json().catch(() => ({ error: res.statusText }));
188
- throw new Error(body.error || `Token exchange failed (${res.status})`);
189
- }
190
- const data = await res.json();
191
- if (!data.access_token || !data.refresh_token) {
192
- throw new Error("Token exchange returned incomplete session");
156
+ function hashToken(token) {
157
+ return createHash("sha256").update(token).digest("hex");
158
+ }
159
+ function getTokenHash() {
160
+ const store = readAuthStore();
161
+ const token = store["mcp_token"];
162
+ if (!token || !token.startsWith("am_")) {
163
+ throw new Error("Not authenticated. Run `assistme login`.");
193
164
  }
194
- return data;
165
+ return hashToken(token);
195
166
  }
196
- async function loginWithToken(cliToken) {
197
- if (!cliToken.startsWith("am_")) {
167
+ async function loginWithToken(mcpToken) {
168
+ if (!mcpToken.startsWith("am_")) {
198
169
  throw new Error(
199
170
  "Invalid token format. Use an am_ token from the web page."
200
171
  );
201
172
  }
202
- const sessionTokens = await exchangeCliToken(cliToken);
203
- const store = readAuthStore();
204
- store["cli_token"] = cliToken;
205
- writeAuthStore(store);
173
+ const hash = hashToken(mcpToken);
206
174
  const sb = getSupabase();
207
- const { data, error } = await sb.auth.setSession({
208
- access_token: sessionTokens.access_token,
209
- refresh_token: sessionTokens.refresh_token
175
+ const { data, error } = await sb.rpc("validate_mcp_token", {
176
+ p_token_hash: hash
210
177
  });
211
- if (error) throw new Error(`Login failed: ${error.message}`);
212
- if (!data.user) throw new Error("Login failed: no user returned");
213
- return data.user.id;
214
- }
215
- async function refreshWithCliToken() {
178
+ if (error) throw new Error(`Token validation failed: ${error.message}`);
179
+ if (!data || data.length === 0) throw new Error("Invalid or expired token");
216
180
  const store = readAuthStore();
217
- const cliToken = store["cli_token"];
218
- if (!cliToken || !cliToken.startsWith("am_")) return null;
219
- try {
220
- const sessionTokens = await exchangeCliToken(cliToken);
221
- const sb = getSupabase();
222
- const { data, error } = await sb.auth.setSession({
223
- access_token: sessionTokens.access_token,
224
- refresh_token: sessionTokens.refresh_token
225
- });
226
- if (error || !data.user) return null;
227
- return data.user.id;
228
- } catch {
229
- return null;
230
- }
231
- }
232
- async function getSession() {
233
- const sb = getSupabase();
234
- const { data } = await sb.auth.getSession();
235
- return data.session;
181
+ store["mcp_token"] = mcpToken;
182
+ writeAuthStore(store);
183
+ return data[0].out_user_id;
236
184
  }
237
185
  async function getCurrentUserId() {
186
+ const hash = getTokenHash();
238
187
  const sb = getSupabase();
239
- const { data, error } = await sb.auth.getUser();
240
- if (error || !data.user) {
241
- const refreshed = await refreshWithCliToken();
242
- if (refreshed) return refreshed;
243
- throw new Error("Not authenticated. Run `assistme login`.");
188
+ const { data, error } = await sb.rpc("validate_mcp_token", {
189
+ p_token_hash: hash
190
+ });
191
+ if (error || !data || data.length === 0) {
192
+ throw new Error("Token expired or revoked. Run `assistme login`.");
244
193
  }
245
- return data.user.id;
194
+ return data[0].out_user_id;
246
195
  }
247
196
  async function logout() {
248
- const sb = getSupabase();
249
- await sb.auth.signOut();
250
197
  try {
251
198
  writeAuthStore({});
252
199
  } catch {
253
200
  }
254
201
  }
255
- async function createSession(userId, sessionName, workspacePath, version) {
202
+ async function createSession(_userId, sessionName, workspacePath, version) {
256
203
  const sb = getSupabase();
257
- const { data, error } = await sb.from("agent_sessions").insert({
258
- user_id: userId,
259
- session_name: sessionName,
260
- status: "online",
261
- workspace_path: workspacePath,
262
- agent_version: version,
263
- metadata: { model: getConfig().model }
264
- }).select().single();
204
+ const { data, error } = await sb.rpc("mcp_create_session", {
205
+ p_token_hash: getTokenHash(),
206
+ p_session_name: sessionName,
207
+ p_workspace_path: workspacePath,
208
+ p_version: version,
209
+ p_model: getConfig().model || null
210
+ });
265
211
  if (error) throw new Error(`Failed to create session: ${error.message}`);
266
212
  return data;
267
213
  }
268
214
  async function updateHeartbeat(sessionId) {
269
215
  const sb = getSupabase();
270
- const { error } = await sb.from("agent_sessions").update({ last_heartbeat_at: (/* @__PURE__ */ new Date()).toISOString() }).eq("id", sessionId);
216
+ const { error } = await sb.rpc("mcp_heartbeat", {
217
+ p_token_hash: getTokenHash(),
218
+ p_session_id: sessionId
219
+ });
271
220
  if (error) log.warn(`Heartbeat update failed: ${error.message}`);
272
221
  }
273
222
  async function endSession(sessionId) {
274
223
  const sb = getSupabase();
275
- const { error } = await sb.from("agent_sessions").update({
276
- status: "offline",
277
- ended_at: (/* @__PURE__ */ new Date()).toISOString()
278
- }).eq("id", sessionId);
224
+ const { error } = await sb.rpc("mcp_end_session", {
225
+ p_token_hash: getTokenHash(),
226
+ p_session_id: sessionId
227
+ });
279
228
  if (error) log.error(`Failed to end session: ${error.message}`);
280
229
  }
281
230
  async function setSessionBusy(sessionId, busy) {
282
231
  const sb = getSupabase();
283
- await sb.from("agent_sessions").update({ status: busy ? "busy" : "online" }).eq("id", sessionId);
232
+ await sb.rpc("mcp_set_session_busy", {
233
+ p_token_hash: getTokenHash(),
234
+ p_session_id: sessionId,
235
+ p_busy: busy
236
+ });
284
237
  }
285
- async function getOrCreateCliConversation(userId, sessionId) {
238
+ async function getOrCreateCliConversation(_userId, _sessionId) {
286
239
  const sb = getSupabase();
287
- const { data: existing } = await sb.from("conversations").select("id").eq("agent", "cli").eq("created_by", userId).order("created_at", { ascending: false }).limit(1);
288
- if (existing && existing.length > 0) {
289
- return existing[0].id;
290
- }
291
- const { data: conv, error: convErr } = await sb.from("conversations").insert({
292
- conversation_type: "direct",
293
- agent: "cli",
294
- created_by: userId
295
- }).select("id").single();
296
- if (convErr || !conv) {
297
- throw new Error(`Failed to create conversation: ${convErr?.message}`);
298
- }
299
- await sb.from("conversation_participants").insert([
300
- { conversation_id: conv.id, user_id: userId, role: "member" },
301
- { conversation_id: conv.id, user_id: CLI_AGENT_ID, role: "member" }
302
- ]);
303
- return conv.id;
240
+ const { data, error } = await sb.rpc("mcp_get_or_create_conversation", {
241
+ p_token_hash: getTokenHash()
242
+ });
243
+ if (error) throw new Error(`Failed to get conversation: ${error.message}`);
244
+ return data;
304
245
  }
305
- async function createTask(conversationId, userId, sessionId, prompt) {
246
+ async function createTask(conversationId, _userId, sessionId, prompt) {
306
247
  const sb = getSupabase();
307
- const { data: rpcResult, error: rpcError } = await sb.rpc(
308
- "create_task_pair",
309
- {
310
- p_conversation_id: conversationId,
311
- p_user_id: userId,
312
- p_agent_id: CLI_AGENT_ID,
313
- p_session_id: sessionId,
314
- p_prompt: prompt
315
- }
316
- );
317
- if (!rpcError && rpcResult) {
318
- return { ...rpcResult, prompt };
319
- }
320
- if (rpcError) {
321
- log.debug(`create_task_pair RPC unavailable, using fallback: ${rpcError.message}`);
322
- }
323
- const { error: userErr } = await sb.from("conversation_messages").insert({
324
- conversation_id: conversationId,
325
- sender_id: userId,
326
- role: "user",
327
- content: prompt
248
+ const { data, error } = await sb.rpc("mcp_create_task", {
249
+ p_token_hash: getTokenHash(),
250
+ p_conversation_id: conversationId,
251
+ p_session_id: sessionId,
252
+ p_prompt: prompt
328
253
  });
329
- if (userErr) throw new Error(`Failed to create user message: ${userErr.message}`);
330
- const { data, error } = await sb.from("conversation_messages").insert({
331
- conversation_id: conversationId,
332
- sender_id: CLI_AGENT_ID,
333
- role: "assistant",
334
- content: "",
335
- status: "pending",
336
- session_id: sessionId,
337
- metadata: { prompt }
338
- }).select().single();
339
254
  if (error) throw new Error(`Failed to create task: ${error.message}`);
340
255
  return { ...data, prompt };
341
256
  }
342
257
  async function pollPendingTasks(sessionId) {
343
258
  const sb = getSupabase();
344
- const { data, error } = await sb.from("conversation_messages").select("*").eq("session_id", sessionId).eq("status", "pending").order("created_at", { ascending: true }).limit(1);
259
+ const { data, error } = await sb.rpc("mcp_poll_tasks", {
260
+ p_token_hash: getTokenHash(),
261
+ p_session_id: sessionId
262
+ });
345
263
  if (error) {
346
264
  log.warn(`Task poll failed: ${error.message}`);
347
265
  return [];
348
266
  }
349
- return (data || []).map((row) => ({
267
+ const rows = data || [];
268
+ return rows.map((row) => ({
350
269
  ...row,
351
270
  prompt: row.metadata?.prompt || row.content || ""
352
271
  }));
353
272
  }
273
+ async function pollAndClaimTask(sessionId) {
274
+ const sb = getSupabase();
275
+ const { data, error } = await sb.rpc("mcp_poll_and_claim_task", {
276
+ p_token_hash: getTokenHash(),
277
+ p_session_id: sessionId
278
+ });
279
+ if (error) {
280
+ log.warn(`Poll-and-claim failed: ${error.message}`);
281
+ return null;
282
+ }
283
+ if (!data) return null;
284
+ const row = data;
285
+ return {
286
+ ...row,
287
+ prompt: row.metadata?.prompt || row.content || ""
288
+ };
289
+ }
354
290
  async function claimTask(messageId) {
355
291
  const sb = getSupabase();
356
- const { error } = await sb.from("conversation_messages").update({
357
- status: "running",
358
- metadata: { started_at: (/* @__PURE__ */ new Date()).toISOString() }
359
- }).eq("id", messageId).eq("status", "pending");
292
+ const { data, error } = await sb.rpc("mcp_claim_task", {
293
+ p_token_hash: getTokenHash(),
294
+ p_message_id: messageId
295
+ });
360
296
  if (error) throw new Error(`Failed to claim task: ${error.message}`);
297
+ return data;
361
298
  }
362
299
  async function completeTask(messageId, resultSummary, tokenUsage) {
363
300
  const sb = getSupabase();
364
- const { data: current } = await sb.from("conversation_messages").select("metadata").eq("id", messageId).single();
365
- const existingMeta = current?.metadata || {};
366
- const { error } = await sb.from("conversation_messages").update({
367
- content: resultSummary,
368
- status: "completed",
369
- metadata: {
370
- ...existingMeta,
371
- completed_at: (/* @__PURE__ */ new Date()).toISOString(),
372
- token_usage: tokenUsage || null
373
- }
374
- }).eq("id", messageId);
301
+ const { error } = await sb.rpc("mcp_complete_task", {
302
+ p_token_hash: getTokenHash(),
303
+ p_message_id: messageId,
304
+ p_result: resultSummary,
305
+ p_token_usage: tokenUsage || null
306
+ });
375
307
  if (error) throw new Error(`Failed to complete task: ${error.message}`);
376
308
  }
377
309
  async function failTask(messageId, errorMessage) {
378
310
  const sb = getSupabase();
379
- const { data: current } = await sb.from("conversation_messages").select("metadata").eq("id", messageId).single();
380
- const existingMeta = current?.metadata || {};
381
- const { error } = await sb.from("conversation_messages").update({
382
- status: "failed",
383
- content: errorMessage,
384
- metadata: {
385
- ...existingMeta,
386
- completed_at: (/* @__PURE__ */ new Date()).toISOString(),
387
- error_message: errorMessage
388
- }
389
- }).eq("id", messageId);
311
+ const { error } = await sb.rpc("mcp_fail_task", {
312
+ p_token_hash: getTokenHash(),
313
+ p_message_id: messageId,
314
+ p_error: errorMessage
315
+ });
390
316
  if (error) log.error(`Failed to update task status: ${error.message}`);
391
317
  }
392
318
  var eventSequence = 0;
@@ -396,26 +322,26 @@ function resetEventSequence() {
396
322
  async function emitEvent(messageId, eventType, eventData) {
397
323
  const sb = getSupabase();
398
324
  eventSequence++;
399
- const { error } = await sb.from("message_events").insert({
400
- message_id: messageId,
401
- event_type: eventType,
402
- event_data: eventData,
403
- sequence_number: eventSequence
325
+ const { error } = await sb.rpc("mcp_emit_event", {
326
+ p_token_hash: getTokenHash(),
327
+ p_message_id: messageId,
328
+ p_event_type: eventType,
329
+ p_event_data: eventData,
330
+ p_seq: eventSequence
404
331
  });
405
332
  if (error) log.warn(`Failed to emit event: ${error.message}`);
406
333
  }
407
334
  async function emitEvents(messageId, events) {
408
335
  const sb = getSupabase();
409
- const rows = events.map((e) => {
336
+ const eventsJson = events.map((e) => {
410
337
  eventSequence++;
411
- return {
412
- message_id: messageId,
413
- event_type: e.type,
414
- event_data: e.data,
415
- sequence_number: eventSequence
416
- };
338
+ return { type: e.type, data: e.data, seq: eventSequence };
339
+ });
340
+ const { error } = await sb.rpc("mcp_emit_events", {
341
+ p_token_hash: getTokenHash(),
342
+ p_message_id: messageId,
343
+ p_events: eventsJson
417
344
  });
418
- const { error } = await sb.from("message_events").insert(rows);
419
345
  if (error) log.warn(`Failed to emit events batch: ${error.message}`);
420
346
  }
421
347
 
@@ -432,8 +358,6 @@ export {
432
358
  DAYBOX_AGENT_ID,
433
359
  getSupabase,
434
360
  loginWithToken,
435
- refreshWithCliToken,
436
- getSession,
437
361
  getCurrentUserId,
438
362
  logout,
439
363
  createSession,
@@ -443,6 +367,7 @@ export {
443
367
  getOrCreateCliConversation,
444
368
  createTask,
445
369
  pollPendingTasks,
370
+ pollAndClaimTask,
446
371
  claimTask,
447
372
  completeTask,
448
373
  failTask,
package/dist/index.js CHANGED
@@ -1,6 +1,5 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
- claimTask,
4
3
  clearConfig,
5
4
  completeTask,
6
5
  createSession,
@@ -17,14 +16,14 @@ import {
17
16
  loginWithToken,
18
17
  logout,
19
18
  newCorrelationId,
20
- pollPendingTasks,
19
+ pollAndClaimTask,
21
20
  resetEventSequence,
22
21
  setConfig,
23
22
  setCorrelationId,
24
23
  setLogLevel,
25
24
  setSessionBusy,
26
25
  updateHeartbeat
27
- } from "./chunk-H5BZPIOY.js";
26
+ } from "./chunk-QXT7DH44.js";
28
27
 
29
28
  // src/index.ts
30
29
  import { Command } from "commander";
@@ -277,12 +276,11 @@ var SessionManager = class {
277
276
  return;
278
277
  }
279
278
  try {
280
- const tasks = await pollPendingTasks(this.session.id);
279
+ const task = await pollAndClaimTask(this.session.id);
281
280
  this.consecutivePollFailures = 0;
282
- if (tasks.length > 0) {
281
+ if (task) {
283
282
  this.processing = true;
284
- const task = tasks[0];
285
- log.info(`New task received: ${task.id}`);
283
+ log.info(`New task claimed: ${task.id}`);
286
284
  log.agent(`Task from conversation: ${task.conversation_id}`);
287
285
  await setSessionBusy(this.session.id, true);
288
286
  try {
@@ -2449,11 +2447,6 @@ var TaskProcessor = class {
2449
2447
  const toolCallRecords = [];
2450
2448
  const usedSkillNames = [];
2451
2449
  try {
2452
- await withRetry(() => claimTask(task.id), {
2453
- maxRetries: 2,
2454
- baseDelayMs: 300,
2455
- label: "claimTask"
2456
- });
2457
2450
  await emitEvent(task.id, "status_change", { status: "running" });
2458
2451
  let systemPrompt = BASE_SYSTEM_PROMPT.replace(
2459
2452
  "{workspace_path}",
@@ -3087,7 +3080,7 @@ program.command("start", { isDefault: true }).description("Start the agent and l
3087
3080
  }
3088
3081
  log.agent(`Processing: "${input}"`);
3089
3082
  try {
3090
- const { createTask: createTask2 } = await import("./supabase-DTKGPEER.js");
3083
+ const { createTask: createTask2 } = await import("./supabase-QU7MFNDI.js");
3091
3084
  const session = sessionManager.getSession();
3092
3085
  const conversationId = sessionManager.getConversationId();
3093
3086
  if (session && conversationId) {
@@ -3116,7 +3109,7 @@ program.command("start", { isDefault: true }).description("Start the agent and l
3116
3109
  program.command("status").description("Check the status of the current agent session").action(async () => {
3117
3110
  try {
3118
3111
  const userId = await getCurrentUserId();
3119
- const { getSupabase: getSupabase2 } = await import("./supabase-DTKGPEER.js");
3112
+ const { getSupabase: getSupabase2 } = await import("./supabase-QU7MFNDI.js");
3120
3113
  const sb = getSupabase2();
3121
3114
  const { data: sessions } = await sb.from("agent_sessions").select("*").eq("user_id", userId).in("status", ["online", "busy"]).order("started_at", { ascending: false }).limit(5);
3122
3115
  if (!sessions || sessions.length === 0) {
@@ -11,16 +11,15 @@ import {
11
11
  failTask,
12
12
  getCurrentUserId,
13
13
  getOrCreateCliConversation,
14
- getSession,
15
14
  getSupabase,
16
15
  loginWithToken,
17
16
  logout,
17
+ pollAndClaimTask,
18
18
  pollPendingTasks,
19
- refreshWithCliToken,
20
19
  resetEventSequence,
21
20
  setSessionBusy,
22
21
  updateHeartbeat
23
- } from "./chunk-H5BZPIOY.js";
22
+ } from "./chunk-QXT7DH44.js";
24
23
  export {
25
24
  CLI_AGENT_ID,
26
25
  DAYBOX_AGENT_ID,
@@ -34,12 +33,11 @@ export {
34
33
  failTask,
35
34
  getCurrentUserId,
36
35
  getOrCreateCliConversation,
37
- getSession,
38
36
  getSupabase,
39
37
  loginWithToken,
40
38
  logout,
39
+ pollAndClaimTask,
41
40
  pollPendingTasks,
42
- refreshWithCliToken,
43
41
  resetEventSequence,
44
42
  setSessionBusy,
45
43
  updateHeartbeat
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "assistme",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "AssistMe CLI Agent - AI-powered assistant that controls your real browser",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -19,7 +19,6 @@ vi.mock("@anthropic-ai/claude-agent-sdk", () => ({
19
19
 
20
20
  // Mock Supabase DB functions
21
21
  const mockDbFns = {
22
- claimTask: vi.fn().mockResolvedValue(undefined),
23
22
  completeTask: vi.fn().mockResolvedValue(undefined),
24
23
  failTask: vi.fn().mockResolvedValue(undefined),
25
24
  emitEvent: vi.fn().mockResolvedValue(undefined),
@@ -130,7 +129,7 @@ describe("TaskProcessor", () => {
130
129
 
131
130
  await processor.processTask(makeTask("say hello"));
132
131
 
133
- expect(mockDbFns.claimTask).toHaveBeenCalledWith("task-001");
132
+ // Task is already claimed by pollAndClaimTask in session.ts, not processor
134
133
  expect(mockDbFns.completeTask).toHaveBeenCalledWith(
135
134
  "task-001",
136
135
  expect.stringContaining("Hello!")
@@ -196,7 +195,7 @@ describe("TaskProcessor", () => {
196
195
  });
197
196
 
198
197
  it("handles task failure gracefully", async () => {
199
- mockDbFns.claimTask.mockRejectedValueOnce(new Error("DB error"));
198
+ mockDbFns.emitEvent.mockRejectedValueOnce(new Error("DB error"));
200
199
 
201
200
  await processor.processTask(makeTask("will fail"));
202
201
 
@@ -5,7 +5,6 @@ import {
5
5
  } from "@anthropic-ai/claude-agent-sdk";
6
6
  import {
7
7
  AgentTask,
8
- claimTask,
9
8
  completeTask,
10
9
  failTask,
11
10
  emitEvent,
@@ -108,12 +107,7 @@ export class TaskProcessor {
108
107
  const usedSkillNames: string[] = [];
109
108
 
110
109
  try {
111
- // Claim the task (with retry for transient DB failures)
112
- await withRetry(() => claimTask(task.id), {
113
- maxRetries: 2,
114
- baseDelayMs: 300,
115
- label: "claimTask",
116
- });
110
+ // Task is already claimed atomically by pollAndClaimTask in session.ts
117
111
  await emitEvent(task.id, "status_change", { status: "running" });
118
112
 
119
113
  // Build system prompt with memories + skills
@@ -30,7 +30,7 @@ const mockCreateSession = vi.fn().mockResolvedValue(mockSession);
30
30
  const mockUpdateHeartbeat = vi.fn().mockResolvedValue(undefined);
31
31
  const mockEndSession = vi.fn().mockResolvedValue(undefined);
32
32
  const mockSetSessionBusy = vi.fn().mockResolvedValue(undefined);
33
- const mockPollPendingTasks = vi.fn().mockResolvedValue([]);
33
+ const mockPollAndClaimTask = vi.fn().mockResolvedValue(null);
34
34
  const mockGetOrCreateCliConversation = vi.fn().mockResolvedValue("conv-001");
35
35
 
36
36
  vi.mock("../db/supabase.js", () => ({
@@ -38,7 +38,7 @@ vi.mock("../db/supabase.js", () => ({
38
38
  updateHeartbeat: (...args: any[]) => mockUpdateHeartbeat(...args),
39
39
  endSession: (...args: any[]) => mockEndSession(...args),
40
40
  setSessionBusy: (...args: any[]) => mockSetSessionBusy(...args),
41
- pollPendingTasks: (...args: any[]) => mockPollPendingTasks(...args),
41
+ pollAndClaimTask: (...args: any[]) => mockPollAndClaimTask(...args),
42
42
  createTask: vi.fn().mockResolvedValue({ id: "task-001", prompt: "test" }),
43
43
  getOrCreateCliConversation: (...args: any[]) => mockGetOrCreateCliConversation(...args),
44
44
  getSupabase: vi.fn().mockReturnValue({ from: vi.fn().mockReturnValue(chain) }),
@@ -79,7 +79,7 @@ describe("SessionManager", () => {
79
79
  mockUpdateHeartbeat.mockResolvedValue(undefined);
80
80
  mockEndSession.mockResolvedValue(undefined);
81
81
  mockSetSessionBusy.mockResolvedValue(undefined);
82
- mockPollPendingTasks.mockResolvedValue([]);
82
+ mockPollAndClaimTask.mockResolvedValue(null);
83
83
  vi.useFakeTimers({ shouldAdvanceTime: true });
84
84
  manager = new SessionManager();
85
85
  });
@@ -117,12 +117,12 @@ describe("SessionManager", () => {
117
117
 
118
118
  await vi.advanceTimersByTimeAsync(3_000);
119
119
 
120
- expect(mockPollPendingTasks).toHaveBeenCalledWith("sess-001");
120
+ expect(mockPollAndClaimTask).toHaveBeenCalledWith("sess-001");
121
121
  });
122
122
 
123
- it("processes tasks when polled", async () => {
123
+ it("processes tasks when polled and claimed atomically", async () => {
124
124
  const task = { id: "task-001", conversation_id: "conv-001", prompt: "hello" };
125
- mockPollPendingTasks.mockResolvedValueOnce([task]);
125
+ mockPollAndClaimTask.mockResolvedValueOnce(task);
126
126
 
127
127
  await manager.start("user-123", onTask);
128
128
  await vi.advanceTimersByTimeAsync(3_000);
@@ -133,24 +133,24 @@ describe("SessionManager", () => {
133
133
  });
134
134
 
135
135
  it("applies exponential backoff on poll failures", async () => {
136
- mockPollPendingTasks
136
+ mockPollAndClaimTask
137
137
  .mockRejectedValueOnce(new Error("Network error"))
138
138
  .mockRejectedValueOnce(new Error("Network error"))
139
- .mockResolvedValue([]);
139
+ .mockResolvedValue(null);
140
140
 
141
141
  await manager.start("user-123", onTask);
142
142
 
143
143
  // First poll at ~2s base interval
144
144
  await vi.advanceTimersByTimeAsync(3_000);
145
- expect(mockPollPendingTasks).toHaveBeenCalledTimes(1);
145
+ expect(mockPollAndClaimTask).toHaveBeenCalledTimes(1);
146
146
 
147
147
  // After first failure: backoff = 2s * 2^1 = 4s → fires at ~6s total
148
148
  await vi.advanceTimersByTimeAsync(4_000);
149
- expect(mockPollPendingTasks).toHaveBeenCalledTimes(2);
149
+ expect(mockPollAndClaimTask).toHaveBeenCalledTimes(2);
150
150
 
151
151
  // After second failure: backoff = 2s * 2^2 = 8s → fires at ~14s total
152
152
  await vi.advanceTimersByTimeAsync(8_500);
153
- expect(mockPollPendingTasks).toHaveBeenCalledTimes(3);
153
+ expect(mockPollAndClaimTask).toHaveBeenCalledTimes(3);
154
154
  // Third call succeeds, so backoff resets. Next poll at 2s would be at ~16s
155
155
  // We don't advance that far, so only 3 calls total.
156
156
  });
@@ -3,7 +3,7 @@ import {
3
3
  updateHeartbeat,
4
4
  endSession,
5
5
  setSessionBusy,
6
- pollPendingTasks,
6
+ pollAndClaimTask,
7
7
  createTask,
8
8
  getOrCreateCliConversation,
9
9
  getSupabase,
@@ -145,14 +145,15 @@ export class SessionManager {
145
145
  }
146
146
 
147
147
  try {
148
- const tasks = await pollPendingTasks(this.session.id);
148
+ // Atomic poll + claim: if a pending task exists, it's claimed in one DB call.
149
+ // FOR UPDATE SKIP LOCKED ensures no two CLIs grab the same task.
150
+ const task = await pollAndClaimTask(this.session.id);
149
151
  // Reset backoff on success
150
152
  this.consecutivePollFailures = 0;
151
153
 
152
- if (tasks.length > 0) {
154
+ if (task) {
153
155
  this.processing = true;
154
- const task = tasks[0];
155
- log.info(`New task received: ${task.id}`);
156
+ log.info(`New task claimed: ${task.id}`);
156
157
  log.agent(`Task from conversation: ${task.conversation_id}`);
157
158
 
158
159
  await setSessionBusy(this.session.id, true);
@@ -1,4 +1,5 @@
1
1
  import { createClient, SupabaseClient } from "@supabase/supabase-js";
2
+ import { createHash } from "crypto";
2
3
  import { getConfig } from "../utils/config.js";
3
4
  import { log } from "../utils/logger.js";
4
5
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
@@ -10,8 +11,7 @@ import { homedir } from "os";
10
11
  export const CLI_AGENT_ID = "00000000-0000-0000-0000-000000000001";
11
12
  export const DAYBOX_AGENT_ID = "00000000-0000-0000-0000-000000000002";
12
13
 
13
- // ── File-Based Storage Adapter ─────────────────────────────────────
14
- // Node.js has no localStorage, so we persist Supabase auth tokens to disk.
14
+ // ── Auth Store (persists am_ token to disk) ──────────────────────────
15
15
 
16
16
  const AUTH_DIR = join(homedir(), ".config", "assistme");
17
17
  const AUTH_FILE = join(AUTH_DIR, "auth.json");
@@ -38,28 +38,7 @@ function writeAuthStore(data: Record<string, string>) {
38
38
  writeFileSync(AUTH_FILE, JSON.stringify(data, null, 2), { mode: 0o600 });
39
39
  }
40
40
 
41
- /**
42
- * Custom storage adapter for Supabase client.
43
- * Persists auth sessions to ~/.config/assistme/auth.json
44
- */
45
- const fileStorage = {
46
- getItem(key: string): string | null {
47
- const store = readAuthStore();
48
- return store[key] ?? null;
49
- },
50
- setItem(key: string, value: string): void {
51
- const store = readAuthStore();
52
- store[key] = value;
53
- writeAuthStore(store);
54
- },
55
- removeItem(key: string): void {
56
- const store = readAuthStore();
57
- delete store[key];
58
- writeAuthStore(store);
59
- },
60
- };
61
-
62
- // ── Supabase Client ────────────────────────────────────────────────
41
+ // ── Supabase Client (anon key only, no auth session) ────────────────
63
42
 
64
43
  let supabase: SupabaseClient | null = null;
65
44
 
@@ -72,122 +51,72 @@ export function getSupabase(): SupabaseClient {
72
51
  );
73
52
  }
74
53
  supabase = createClient(config.supabaseUrl, config.supabaseAnonKey, {
75
- auth: {
76
- storage: fileStorage,
77
- autoRefreshToken: true,
78
- persistSession: true,
79
- },
54
+ auth: { persistSession: false },
80
55
  });
81
56
  }
82
57
  return supabase;
83
58
  }
84
59
 
85
- // ── Auth: Token-Based Login ────────────────────────────────────────
86
-
87
- /**
88
- * Exchange an am_ CLI token for a Supabase session via the Edge Function.
89
- */
90
- async function exchangeCliToken(
91
- cliToken: string
92
- ): Promise<{ access_token: string; refresh_token: string }> {
93
- const config = getConfig();
94
- const exchangeUrl = `${config.supabaseUrl}/functions/v1/cli-token-exchange`;
95
-
96
- const res = await fetch(exchangeUrl, {
97
- method: "POST",
98
- headers: {
99
- "Content-Type": "application/json",
100
- apikey: config.supabaseAnonKey,
101
- },
102
- body: JSON.stringify({ token: cliToken }),
103
- });
60
+ // ── Token Hash ──────────────────────────────────────────────────────
104
61
 
105
- if (!res.ok) {
106
- const body = await res.json().catch(() => ({ error: res.statusText }));
107
- throw new Error(body.error || `Token exchange failed (${res.status})`);
108
- }
62
+ function hashToken(token: string): string {
63
+ return createHash("sha256").update(token).digest("hex");
64
+ }
109
65
 
110
- const data = await res.json();
111
- if (!data.access_token || !data.refresh_token) {
112
- throw new Error("Token exchange returned incomplete session");
66
+ /** Get stored token hash (computed from persisted am_ token). */
67
+ function getTokenHash(): string {
68
+ const store = readAuthStore();
69
+ const token = store["mcp_token"];
70
+ if (!token || !token.startsWith("am_")) {
71
+ throw new Error("Not authenticated. Run `assistme login`.");
113
72
  }
114
- return data;
73
+ return hashToken(token);
115
74
  }
116
75
 
76
+ // ── Auth ─────────────────────────────────────────────────────────────
77
+
117
78
  /**
118
- * Login using an am_ CLI token (exchanged via Edge Function for a Supabase session).
79
+ * Login using an am_ MCP token.
80
+ * Validates against DB via validate_mcp_token RPC, stores locally.
119
81
  */
120
- export async function loginWithToken(cliToken: string): Promise<string> {
121
- if (!cliToken.startsWith("am_")) {
82
+ export async function loginWithToken(mcpToken: string): Promise<string> {
83
+ if (!mcpToken.startsWith("am_")) {
122
84
  throw new Error(
123
85
  "Invalid token format. Use an am_ token from the web page."
124
86
  );
125
87
  }
126
88
 
127
- const sessionTokens = await exchangeCliToken(cliToken);
128
-
129
- // Persist the CLI token so we can re-authenticate later
130
- const store = readAuthStore();
131
- store["cli_token"] = cliToken;
132
- writeAuthStore(store);
133
-
89
+ const hash = hashToken(mcpToken);
134
90
  const sb = getSupabase();
135
- const { data, error } = await sb.auth.setSession({
136
- access_token: sessionTokens.access_token,
137
- refresh_token: sessionTokens.refresh_token,
91
+
92
+ const { data, error } = await sb.rpc("validate_mcp_token", {
93
+ p_token_hash: hash,
138
94
  });
139
95
 
140
- if (error) throw new Error(`Login failed: ${error.message}`);
141
- if (!data.user) throw new Error("Login failed: no user returned");
96
+ if (error) throw new Error(`Token validation failed: ${error.message}`);
97
+ if (!data || data.length === 0) throw new Error("Invalid or expired token");
142
98
 
143
- return data.user.id;
144
- }
145
-
146
- /**
147
- * Re-authenticate using the stored CLI token (am_).
148
- * Called automatically when the Supabase session has expired.
149
- */
150
- export async function refreshWithCliToken(): Promise<string | null> {
99
+ // Persist token
151
100
  const store = readAuthStore();
152
- const cliToken = store["cli_token"];
153
- if (!cliToken || !cliToken.startsWith("am_")) return null;
154
-
155
- try {
156
- const sessionTokens = await exchangeCliToken(cliToken);
157
- const sb = getSupabase();
158
- const { data, error } = await sb.auth.setSession({
159
- access_token: sessionTokens.access_token,
160
- refresh_token: sessionTokens.refresh_token,
161
- });
162
- if (error || !data.user) return null;
163
- return data.user.id;
164
- } catch {
165
- return null;
166
- }
167
- }
101
+ store["mcp_token"] = mcpToken;
102
+ writeAuthStore(store);
168
103
 
169
- export async function getSession() {
170
- const sb = getSupabase();
171
- const { data } = await sb.auth.getSession();
172
- return data.session;
104
+ return data[0].out_user_id;
173
105
  }
174
106
 
175
107
  export async function getCurrentUserId(): Promise<string> {
108
+ const hash = getTokenHash();
176
109
  const sb = getSupabase();
177
- const { data, error } = await sb.auth.getUser();
178
- if (error || !data.user) {
179
- // Try auto-refresh with stored CLI token
180
- const refreshed = await refreshWithCliToken();
181
- if (refreshed) return refreshed;
182
- throw new Error("Not authenticated. Run `assistme login`.");
110
+ const { data, error } = await sb.rpc("validate_mcp_token", {
111
+ p_token_hash: hash,
112
+ });
113
+ if (error || !data || data.length === 0) {
114
+ throw new Error("Token expired or revoked. Run `assistme login`.");
183
115
  }
184
- return data.user.id;
116
+ return data[0].out_user_id;
185
117
  }
186
118
 
187
119
  export async function logout(): Promise<void> {
188
- const sb = getSupabase();
189
- await sb.auth.signOut();
190
- // Also clear the file
191
120
  try {
192
121
  writeAuthStore({});
193
122
  } catch {
@@ -211,24 +140,19 @@ export interface AgentSession {
211
140
  }
212
141
 
213
142
  export async function createSession(
214
- userId: string,
143
+ _userId: string,
215
144
  sessionName: string,
216
145
  workspacePath: string,
217
146
  version: string
218
147
  ): Promise<AgentSession> {
219
148
  const sb = getSupabase();
220
- const { data, error } = await sb
221
- .from("agent_sessions")
222
- .insert({
223
- user_id: userId,
224
- session_name: sessionName,
225
- status: "online",
226
- workspace_path: workspacePath,
227
- agent_version: version,
228
- metadata: { model: getConfig().model },
229
- })
230
- .select()
231
- .single();
149
+ const { data, error } = await sb.rpc("mcp_create_session", {
150
+ p_token_hash: getTokenHash(),
151
+ p_session_name: sessionName,
152
+ p_workspace_path: workspacePath,
153
+ p_version: version,
154
+ p_model: getConfig().model || null,
155
+ });
232
156
 
233
157
  if (error) throw new Error(`Failed to create session: ${error.message}`);
234
158
  return data as AgentSession;
@@ -236,24 +160,19 @@ export async function createSession(
236
160
 
237
161
  export async function updateHeartbeat(sessionId: string): Promise<void> {
238
162
  const sb = getSupabase();
239
- const { error } = await sb
240
- .from("agent_sessions")
241
- .update({ last_heartbeat_at: new Date().toISOString() })
242
- .eq("id", sessionId);
243
-
163
+ const { error } = await sb.rpc("mcp_heartbeat", {
164
+ p_token_hash: getTokenHash(),
165
+ p_session_id: sessionId,
166
+ });
244
167
  if (error) log.warn(`Heartbeat update failed: ${error.message}`);
245
168
  }
246
169
 
247
170
  export async function endSession(sessionId: string): Promise<void> {
248
171
  const sb = getSupabase();
249
- const { error } = await sb
250
- .from("agent_sessions")
251
- .update({
252
- status: "offline",
253
- ended_at: new Date().toISOString(),
254
- })
255
- .eq("id", sessionId);
256
-
172
+ const { error } = await sb.rpc("mcp_end_session", {
173
+ p_token_hash: getTokenHash(),
174
+ p_session_id: sessionId,
175
+ });
257
176
  if (error) log.error(`Failed to end session: ${error.message}`);
258
177
  }
259
178
 
@@ -262,64 +181,28 @@ export async function setSessionBusy(
262
181
  busy: boolean
263
182
  ): Promise<void> {
264
183
  const sb = getSupabase();
265
- await sb
266
- .from("agent_sessions")
267
- .update({ status: busy ? "busy" : "online" })
268
- .eq("id", sessionId);
184
+ await sb.rpc("mcp_set_session_busy", {
185
+ p_token_hash: getTokenHash(),
186
+ p_session_id: sessionId,
187
+ p_busy: busy,
188
+ });
269
189
  }
270
190
 
271
191
  // ── Conversation Management ─────────────────────────────────────────
272
192
 
273
- /**
274
- * Find or create a CLI conversation for a given session.
275
- * Each agent session gets one conversation.
276
- */
277
193
  export async function getOrCreateCliConversation(
278
- userId: string,
279
- sessionId: string
194
+ _userId: string,
195
+ _sessionId: string
280
196
  ): Promise<string> {
281
197
  const sb = getSupabase();
282
-
283
- // Check if a CLI conversation already exists for this user's current session
284
- // by looking for conversations where the agent is 'cli' and user is participant
285
- const { data: existing } = await sb
286
- .from("conversations")
287
- .select("id")
288
- .eq("agent", "cli")
289
- .eq("created_by", userId)
290
- .order("created_at", { ascending: false })
291
- .limit(1);
292
-
293
- if (existing && existing.length > 0) {
294
- return existing[0].id;
295
- }
296
-
297
- // Create new conversation
298
- const { data: conv, error: convErr } = await sb
299
- .from("conversations")
300
- .insert({
301
- conversation_type: "direct",
302
- agent: "cli",
303
- created_by: userId,
304
- })
305
- .select("id")
306
- .single();
307
-
308
- if (convErr || !conv) {
309
- throw new Error(`Failed to create conversation: ${convErr?.message}`);
310
- }
311
-
312
- // Add participants: user + CLI agent
313
- await sb.from("conversation_participants").insert([
314
- { conversation_id: conv.id, user_id: userId, role: "member" },
315
- { conversation_id: conv.id, user_id: CLI_AGENT_ID, role: "member" },
316
- ]);
317
-
318
- return conv.id;
198
+ const { data, error } = await sb.rpc("mcp_get_or_create_conversation", {
199
+ p_token_hash: getTokenHash(),
200
+ });
201
+ if (error) throw new Error(`Failed to get conversation: ${error.message}`);
202
+ return data as string;
319
203
  }
320
204
 
321
205
  // ── Message / Task Management ────────────────────────────────────────
322
- // A "task" is now an assistant message in conversation_messages with a status.
323
206
 
324
207
  export interface ConversationMessage {
325
208
  id: string;
@@ -334,115 +217,88 @@ export interface ConversationMessage {
334
217
  metadata: Record<string, unknown>;
335
218
  created_at: string;
336
219
  updated_at: string;
337
- /** The original user prompt (stored in metadata for assistant messages) */
338
220
  prompt: string;
339
221
  }
340
222
 
341
- // Keep AgentTask as an alias for backward compatibility with processor.ts
342
223
  export type AgentTask = ConversationMessage;
343
224
 
344
- /**
345
- * Create a user prompt + pending assistant message pair atomically.
346
- * Uses a PG function for transactional safety. Falls back to sequential
347
- * inserts if the function doesn't exist yet.
348
- * Returns the assistant message (the "task" to process).
349
- */
350
225
  export async function createTask(
351
226
  conversationId: string,
352
- userId: string,
227
+ _userId: string,
353
228
  sessionId: string,
354
229
  prompt: string
355
230
  ): Promise<ConversationMessage> {
356
231
  const sb = getSupabase();
357
-
358
- // Try atomic RPC first
359
- const { data: rpcResult, error: rpcError } = await sb.rpc(
360
- "create_task_pair",
361
- {
362
- p_conversation_id: conversationId,
363
- p_user_id: userId,
364
- p_agent_id: CLI_AGENT_ID,
365
- p_session_id: sessionId,
366
- p_prompt: prompt,
367
- }
368
- );
369
-
370
- if (!rpcError && rpcResult) {
371
- return { ...rpcResult, prompt } as ConversationMessage;
372
- }
373
-
374
- // Fallback: sequential inserts (for environments without the PG function)
375
- if (rpcError) {
376
- log.debug(`create_task_pair RPC unavailable, using fallback: ${rpcError.message}`);
377
- }
378
-
379
- // Insert user message
380
- const { error: userErr } = await sb.from("conversation_messages").insert({
381
- conversation_id: conversationId,
382
- sender_id: userId,
383
- role: "user",
384
- content: prompt,
232
+ const { data, error } = await sb.rpc("mcp_create_task", {
233
+ p_token_hash: getTokenHash(),
234
+ p_conversation_id: conversationId,
235
+ p_session_id: sessionId,
236
+ p_prompt: prompt,
385
237
  });
386
238
 
387
- if (userErr) throw new Error(`Failed to create user message: ${userErr.message}`);
388
-
389
- // Insert assistant placeholder (the "task")
390
- const { data, error } = await sb
391
- .from("conversation_messages")
392
- .insert({
393
- conversation_id: conversationId,
394
- sender_id: CLI_AGENT_ID,
395
- role: "assistant",
396
- content: "",
397
- status: "pending",
398
- session_id: sessionId,
399
- metadata: { prompt },
400
- })
401
- .select()
402
- .single();
403
-
404
239
  if (error) throw new Error(`Failed to create task: ${error.message}`);
405
240
  return { ...data, prompt } as ConversationMessage;
406
241
  }
407
242
 
408
- /**
409
- * Poll for pending assistant messages assigned to this session.
410
- */
411
243
  export async function pollPendingTasks(
412
244
  sessionId: string
413
245
  ): Promise<ConversationMessage[]> {
414
246
  const sb = getSupabase();
415
- const { data, error } = await sb
416
- .from("conversation_messages")
417
- .select("*")
418
- .eq("session_id", sessionId)
419
- .eq("status", "pending")
420
- .order("created_at", { ascending: true })
421
- .limit(1);
247
+ const { data, error } = await sb.rpc("mcp_poll_tasks", {
248
+ p_token_hash: getTokenHash(),
249
+ p_session_id: sessionId,
250
+ });
422
251
 
423
252
  if (error) {
424
253
  log.warn(`Task poll failed: ${error.message}`);
425
254
  return [];
426
255
  }
427
- // Extract prompt from metadata for backward compat with processor
428
- return (data || []).map((row: Record<string, unknown>) => ({
256
+
257
+ const rows = (data || []) as Record<string, unknown>[];
258
+ return rows.map((row) => ({
429
259
  ...row,
430
- prompt: (row.metadata as Record<string, unknown>)?.prompt || row.content || "",
260
+ prompt:
261
+ (row.metadata as Record<string, unknown>)?.prompt || row.content || "",
431
262
  })) as ConversationMessage[];
432
263
  }
433
264
 
434
- export async function claimTask(messageId: string): Promise<void> {
265
+ /**
266
+ * Atomically poll ONE pending task and claim it in a single DB call.
267
+ * Uses FOR UPDATE SKIP LOCKED — concurrent CLIs will never grab the same task.
268
+ * Returns null if no pending task exists.
269
+ */
270
+ export async function pollAndClaimTask(
271
+ sessionId: string
272
+ ): Promise<ConversationMessage | null> {
435
273
  const sb = getSupabase();
436
- const { error } = await sb
437
- .from("conversation_messages")
438
- .update({
439
- status: "running",
440
- metadata: { started_at: new Date().toISOString() },
441
- })
442
- .eq("id", messageId)
443
- .eq("status", "pending");
274
+ const { data, error } = await sb.rpc("mcp_poll_and_claim_task", {
275
+ p_token_hash: getTokenHash(),
276
+ p_session_id: sessionId,
277
+ });
278
+
279
+ if (error) {
280
+ log.warn(`Poll-and-claim failed: ${error.message}`);
281
+ return null;
282
+ }
444
283
 
284
+ if (!data) return null;
285
+
286
+ const row = data as Record<string, unknown>;
287
+ return {
288
+ ...row,
289
+ prompt:
290
+ (row.metadata as Record<string, unknown>)?.prompt || row.content || "",
291
+ } as ConversationMessage;
292
+ }
293
+
294
+ export async function claimTask(messageId: string): Promise<boolean> {
295
+ const sb = getSupabase();
296
+ const { data, error } = await sb.rpc("mcp_claim_task", {
297
+ p_token_hash: getTokenHash(),
298
+ p_message_id: messageId,
299
+ });
445
300
  if (error) throw new Error(`Failed to claim task: ${error.message}`);
301
+ return data as boolean;
446
302
  }
447
303
 
448
304
  export async function completeTask(
@@ -451,29 +307,12 @@ export async function completeTask(
451
307
  tokenUsage?: Record<string, number>
452
308
  ): Promise<void> {
453
309
  const sb = getSupabase();
454
-
455
- // Fetch current metadata to merge
456
- const { data: current } = await sb
457
- .from("conversation_messages")
458
- .select("metadata")
459
- .eq("id", messageId)
460
- .single();
461
-
462
- const existingMeta = (current?.metadata as Record<string, unknown>) || {};
463
-
464
- const { error } = await sb
465
- .from("conversation_messages")
466
- .update({
467
- content: resultSummary,
468
- status: "completed",
469
- metadata: {
470
- ...existingMeta,
471
- completed_at: new Date().toISOString(),
472
- token_usage: tokenUsage || null,
473
- },
474
- })
475
- .eq("id", messageId);
476
-
310
+ const { error } = await sb.rpc("mcp_complete_task", {
311
+ p_token_hash: getTokenHash(),
312
+ p_message_id: messageId,
313
+ p_result: resultSummary,
314
+ p_token_usage: tokenUsage || null,
315
+ });
477
316
  if (error) throw new Error(`Failed to complete task: ${error.message}`);
478
317
  }
479
318
 
@@ -482,28 +321,11 @@ export async function failTask(
482
321
  errorMessage: string
483
322
  ): Promise<void> {
484
323
  const sb = getSupabase();
485
-
486
- const { data: current } = await sb
487
- .from("conversation_messages")
488
- .select("metadata")
489
- .eq("id", messageId)
490
- .single();
491
-
492
- const existingMeta = (current?.metadata as Record<string, unknown>) || {};
493
-
494
- const { error } = await sb
495
- .from("conversation_messages")
496
- .update({
497
- status: "failed",
498
- content: errorMessage,
499
- metadata: {
500
- ...existingMeta,
501
- completed_at: new Date().toISOString(),
502
- error_message: errorMessage,
503
- },
504
- })
505
- .eq("id", messageId);
506
-
324
+ const { error } = await sb.rpc("mcp_fail_task", {
325
+ p_token_hash: getTokenHash(),
326
+ p_message_id: messageId,
327
+ p_error: errorMessage,
328
+ });
507
329
  if (error) log.error(`Failed to update task status: ${error.message}`);
508
330
  }
509
331
 
@@ -531,32 +353,29 @@ export async function emitEvent(
531
353
  ): Promise<void> {
532
354
  const sb = getSupabase();
533
355
  eventSequence++;
534
- const { error } = await sb.from("message_events").insert({
535
- message_id: messageId,
536
- event_type: eventType,
537
- event_data: eventData,
538
- sequence_number: eventSequence,
356
+ const { error } = await sb.rpc("mcp_emit_event", {
357
+ p_token_hash: getTokenHash(),
358
+ p_message_id: messageId,
359
+ p_event_type: eventType,
360
+ p_event_data: eventData,
361
+ p_seq: eventSequence,
539
362
  });
540
-
541
363
  if (error) log.warn(`Failed to emit event: ${error.message}`);
542
364
  }
543
365
 
544
- // Batch emit for efficiency
545
366
  export async function emitEvents(
546
367
  messageId: string,
547
368
  events: Array<{ type: EventType; data: Record<string, unknown> }>
548
369
  ): Promise<void> {
549
370
  const sb = getSupabase();
550
- const rows = events.map((e) => {
371
+ const eventsJson = events.map((e) => {
551
372
  eventSequence++;
552
- return {
553
- message_id: messageId,
554
- event_type: e.type,
555
- event_data: e.data,
556
- sequence_number: eventSequence,
557
- };
373
+ return { type: e.type, data: e.data, seq: eventSequence };
374
+ });
375
+ const { error } = await sb.rpc("mcp_emit_events", {
376
+ p_token_hash: getTokenHash(),
377
+ p_message_id: messageId,
378
+ p_events: eventsJson,
558
379
  });
559
-
560
- const { error } = await sb.from("message_events").insert(rows);
561
380
  if (error) log.warn(`Failed to emit events batch: ${error.message}`);
562
381
  }