@keyou007/agent-bot 0.1.14-alpha.0 → 0.1.14-alpha.1

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 (74) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +41 -44
  3. package/README.zh.md +41 -44
  4. package/config.example.yaml +4 -0
  5. package/dist/codex/CodexRuntime.js +8 -3
  6. package/dist/codex/CodexRuntime.js.map +1 -1
  7. package/dist/commands/CommandRouter.js +13 -0
  8. package/dist/commands/CommandRouter.js.map +1 -1
  9. package/dist/config/loadConfig.js +2 -0
  10. package/dist/config/loadConfig.js.map +1 -1
  11. package/dist/config/schema.js +2 -0
  12. package/dist/config/schema.js.map +1 -1
  13. package/dist/console/ConsoleTurnPresenter.js +2 -0
  14. package/dist/console/ConsoleTurnPresenter.js.map +1 -1
  15. package/dist/feishu/CardRenderer.js +428 -9
  16. package/dist/feishu/CardRenderer.js.map +1 -1
  17. package/dist/feishu/FeishuConnector.js +29 -8
  18. package/dist/feishu/FeishuConnector.js.map +1 -1
  19. package/dist/feishu/FeishuMessageClient.js +43 -0
  20. package/dist/feishu/FeishuMessageClient.js.map +1 -1
  21. package/dist/feishu/FeishuTurnPresenter.js +29 -1
  22. package/dist/feishu/FeishuTurnPresenter.js.map +1 -1
  23. package/dist/feishu/ownerAccess.js +7 -0
  24. package/dist/feishu/ownerAccess.js.map +1 -0
  25. package/dist/index.js +14 -17
  26. package/dist/index.js.map +1 -1
  27. package/dist/presentation/OutboundRouter.js +19 -1
  28. package/dist/presentation/OutboundRouter.js.map +1 -1
  29. package/dist/proxy/ProxySessionController.js +1012 -58
  30. package/dist/proxy/ProxySessionController.js.map +1 -1
  31. package/dist/startup/StartupNotifier.js +65 -16
  32. package/dist/startup/StartupNotifier.js.map +1 -1
  33. package/dist/startup/startFeishu.js +2 -2
  34. package/dist/startup/startFeishu.js.map +1 -1
  35. package/dist/state/StateStore.js +250 -67
  36. package/dist/state/StateStore.js.map +1 -1
  37. package/dist/state/migrations.js +26 -0
  38. package/dist/state/migrations.js.map +1 -1
  39. package/dist/supervision/SafeRestartNotifier.js +17 -16
  40. package/dist/supervision/SafeRestartNotifier.js.map +1 -1
  41. package/dist/supervision/SafeRestartScheduler.js +12 -7
  42. package/dist/supervision/SafeRestartScheduler.js.map +1 -1
  43. package/dist/supervision/replacementSupervisor.js +45 -4
  44. package/dist/supervision/replacementSupervisor.js.map +1 -1
  45. package/dist/supervisor.js +30 -18
  46. package/dist/supervisor.js.map +1 -1
  47. package/docs/technical-reference.md +18 -5
  48. package/docs/technical-reference.zh.md +18 -5
  49. package/npm-shrinkwrap.json +2 -2
  50. package/package.json +1 -1
  51. package/skills/agent-bot/SKILL.md +8 -2
  52. package/src/codex/CodexRuntime.ts +9 -3
  53. package/src/commands/CommandRouter.ts +13 -0
  54. package/src/commands/commandTypes.ts +2 -0
  55. package/src/config/loadConfig.ts +2 -0
  56. package/src/config/schema.ts +2 -0
  57. package/src/console/ConsoleTurnPresenter.ts +4 -1
  58. package/src/feishu/CardRenderer.ts +533 -9
  59. package/src/feishu/FeishuConnector.ts +44 -10
  60. package/src/feishu/FeishuMessageClient.ts +58 -0
  61. package/src/feishu/FeishuTurnPresenter.ts +35 -2
  62. package/src/feishu/ownerAccess.ts +7 -0
  63. package/src/feishu/types.ts +7 -0
  64. package/src/index.ts +20 -20
  65. package/src/presentation/OutboundRouter.ts +28 -3
  66. package/src/proxy/ProxySessionController.ts +1177 -57
  67. package/src/startup/StartupNotifier.ts +67 -17
  68. package/src/startup/startFeishu.ts +5 -3
  69. package/src/state/StateStore.ts +394 -65
  70. package/src/state/migrations.ts +26 -0
  71. package/src/supervision/SafeRestartNotifier.ts +17 -17
  72. package/src/supervision/SafeRestartScheduler.ts +13 -6
  73. package/src/supervision/replacementSupervisor.ts +50 -4
  74. package/src/supervisor.ts +35 -16
@@ -18,6 +18,7 @@ export interface UserContextRecord {
18
18
  export interface ChatContextRecord {
19
19
  contextKey: string;
20
20
  chatType: "p2p" | "group";
21
+ requiresMention: boolean;
21
22
  lastActivityAt?: string;
22
23
  createdAt: string;
23
24
  updatedAt: string;
@@ -89,6 +90,41 @@ export interface QueuedPromptRecord {
89
90
  createdAt: string;
90
91
  }
91
92
 
93
+ export type TurnAttemptStatus =
94
+ | "accepted"
95
+ | "running"
96
+ | "recovering"
97
+ | "completed"
98
+ | "failed"
99
+ | "cancelled"
100
+ | "interrupted";
101
+
102
+ export interface TurnAttemptRecord {
103
+ attemptId: string;
104
+ localSessionId: string;
105
+ contextKey: string;
106
+ promptText: string;
107
+ localImagePaths?: string[];
108
+ messageId?: string;
109
+ replyMessageId?: string;
110
+ pendingTurnId?: string;
111
+ turnId?: string;
112
+ recoveredFromTurnId?: string;
113
+ recoveryCount: number;
114
+ retryCount: number;
115
+ status: TurnAttemptStatus;
116
+ createdAt: string;
117
+ updatedAt: string;
118
+ }
119
+
120
+ export interface PersistedTurnSnapshotRecord {
121
+ turnId: string;
122
+ localSessionId: string;
123
+ contextKey?: string;
124
+ snapshot: unknown;
125
+ updatedAt: string;
126
+ }
127
+
92
128
  interface UserContextRow {
93
129
  context_key: string;
94
130
  default_agent: string;
@@ -102,6 +138,7 @@ interface UserContextRow {
102
138
  interface ChatContextRow {
103
139
  context_key: string;
104
140
  chat_type: "p2p" | "group";
141
+ require_mention: number;
105
142
  last_activity_at: string | null;
106
143
  created_at: string;
107
144
  updated_at: string;
@@ -150,6 +187,24 @@ interface QueuedPromptRow {
150
187
  created_at: string;
151
188
  }
152
189
 
190
+ interface TurnAttemptRow {
191
+ attempt_id: string;
192
+ local_session_id: string;
193
+ context_key: string;
194
+ prompt_text: string;
195
+ local_image_paths_json: string;
196
+ message_id: string | null;
197
+ reply_message_id: string | null;
198
+ pending_turn_id: string | null;
199
+ turn_id: string | null;
200
+ recovered_from_turn_id: string | null;
201
+ recovery_count: number;
202
+ retry_count: number;
203
+ status: TurnAttemptStatus;
204
+ created_at: string;
205
+ updated_at: string;
206
+ }
207
+
153
208
  export class StateStore {
154
209
  private readonly db: Database.Database;
155
210
 
@@ -165,6 +220,7 @@ export class StateStore {
165
220
  this.ensureSessionColumns();
166
221
  this.ensureTurnSnapshotColumns();
167
222
  this.ensureChatContextColumns();
223
+ this.ensureTurnAttemptColumns();
168
224
  this.initializeContextSessionMappings();
169
225
  this.db.exec("DROP INDEX IF EXISTS idx_sessions_remote_session_id_unique");
170
226
  this.reconcileDuplicateRemoteSessions();
@@ -235,6 +291,30 @@ export class StateStore {
235
291
  `).run(contextKey, chatType, now, now);
236
292
  }
237
293
 
294
+ getChatContext(contextKey: string): ChatContextRecord | undefined {
295
+ const row = this.db.prepare("SELECT * FROM chat_contexts WHERE context_key = ?")
296
+ .get(contextKey) as ChatContextRow | undefined;
297
+ return row ? mapChatContext(row) : undefined;
298
+ }
299
+
300
+ setChatRequiresMention(contextKey: string, requiresMention: boolean): void {
301
+ const now = new Date().toISOString();
302
+ this.db.prepare(`
303
+ UPDATE chat_contexts
304
+ SET require_mention = ?, updated_at = ?
305
+ WHERE context_key = ? AND chat_type = 'group'
306
+ `).run(requiresMention ? 1 : 0, now, contextKey);
307
+ }
308
+
309
+ chatRequiresMention(contextKey: string): boolean {
310
+ const row = this.db.prepare(`
311
+ SELECT require_mention
312
+ FROM chat_contexts
313
+ WHERE context_key = ? AND chat_type = 'group'
314
+ `).get(contextKey) as { require_mention: number } | undefined;
315
+ return row?.require_mention === 1;
316
+ }
317
+
238
318
  listChatContexts(chatType?: "p2p" | "group"): ChatContextRecord[] {
239
319
  const rows = chatType
240
320
  ? this.db.prepare(`
@@ -243,13 +323,7 @@ export class StateStore {
243
323
  ORDER BY updated_at DESC
244
324
  `).all(chatType) as ChatContextRow[]
245
325
  : this.db.prepare("SELECT * FROM chat_contexts ORDER BY updated_at DESC").all() as ChatContextRow[];
246
- return rows.map((row) => ({
247
- contextKey: row.context_key,
248
- chatType: row.chat_type,
249
- lastActivityAt: row.last_activity_at ?? undefined,
250
- createdAt: row.created_at,
251
- updatedAt: row.updated_at,
252
- }));
326
+ return rows.map(mapChatContext);
253
327
  }
254
328
 
255
329
  markChatActive(contextKey: string, activeAt = new Date()): void {
@@ -267,13 +341,7 @@ export class StateStore {
267
341
  WHERE last_activity_at >= ?
268
342
  ORDER BY last_activity_at DESC
269
343
  `).all(since.toISOString()) as ChatContextRow[];
270
- return rows.map((row) => ({
271
- contextKey: row.context_key,
272
- chatType: row.chat_type,
273
- lastActivityAt: row.last_activity_at ?? undefined,
274
- createdAt: row.created_at,
275
- updatedAt: row.updated_at,
276
- }));
344
+ return rows.map(mapChatContext);
277
345
  }
278
346
 
279
347
  enqueuePrompt(input: {
@@ -361,6 +429,199 @@ export class StateStore {
361
429
  return cancel();
362
430
  }
363
431
 
432
+ createTurnAttempt(input: {
433
+ attemptId: string;
434
+ localSessionId: string;
435
+ contextKey: string;
436
+ promptText: string;
437
+ localImagePaths?: string[];
438
+ messageId?: string;
439
+ replyMessageId?: string;
440
+ pendingTurnId?: string;
441
+ turnId?: string;
442
+ recoveredFromTurnId?: string;
443
+ recoveryCount?: number;
444
+ retryCount?: number;
445
+ status?: Extract<TurnAttemptStatus, "accepted" | "running" | "recovering">;
446
+ createdAt?: string;
447
+ updatedAt?: string;
448
+ }): TurnAttemptRecord {
449
+ const createdAt = input.createdAt ?? new Date().toISOString();
450
+ const updatedAt = input.updatedAt ?? createdAt;
451
+ const status = input.status ?? (input.turnId ? "running" : "accepted");
452
+ this.db.prepare(`
453
+ INSERT INTO turn_attempts (
454
+ attempt_id, local_session_id, context_key, prompt_text, local_image_paths_json,
455
+ message_id, reply_message_id, pending_turn_id, turn_id, recovered_from_turn_id,
456
+ recovery_count, retry_count, status, created_at, updated_at
457
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
458
+ `).run(
459
+ input.attemptId,
460
+ input.localSessionId,
461
+ input.contextKey,
462
+ input.promptText,
463
+ JSON.stringify(input.localImagePaths ?? []),
464
+ input.messageId ?? null,
465
+ input.replyMessageId ?? null,
466
+ input.pendingTurnId ?? null,
467
+ input.turnId ?? null,
468
+ input.recoveredFromTurnId ?? null,
469
+ input.recoveryCount ?? 0,
470
+ input.retryCount ?? 0,
471
+ status,
472
+ createdAt,
473
+ updatedAt,
474
+ );
475
+ return this.getTurnAttempt(input.attemptId)!;
476
+ }
477
+
478
+ getTurnAttempt(attemptId: string): TurnAttemptRecord | undefined {
479
+ const row = this.db.prepare("SELECT * FROM turn_attempts WHERE attempt_id = ?")
480
+ .get(attemptId) as TurnAttemptRow | undefined;
481
+ return row ? mapTurnAttempt(row) : undefined;
482
+ }
483
+
484
+ findIncompleteTurnAttemptForSession(localSessionId: string): TurnAttemptRecord | undefined {
485
+ const row = this.db.prepare(`
486
+ SELECT * FROM turn_attempts
487
+ WHERE local_session_id = ?
488
+ AND status IN ('accepted', 'running', 'recovering')
489
+ ORDER BY updated_at DESC, rowid DESC
490
+ LIMIT 1
491
+ `).get(localSessionId) as TurnAttemptRow | undefined;
492
+ return row ? mapTurnAttempt(row) : undefined;
493
+ }
494
+
495
+ listIncompleteTurnAttempts(): TurnAttemptRecord[] {
496
+ const rows = this.db.prepare(`
497
+ SELECT * FROM turn_attempts
498
+ WHERE status IN ('accepted', 'running', 'recovering')
499
+ ORDER BY created_at ASC, rowid ASC
500
+ `).all() as TurnAttemptRow[];
501
+ return rows.map(mapTurnAttempt);
502
+ }
503
+
504
+ updateTurnAttempt(
505
+ attemptId: string,
506
+ patch: Partial<{
507
+ messageId: string | null;
508
+ replyMessageId: string | null;
509
+ pendingTurnId: string | null;
510
+ turnId: string | null;
511
+ recoveredFromTurnId: string | null;
512
+ recoveryCount: number;
513
+ retryCount: number;
514
+ status: TurnAttemptStatus;
515
+ }>,
516
+ ): void {
517
+ const existing = this.getTurnAttempt(attemptId);
518
+ if (!existing) return;
519
+ const value = <T>(candidate: T | null | undefined, fallback: T | undefined): T | null =>
520
+ candidate === undefined ? fallback ?? null : candidate;
521
+ this.db.prepare(`
522
+ UPDATE turn_attempts
523
+ SET message_id = ?, reply_message_id = ?, pending_turn_id = ?, turn_id = ?,
524
+ recovered_from_turn_id = ?, recovery_count = ?, retry_count = ?, status = ?, updated_at = ?
525
+ WHERE attempt_id = ?
526
+ `).run(
527
+ value(patch.messageId, existing.messageId),
528
+ value(patch.replyMessageId, existing.replyMessageId),
529
+ value(patch.pendingTurnId, existing.pendingTurnId),
530
+ value(patch.turnId, existing.turnId),
531
+ value(patch.recoveredFromTurnId, existing.recoveredFromTurnId),
532
+ patch.recoveryCount ?? existing.recoveryCount,
533
+ patch.retryCount ?? existing.retryCount,
534
+ patch.status ?? existing.status,
535
+ new Date().toISOString(),
536
+ attemptId,
537
+ );
538
+ }
539
+
540
+ bindTurnAttempt(localSessionId: string, turnId: string): TurnAttemptRecord | undefined {
541
+ const attempt = this.findIncompleteTurnAttemptForSession(localSessionId);
542
+ if (!attempt || (attempt.turnId && attempt.turnId !== turnId)) return undefined;
543
+ this.updateTurnAttempt(attempt.attemptId, { turnId, status: "running" });
544
+ return this.getTurnAttempt(attempt.attemptId);
545
+ }
546
+
547
+ markTurnAttemptTerminal(
548
+ turnId: string,
549
+ status: Extract<TurnAttemptStatus, "completed" | "failed" | "cancelled">,
550
+ ): void {
551
+ this.db.prepare(`
552
+ UPDATE turn_attempts
553
+ SET status = ?, updated_at = ?
554
+ WHERE turn_id = ?
555
+ `).run(status, new Date().toISOString(), turnId);
556
+ }
557
+
558
+ findIncompleteTurnAttemptByTurnId(turnId: string): TurnAttemptRecord | undefined {
559
+ const row = this.db.prepare(`
560
+ SELECT * FROM turn_attempts
561
+ WHERE turn_id = ? AND status IN ('accepted', 'running', 'recovering')
562
+ ORDER BY updated_at DESC, rowid DESC
563
+ LIMIT 1
564
+ `).get(turnId) as TurnAttemptRow | undefined;
565
+ return row ? mapTurnAttempt(row) : undefined;
566
+ }
567
+
568
+ touchTurnAttempt(turnId: string, activityAt = new Date()): void {
569
+ const timestamp = activityAt.toISOString();
570
+ const throttleCutoff = new Date(activityAt.getTime() - 15_000).toISOString();
571
+ this.db.prepare(`
572
+ UPDATE turn_attempts
573
+ SET updated_at = ?
574
+ WHERE turn_id = ?
575
+ AND status IN ('accepted', 'running', 'recovering')
576
+ AND updated_at < ?
577
+ `).run(timestamp, turnId, throttleCutoff);
578
+ }
579
+
580
+ prepareTurnAttemptRecovery(attemptId: string, recoveredFromTurnId?: string): TurnAttemptRecord | undefined {
581
+ const existing = this.getTurnAttempt(attemptId);
582
+ if (!existing) return undefined;
583
+ this.updateTurnAttempt(attemptId, {
584
+ pendingTurnId: null,
585
+ turnId: null,
586
+ recoveredFromTurnId: recoveredFromTurnId ?? existing.turnId ?? existing.recoveredFromTurnId ?? null,
587
+ recoveryCount: existing.recoveryCount + 1,
588
+ status: "recovering",
589
+ });
590
+ return this.getTurnAttempt(attemptId);
591
+ }
592
+
593
+ prepareTurnAttemptRetry(attemptId: string, failedTurnId: string): TurnAttemptRecord | undefined {
594
+ const existing = this.getTurnAttempt(attemptId);
595
+ if (!existing || existing.turnId !== failedTurnId || !isIncompleteTurnAttemptStatus(existing.status)) {
596
+ return undefined;
597
+ }
598
+ this.updateTurnAttempt(attemptId, {
599
+ pendingTurnId: null,
600
+ turnId: null,
601
+ recoveredFromTurnId: failedTurnId,
602
+ retryCount: existing.retryCount + 1,
603
+ status: "recovering",
604
+ });
605
+ return this.getTurnAttempt(attemptId);
606
+ }
607
+
608
+ rebindPendingTurnMessages(localSessionId: string, sourceTurnId: string, targetTurnId: string): void {
609
+ const now = new Date().toISOString();
610
+ const rebind = this.db.transaction(() => {
611
+ this.db.prepare(`
612
+ UPDATE message_reactions
613
+ SET turn_id = ?, updated_at = ?
614
+ WHERE local_session_id = ? AND turn_id = ? AND status = 'pending'
615
+ `).run(targetTurnId, now, localSessionId, sourceTurnId);
616
+ this.db.prepare(`
617
+ UPDATE message_turn_bindings
618
+ SET turn_id = ?, updated_at = ?
619
+ WHERE local_session_id = ? AND turn_id = ?
620
+ `).run(targetTurnId, now, localSessionId, sourceTurnId);
621
+ });
622
+ rebind();
623
+ }
624
+
364
625
  nextForkTitle(sourceTitle?: string): string {
365
626
  const baseTitle = forkBaseTitle(sourceTitle);
366
627
  const allocate = this.db.transaction(() => {
@@ -378,57 +639,6 @@ export class StateStore {
378
639
  return formatForkTitle(baseTitle, allocate());
379
640
  }
380
641
 
381
- reconcileInterruptedAcpSessions(acpAgentNames: string[]): SessionRecord[] {
382
- const agentClause = acpAgentNames.length
383
- ? ` OR (runtime_kind IS NULL AND agent_name IN (${acpAgentNames.map(() => "?").join(", ")}))`
384
- : "";
385
- const rows = this.db
386
- .prepare(`SELECT * FROM sessions WHERE status = 'running' AND (runtime_kind = 'acp'${agentClause})`)
387
- .all(...acpAgentNames) as SessionRow[];
388
- if (rows.length === 0) return [];
389
-
390
- const now = new Date().toISOString();
391
- const completedAt = Date.now();
392
- const reconcile = this.db.transaction(() => {
393
- for (const row of rows) {
394
- this.db.prepare(`
395
- UPDATE sessions
396
- SET status = 'failed',
397
- last_turn_status = CASE WHEN last_turn_id IS NULL THEN last_turn_status ELSE 'failed' END,
398
- updated_at = ?
399
- WHERE local_session_id = ?
400
- `).run(now, row.local_session_id);
401
-
402
- if (!row.last_turn_id) continue;
403
- const snapshotRow = this.db
404
- .prepare("SELECT snapshot_json FROM turn_snapshots WHERE turn_id = ?")
405
- .get(row.last_turn_id) as { snapshot_json: string } | undefined;
406
- if (!snapshotRow) continue;
407
- try {
408
- const snapshot = JSON.parse(snapshotRow.snapshot_json) as Record<string, unknown>;
409
- if (!["starting", "running", "tool_running", "waiting_for_approval"].includes(String(snapshot.status))) continue;
410
- const startedAt = typeof snapshot.startedAt === "number" ? snapshot.startedAt : undefined;
411
- const failedSnapshot: Record<string, unknown> = {
412
- ...snapshot,
413
- status: "failed",
414
- completedAt,
415
- ...(startedAt === undefined ? {} : { durationMs: Math.max(0, completedAt - startedAt) }),
416
- error: "Agent Bot 已重启,原 ACP 进程中的执行无法继续。",
417
- };
418
- delete failedSnapshot.activeTool;
419
- delete failedSnapshot.approval;
420
- this.db.prepare(`
421
- UPDATE turn_snapshots SET snapshot_json = ?, updated_at = ? WHERE turn_id = ?
422
- `).run(JSON.stringify(failedSnapshot), now, row.last_turn_id);
423
- } catch {
424
- // Keep an unreadable historical snapshot intact while still correcting the session state.
425
- }
426
- }
427
- });
428
- reconcile();
429
- return rows.map(mapSession);
430
- }
431
-
432
642
  setDefaultAgent(contextKey: string, agentName: string): void {
433
643
  const now = new Date().toISOString();
434
644
  this.db
@@ -620,6 +830,34 @@ export class StateStore {
620
830
  return row ? JSON.parse(row.snapshot_json) : undefined;
621
831
  }
622
832
 
833
+ findLatestTurnSnapshotForSession(localSessionId: string): PersistedTurnSnapshotRecord | undefined {
834
+ const row = this.db.prepare(`
835
+ SELECT turn_id, local_session_id, context_key, snapshot_json, updated_at
836
+ FROM turn_snapshots
837
+ WHERE local_session_id = ?
838
+ ORDER BY
839
+ coalesce(json_extract(snapshot_json, '$.startedAt'), 0) DESC,
840
+ updated_at DESC,
841
+ rowid DESC
842
+ LIMIT 1
843
+ `).get(localSessionId) as {
844
+ turn_id: string;
845
+ local_session_id: string;
846
+ context_key: string | null;
847
+ snapshot_json: string;
848
+ updated_at: string;
849
+ } | undefined;
850
+ return row
851
+ ? {
852
+ turnId: row.turn_id,
853
+ localSessionId: row.local_session_id,
854
+ contextKey: row.context_key ?? undefined,
855
+ snapshot: JSON.parse(row.snapshot_json),
856
+ updatedAt: row.updated_at,
857
+ }
858
+ : undefined;
859
+ }
860
+
623
861
  saveTurnRuntimeOrigin(
624
862
  turnId: string,
625
863
  localSessionId: string,
@@ -983,6 +1221,46 @@ export class StateStore {
983
1221
  return rows.map(mapSession);
984
1222
  }
985
1223
 
1224
+ listUndeliveredCompletedTurns(): TurnAnchorRecord[] {
1225
+ const rows = this.db.prepare(`
1226
+ SELECT snapshots.turn_id, snapshots.local_session_id, snapshots.context_key
1227
+ FROM turn_snapshots AS snapshots
1228
+ LEFT JOIN turn_deliveries AS deliveries ON deliveries.turn_id = snapshots.turn_id
1229
+ WHERE json_extract(snapshots.snapshot_json, '$.status') = 'completed'
1230
+ AND length(coalesce(json_extract(snapshots.snapshot_json, '$.finalResponse'), '')) > 0
1231
+ AND deliveries.final_delivered_at IS NULL
1232
+ ORDER BY snapshots.updated_at ASC
1233
+ `).all() as Array<{
1234
+ turn_id: string;
1235
+ local_session_id: string;
1236
+ context_key: string | null;
1237
+ }>;
1238
+ return rows.map((row) => ({
1239
+ turnId: row.turn_id,
1240
+ localSessionId: row.local_session_id,
1241
+ contextKey: row.context_key ?? undefined,
1242
+ }));
1243
+ }
1244
+
1245
+ findMessageIdForTurn(turnId: string): string | undefined {
1246
+ const binding = this.db.prepare(`
1247
+ SELECT message_id
1248
+ FROM message_turn_bindings
1249
+ WHERE turn_id = ?
1250
+ ORDER BY updated_at DESC, rowid DESC
1251
+ LIMIT 1
1252
+ `).get(turnId) as { message_id: string } | undefined;
1253
+ if (binding) return binding.message_id;
1254
+ const reaction = this.db.prepare(`
1255
+ SELECT message_id
1256
+ FROM message_reactions
1257
+ WHERE turn_id = ?
1258
+ ORDER BY updated_at DESC, rowid DESC
1259
+ LIMIT 1
1260
+ `).get(turnId) as { message_id: string } | undefined;
1261
+ return reaction?.message_id;
1262
+ }
1263
+
986
1264
  findLatestMessageIdForSession(localSessionId: string, contextKey?: string): string | undefined {
987
1265
  const row = this.db.prepare(`
988
1266
  SELECT message_id
@@ -1402,6 +1680,18 @@ export class StateStore {
1402
1680
  if (!existing.has("last_activity_at")) {
1403
1681
  this.db.exec("ALTER TABLE chat_contexts ADD COLUMN last_activity_at TEXT");
1404
1682
  }
1683
+ if (!existing.has("require_mention")) {
1684
+ this.db.exec("ALTER TABLE chat_contexts ADD COLUMN require_mention INTEGER NOT NULL DEFAULT 0");
1685
+ }
1686
+ }
1687
+
1688
+ private ensureTurnAttemptColumns(): void {
1689
+ const existing = new Set(
1690
+ (this.db.pragma("table_info(turn_attempts)") as Array<{ name: string }>).map((column) => column.name),
1691
+ );
1692
+ if (!existing.has("retry_count")) {
1693
+ this.db.exec("ALTER TABLE turn_attempts ADD COLUMN retry_count INTEGER NOT NULL DEFAULT 0");
1694
+ }
1405
1695
  }
1406
1696
  }
1407
1697
 
@@ -1433,6 +1723,17 @@ function mapUserContext(row: UserContextRow): UserContextRecord {
1433
1723
  };
1434
1724
  }
1435
1725
 
1726
+ function mapChatContext(row: ChatContextRow): ChatContextRecord {
1727
+ return {
1728
+ contextKey: row.context_key,
1729
+ chatType: row.chat_type,
1730
+ requiresMention: row.require_mention === 1,
1731
+ lastActivityAt: row.last_activity_at ?? undefined,
1732
+ createdAt: row.created_at,
1733
+ updatedAt: row.updated_at,
1734
+ };
1735
+ }
1736
+
1436
1737
  function mapSession(row: SessionRow): SessionRecord {
1437
1738
  return {
1438
1739
  localSessionId: row.local_session_id,
@@ -1485,3 +1786,31 @@ function mapQueuedPrompt(row: QueuedPromptRow): QueuedPromptRecord {
1485
1786
  createdAt: row.created_at,
1486
1787
  };
1487
1788
  }
1789
+
1790
+ function mapTurnAttempt(row: TurnAttemptRow): TurnAttemptRecord {
1791
+ const parsedPaths = JSON.parse(row.local_image_paths_json) as unknown;
1792
+ const localImagePaths = Array.isArray(parsedPaths)
1793
+ ? parsedPaths.filter((value): value is string => typeof value === "string")
1794
+ : [];
1795
+ return {
1796
+ attemptId: row.attempt_id,
1797
+ localSessionId: row.local_session_id,
1798
+ contextKey: row.context_key,
1799
+ promptText: row.prompt_text,
1800
+ localImagePaths: localImagePaths.length > 0 ? localImagePaths : undefined,
1801
+ messageId: row.message_id ?? undefined,
1802
+ replyMessageId: row.reply_message_id ?? undefined,
1803
+ pendingTurnId: row.pending_turn_id ?? undefined,
1804
+ turnId: row.turn_id ?? undefined,
1805
+ recoveredFromTurnId: row.recovered_from_turn_id ?? undefined,
1806
+ recoveryCount: row.recovery_count,
1807
+ retryCount: row.retry_count,
1808
+ status: row.status,
1809
+ createdAt: row.created_at,
1810
+ updatedAt: row.updated_at,
1811
+ };
1812
+ }
1813
+
1814
+ function isIncompleteTurnAttemptStatus(status: TurnAttemptStatus): boolean {
1815
+ return status === "accepted" || status === "running" || status === "recovering";
1816
+ }
@@ -104,6 +104,7 @@ export const migrations = [
104
104
  CREATE TABLE IF NOT EXISTS chat_contexts (
105
105
  context_key TEXT PRIMARY KEY,
106
106
  chat_type TEXT NOT NULL,
107
+ require_mention INTEGER NOT NULL DEFAULT 0,
107
108
  last_activity_at TEXT,
108
109
  created_at TEXT NOT NULL,
109
110
  updated_at TEXT NOT NULL
@@ -146,4 +147,29 @@ export const migrations = [
146
147
  CREATE INDEX IF NOT EXISTS idx_turn_parent_links_session
147
148
  ON turn_parent_links(local_session_id);
148
149
  `,
150
+ `
151
+ CREATE TABLE IF NOT EXISTS turn_attempts (
152
+ attempt_id TEXT PRIMARY KEY,
153
+ local_session_id TEXT NOT NULL,
154
+ context_key TEXT NOT NULL,
155
+ prompt_text TEXT NOT NULL,
156
+ local_image_paths_json TEXT NOT NULL DEFAULT '[]',
157
+ message_id TEXT,
158
+ reply_message_id TEXT,
159
+ pending_turn_id TEXT,
160
+ turn_id TEXT,
161
+ recovered_from_turn_id TEXT,
162
+ recovery_count INTEGER NOT NULL DEFAULT 0,
163
+ retry_count INTEGER NOT NULL DEFAULT 0,
164
+ status TEXT NOT NULL,
165
+ created_at TEXT NOT NULL,
166
+ updated_at TEXT NOT NULL
167
+ );
168
+ CREATE INDEX IF NOT EXISTS idx_turn_attempts_recovery
169
+ ON turn_attempts(status, created_at);
170
+ CREATE INDEX IF NOT EXISTS idx_turn_attempts_session
171
+ ON turn_attempts(local_session_id, updated_at);
172
+ CREATE INDEX IF NOT EXISTS idx_turn_attempts_turn
173
+ ON turn_attempts(turn_id);
174
+ `,
149
175
  ] as const;
@@ -109,17 +109,16 @@ export class SafeRestartNotifier {
109
109
  id: session.remoteSessionId ?? session.localSessionId,
110
110
  title: session.title,
111
111
  }));
112
- const card = this.renderer.renderSafeRestartStatus({
113
- scheduleId: status.scheduleId,
114
- reason: status.reason,
115
- phase: status.phase,
116
- remainingMs: status.remainingMs,
117
- pendingFinalDeliveries: status.activity.pendingFinalDeliveries,
118
- waitingTasks,
119
- });
120
- const cardHash = JSON.stringify(card);
121
-
122
- await Promise.all(targets.map(async ({ contextKey, replyMessageId }) => {
112
+ await Promise.all(targets.map(async ({ contextKey, replyMessageId, reason }) => {
113
+ const card = this.renderer.renderSafeRestartStatus({
114
+ scheduleId: status.scheduleId,
115
+ reason: reason ?? status.reason,
116
+ phase: status.phase,
117
+ remainingMs: status.remainingMs,
118
+ pendingFinalDeliveries: status.activity.pendingFinalDeliveries,
119
+ waitingTasks,
120
+ });
121
+ const cardHash = JSON.stringify(card);
123
122
  const messageId = this.messageIds.get(contextKey);
124
123
  if (messageId) {
125
124
  if (this.cardHashes.get(contextKey) === cardHash) return;
@@ -165,12 +164,13 @@ export class SafeRestartNotifier {
165
164
  const contextKey = target.contextKey.trim();
166
165
  if (!contextKey) continue;
167
166
  const existing = this.notificationTargets.get(contextKey);
168
- if (!existing || (!existing.replyMessageId && target.replyMessageId)) {
169
- this.notificationTargets.set(contextKey, {
170
- contextKey,
171
- ...(target.replyMessageId?.trim() ? { replyMessageId: target.replyMessageId.trim() } : {}),
172
- });
173
- }
167
+ const replyMessageId = existing?.replyMessageId ?? target.replyMessageId?.trim();
168
+ const reason = target.reason?.trim() ?? existing?.reason;
169
+ this.notificationTargets.set(contextKey, {
170
+ contextKey,
171
+ ...(replyMessageId ? { replyMessageId } : {}),
172
+ ...(reason ? { reason } : {}),
173
+ });
174
174
  }
175
175
  }
176
176
  }
@@ -14,6 +14,7 @@ export type SafeRestartPhase =
14
14
  export interface RestartNotificationTarget {
15
15
  contextKey: string;
16
16
  replyMessageId?: string;
17
+ reason?: string;
17
18
  }
18
19
 
19
20
  export interface SafeRestartStatus {
@@ -60,7 +61,7 @@ export class SafeRestartScheduler {
60
61
  this.scheduleId += 1;
61
62
  this.notificationTargets = [];
62
63
  }
63
- this.addNotificationTarget(notificationTarget);
64
+ this.addNotificationTarget(notificationTarget, reason);
64
65
  this.reason = reason;
65
66
  this.idleSince = undefined;
66
67
  this.idleInboundAt = undefined;
@@ -215,8 +216,8 @@ export class SafeRestartScheduler {
215
216
  });
216
217
  }
217
218
 
218
- private addNotificationTarget(value: RestartNotificationTarget | undefined): void {
219
- const target = normalizedNotificationTarget(value);
219
+ private addNotificationTarget(value: RestartNotificationTarget | undefined, reason: string): void {
220
+ const target = normalizedNotificationTarget(value, reason);
220
221
  if (!target) return;
221
222
  const existingIndex = this.notificationTargets.findIndex(
222
223
  (candidate) => candidate.contextKey === target.contextKey,
@@ -226,9 +227,13 @@ export class SafeRestartScheduler {
226
227
  return;
227
228
  }
228
229
  const existing = this.notificationTargets[existingIndex]!;
229
- if (!existing.replyMessageId && target.replyMessageId) {
230
- this.notificationTargets[existingIndex] = target;
231
- }
230
+ this.notificationTargets[existingIndex] = {
231
+ ...existing,
232
+ reason: target.reason,
233
+ ...(!existing.replyMessageId && target.replyMessageId
234
+ ? { replyMessageId: target.replyMessageId }
235
+ : {}),
236
+ };
232
237
  }
233
238
 
234
239
  private notificationTargetsSnapshot(): RestartNotificationTarget[] {
@@ -238,6 +243,7 @@ export class SafeRestartScheduler {
238
243
 
239
244
  function normalizedNotificationTarget(
240
245
  value: RestartNotificationTarget | undefined,
246
+ reason: string,
241
247
  ): RestartNotificationTarget | undefined {
242
248
  const contextKey = value?.contextKey.trim();
243
249
  if (!contextKey) return undefined;
@@ -245,5 +251,6 @@ function normalizedNotificationTarget(
245
251
  return {
246
252
  contextKey,
247
253
  ...(replyMessageId ? { replyMessageId } : {}),
254
+ reason,
248
255
  };
249
256
  }