@keyou007/agent-bot 0.1.13-alpha.0 → 0.1.13-alpha.2

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 (97) hide show
  1. package/.env.example +2 -2
  2. package/CHANGELOG.md +15 -0
  3. package/README.md +107 -98
  4. package/README.zh.md +81 -72
  5. package/dist/acp/AcpProcessManager.js +8 -3
  6. package/dist/acp/AcpProcessManager.js.map +1 -1
  7. package/dist/acp/AcpSessionManager.js +15 -0
  8. package/dist/acp/AcpSessionManager.js.map +1 -1
  9. package/dist/cli/ServerStarter.js +17 -2
  10. package/dist/cli/ServerStarter.js.map +1 -1
  11. package/dist/cli/controlProtocol.js.map +1 -1
  12. package/dist/cli/help.js +16 -4
  13. package/dist/cli/help.js.map +1 -1
  14. package/dist/cli/serverStatus.js +27 -0
  15. package/dist/cli/serverStatus.js.map +1 -1
  16. package/dist/cli/taskListOutput.js +72 -0
  17. package/dist/cli/taskListOutput.js.map +1 -0
  18. package/dist/cli.js +88 -54
  19. package/dist/cli.js.map +1 -1
  20. package/dist/codex/CodexProcessManager.js +34 -4
  21. package/dist/codex/CodexProcessManager.js.map +1 -1
  22. package/dist/codex/CodexRuntime.js +3 -0
  23. package/dist/codex/CodexRuntime.js.map +1 -1
  24. package/dist/commands/CommandRouter.js +5 -0
  25. package/dist/commands/CommandRouter.js.map +1 -1
  26. package/dist/config/FeishuUserOpenIdStore.js +76 -0
  27. package/dist/config/FeishuUserOpenIdStore.js.map +1 -0
  28. package/dist/config/loadConfig.js +3 -0
  29. package/dist/config/loadConfig.js.map +1 -1
  30. package/dist/feishu/CardRenderer.js +123 -22
  31. package/dist/feishu/CardRenderer.js.map +1 -1
  32. package/dist/feishu/FeishuConnector.js +16 -5
  33. package/dist/feishu/FeishuConnector.js.map +1 -1
  34. package/dist/index.js +125 -26
  35. package/dist/index.js.map +1 -1
  36. package/dist/presentation/turnGraph.js +36 -0
  37. package/dist/presentation/turnGraph.js.map +1 -0
  38. package/dist/proxy/ProxySessionController.js +425 -104
  39. package/dist/proxy/ProxySessionController.js.map +1 -1
  40. package/dist/runtime/AcpRuntimeAdapter.js +3 -0
  41. package/dist/runtime/AcpRuntimeAdapter.js.map +1 -1
  42. package/dist/runtime/agentEnvironment.js +24 -4
  43. package/dist/runtime/agentEnvironment.js.map +1 -1
  44. package/dist/runtime/createAgentRuntimeRegistry.js +7 -3
  45. package/dist/runtime/createAgentRuntimeRegistry.js.map +1 -1
  46. package/dist/startup/StartupNotifier.js +16 -7
  47. package/dist/startup/StartupNotifier.js.map +1 -1
  48. package/dist/startup/startFeishu.js +2 -2
  49. package/dist/startup/startFeishu.js.map +1 -1
  50. package/dist/state/StateStore.js +172 -0
  51. package/dist/state/StateStore.js.map +1 -1
  52. package/dist/state/migrations.js +21 -0
  53. package/dist/state/migrations.js.map +1 -1
  54. package/dist/supervision/SafeRestartNotifier.js +46 -3
  55. package/dist/supervision/SafeRestartNotifier.js.map +1 -1
  56. package/dist/supervision/SafeRestartScheduler.js +65 -6
  57. package/dist/supervision/SafeRestartScheduler.js.map +1 -1
  58. package/dist/supervision/replacementSupervisor.js +27 -2
  59. package/dist/supervision/replacementSupervisor.js.map +1 -1
  60. package/dist/supervisor.js +27 -2
  61. package/dist/supervisor.js.map +1 -1
  62. package/docs/technical-reference.md +20 -7
  63. package/docs/technical-reference.zh.md +20 -7
  64. package/npm-shrinkwrap.json +2 -2
  65. package/package.json +1 -1
  66. package/skills/agent-bot/SKILL.md +63 -26
  67. package/src/acp/AcpProcessManager.ts +16 -3
  68. package/src/acp/AcpSessionManager.ts +14 -0
  69. package/src/cli/ServerStarter.ts +25 -2
  70. package/src/cli/controlProtocol.ts +6 -1
  71. package/src/cli/help.ts +16 -4
  72. package/src/cli/serverStatus.ts +28 -0
  73. package/src/cli/taskListOutput.ts +91 -0
  74. package/src/cli.ts +123 -51
  75. package/src/codex/CodexProcessManager.ts +38 -3
  76. package/src/codex/CodexRuntime.ts +6 -0
  77. package/src/commands/CommandRouter.ts +4 -0
  78. package/src/commands/commandTypes.ts +1 -0
  79. package/src/config/FeishuUserOpenIdStore.ts +81 -0
  80. package/src/config/loadConfig.ts +3 -0
  81. package/src/feishu/CardRenderer.ts +169 -22
  82. package/src/feishu/FeishuConnector.ts +13 -4
  83. package/src/index.ts +195 -23
  84. package/src/presentation/turnGraph.ts +48 -0
  85. package/src/proxy/ProxySessionController.ts +507 -112
  86. package/src/runtime/AcpRuntimeAdapter.ts +5 -0
  87. package/src/runtime/agentEnvironment.ts +41 -3
  88. package/src/runtime/createAgentRuntimeRegistry.ts +18 -3
  89. package/src/runtime/types.ts +5 -0
  90. package/src/startup/StartupNotifier.ts +23 -7
  91. package/src/startup/startFeishu.ts +3 -2
  92. package/src/state/StateStore.ts +224 -0
  93. package/src/state/migrations.ts +21 -0
  94. package/src/supervision/SafeRestartNotifier.ts +53 -4
  95. package/src/supervision/SafeRestartScheduler.ts +78 -7
  96. package/src/supervision/replacementSupervisor.ts +27 -1
  97. package/src/supervisor.ts +33 -2
@@ -3,6 +3,7 @@ import { AcpSessionManager } from "../acp/AcpSessionManager.js";
3
3
  import { createId } from "../utils/id.js";
4
4
  import type {
5
5
  AgentRuntime,
6
+ AgentProcessInfo,
6
7
  ApprovalDecision,
7
8
  CreateRuntimeSessionInput,
8
9
  ModelOption,
@@ -37,6 +38,10 @@ export class AcpRuntimeAdapter implements AgentRuntime {
37
38
  return this.sessions.get(localSessionId);
38
39
  }
39
40
 
41
+ getProcessInfo(): AgentProcessInfo {
42
+ return this.acp.getProcessInfo();
43
+ }
44
+
40
45
  async createSession(input: CreateRuntimeSessionInput): Promise<RuntimeSession> {
41
46
  const created = await this.acp.create({
42
47
  localSessionId: input.localSessionId,
@@ -2,13 +2,51 @@ export const AGENT_BOT_ENVIRONMENT = Object.freeze({
2
2
  AGENT_BOT: "1",
3
3
  });
4
4
 
5
+ export interface AgentEnvironmentContext {
6
+ profilePath?: string;
7
+ configPath?: string;
8
+ agentName?: string;
9
+ larkAppId?: string;
10
+ larkBotOpenId?: string;
11
+ larkUserOpenId?: string;
12
+ }
13
+
14
+ const RESERVED_ENVIRONMENT_PREFIXES = ["AGENT_BOT_", "FEISHU_"];
15
+
5
16
  export function agentBotEnvironment(
6
17
  baseEnv: NodeJS.ProcessEnv,
7
18
  agentEnv: Record<string, string>,
19
+ context: AgentEnvironmentContext = {},
8
20
  ): NodeJS.ProcessEnv {
9
- return {
10
- ...baseEnv,
11
- ...agentEnv,
21
+ const environment: NodeJS.ProcessEnv = {
22
+ ...withoutReservedEnvironment(baseEnv),
23
+ ...withoutReservedEnvironment(agentEnv),
12
24
  ...AGENT_BOT_ENVIRONMENT,
13
25
  };
26
+ setNonBlank(environment, "AGENT_BOT_HOME", context.profilePath);
27
+ setNonBlank(environment, "AGENT_BOT_CONFIG", context.configPath);
28
+ setNonBlank(environment, "AGENT_BOT_AGENT_NAME", context.agentName);
29
+ setNonBlank(environment, "AGENT_BOT_LARK_APP_ID", context.larkAppId);
30
+ setNonBlank(environment, "AGENT_BOT_LARK_BOT_OPEN_ID", context.larkBotOpenId);
31
+ setNonBlank(environment, "AGENT_BOT_LARK_USER_OPEN_ID", context.larkUserOpenId);
32
+ return environment;
33
+ }
34
+
35
+ function withoutReservedEnvironment(
36
+ environment: NodeJS.ProcessEnv | Record<string, string>,
37
+ ): NodeJS.ProcessEnv {
38
+ return Object.fromEntries(
39
+ Object.entries(environment).filter(([name, value]) =>
40
+ value !== undefined && !isReservedEnvironmentName(name)),
41
+ );
42
+ }
43
+
44
+ function isReservedEnvironmentName(name: string): boolean {
45
+ const normalized = name.toUpperCase();
46
+ return normalized === "AGENT_BOT"
47
+ || RESERVED_ENVIRONMENT_PREFIXES.some((prefix) => normalized.startsWith(prefix));
48
+ }
49
+
50
+ function setNonBlank(environment: NodeJS.ProcessEnv, name: string, value: string | undefined): void {
51
+ if (value?.trim()) environment[name] = value;
14
52
  }
@@ -5,19 +5,34 @@ import { CodexProcessManager } from "../codex/CodexProcessManager.js";
5
5
  import { CodexRuntime } from "../codex/CodexRuntime.js";
6
6
  import type { AppConfig } from "../config/schema.js";
7
7
  import { AcpRuntimeAdapter } from "./AcpRuntimeAdapter.js";
8
+ import type { AgentEnvironmentContext } from "./agentEnvironment.js";
8
9
  import { AgentRuntimeRegistry } from "./AgentRuntimeRegistry.js";
9
10
  import type { AgentRuntime } from "./types.js";
10
11
 
11
- export function createAgentRuntimeRegistry(config: AppConfig, logger: Logger): AgentRuntimeRegistry {
12
+ export function createAgentRuntimeRegistry(
13
+ config: AppConfig,
14
+ logger: Logger,
15
+ environmentContext: AgentEnvironmentContext = {},
16
+ ): AgentRuntimeRegistry {
12
17
  const runtimes: Record<string, AgentRuntime> = {};
13
18
  for (const [agentName, agent] of Object.entries(config.agents)) {
14
19
  const runtimeLogger = logger.child({ agentName, runtimeKind: agent.kind });
20
+ const runtimeEnvironmentContext = (): AgentEnvironmentContext => ({
21
+ ...environmentContext,
22
+ agentName,
23
+ });
15
24
  if (agent.kind === "app-server") {
16
- const processManager = new CodexProcessManager(agent.command, agent.args, agent.env, runtimeLogger);
25
+ const processManager = new CodexProcessManager(
26
+ agent.command,
27
+ agent.args,
28
+ agent.env,
29
+ runtimeLogger,
30
+ runtimeEnvironmentContext,
31
+ );
17
32
  runtimes[agentName] = new CodexRuntime(processManager, runtimeLogger);
18
33
  continue;
19
34
  }
20
- const processManager = new AcpProcessManager(runtimeLogger);
35
+ const processManager = new AcpProcessManager(runtimeLogger, runtimeEnvironmentContext);
21
36
  const sessionManager = new AcpSessionManager(agentName, agent, processManager, runtimeLogger);
22
37
  runtimes[agentName] = new AcpRuntimeAdapter(sessionManager);
23
38
  }
@@ -1,4 +1,8 @@
1
1
  export type RuntimeKind = "acp" | "codex";
2
+ export interface AgentProcessInfo {
3
+ pid?: number;
4
+ version?: string;
5
+ }
2
6
  export type PermissionMode = "auto" | "confirm";
3
7
  export type ApprovalDecision = "accept" | "acceptForSession" | "decline" | "cancel";
4
8
  export type ToolStatus = "running" | "completed" | "failed";
@@ -203,6 +207,7 @@ export interface ReasoningEffortOption {
203
207
 
204
208
  export interface AgentRuntime {
205
209
  readonly kind: RuntimeKind;
210
+ getProcessInfo?(): AgentProcessInfo;
206
211
  createSession(input: CreateRuntimeSessionInput): Promise<RuntimeSession>;
207
212
  resumeSession(input: ResumeRuntimeSessionInput): Promise<RuntimeSession>;
208
213
  forkSession?(input: ForkRuntimeSessionInput): Promise<RuntimeSession>;
@@ -1,6 +1,7 @@
1
1
  import type { Logger } from "pino";
2
2
  import { detectProjectlessWorkspace } from "../codex/ProjectlessWorkspace.js";
3
3
  import { CardRenderer } from "../feishu/CardRenderer.js";
4
+ import { baseChatContextKey, isThreadContextKey } from "../feishu/contextKey.js";
4
5
  import type { FeishuOutbound } from "../feishu/types.js";
5
6
  import type { SessionRecord, StateStore, UserContextRecord } from "../state/StateStore.js";
6
7
 
@@ -32,15 +33,30 @@ export class StartupNotifier {
32
33
  private readonly metadataHydrator?: StartupTaskMetadataHydrator,
33
34
  ) {}
34
35
 
35
- async notify(startedAt: Date, restartReason: string): Promise<void> {
36
+ async notify(
37
+ startedAt: Date,
38
+ restartReason: string,
39
+ restartGroupContextKeys: string[] = [],
40
+ ): Promise<void> {
36
41
  let targets: StartupNotificationTarget[];
37
42
  try {
38
- const activeGroupSince = new Date(startedAt.getTime() - 3 * 60 * 1_000);
39
- const chats = [
40
- ...this.store.listChatContexts("p2p"),
41
- ...this.store.listRecentlyActiveChatContexts(activeGroupSince)
42
- .filter((chat) => chat.chatType === "group"),
43
- ];
43
+ const activeGroupSince = new Date(startedAt.getTime() - 60_000);
44
+ const knownGroups = new Map(
45
+ this.store.listChatContexts("group")
46
+ .filter((chat) => !isThreadContextKey(chat.contextKey))
47
+ .map((chat) => [chat.contextKey, chat]),
48
+ );
49
+ const targetGroups = new Map(
50
+ this.store.listRecentlyActiveChatContexts(activeGroupSince)
51
+ .filter((chat) => chat.chatType === "group" && !isThreadContextKey(chat.contextKey))
52
+ .map((chat) => [chat.contextKey, chat]),
53
+ );
54
+ for (const contextKey of restartGroupContextKeys) {
55
+ const baseContextKey = baseChatContextKey(contextKey.trim());
56
+ const group = knownGroups.get(baseContextKey);
57
+ if (group) targetGroups.set(baseContextKey, group);
58
+ }
59
+ const chats = [...this.store.listChatContexts("p2p"), ...targetGroups.values()];
44
60
  targets = chats.map((chat) => ({
45
61
  contextKey: chat.contextKey,
46
62
  context: this.store.getUserContext(chat.contextKey),
@@ -3,7 +3,7 @@ export interface StartableFeishuConnector {
3
3
  }
4
4
 
5
5
  export interface StartupNotificationSender {
6
- notify(startedAt: Date, restartReason: string): Promise<void>;
6
+ notify(startedAt: Date, restartReason: string, restartGroupContextKeys?: string[]): Promise<void>;
7
7
  }
8
8
 
9
9
  export async function startFeishu(
@@ -13,9 +13,10 @@ export async function startFeishu(
13
13
  restartReason: string,
14
14
  prepare?: () => Promise<void>,
15
15
  onConnected?: () => void,
16
+ restartGroupContextKeys: string[] = [],
16
17
  ): Promise<void> {
17
18
  await prepare?.();
18
19
  await connector.start();
19
- await notifier.notify(startedAt, restartReason);
20
+ await notifier.notify(startedAt, restartReason, restartGroupContextKeys);
20
21
  onConnected?.();
21
22
  }
@@ -63,6 +63,21 @@ export interface TurnAnchorRecord {
63
63
  contextKey?: string;
64
64
  }
65
65
 
66
+ export interface TurnRuntimeOriginRecord {
67
+ turnId: string;
68
+ localSessionId: string;
69
+ agentName: string;
70
+ remoteSessionId: string;
71
+ createdAt: string;
72
+ }
73
+
74
+ export interface CompletedTurnSnapshotRecord {
75
+ turnId: string;
76
+ parentTurnId?: string;
77
+ snapshot: unknown;
78
+ updatedAt: string;
79
+ }
80
+
66
81
  export interface QueuedPromptRecord {
67
82
  promptId: string;
68
83
  localSessionId: string;
@@ -605,6 +620,54 @@ export class StateStore {
605
620
  return row ? JSON.parse(row.snapshot_json) : undefined;
606
621
  }
607
622
 
623
+ saveTurnRuntimeOrigin(
624
+ turnId: string,
625
+ localSessionId: string,
626
+ agentName: string,
627
+ remoteSessionId: string,
628
+ ): void {
629
+ this.db.prepare(`
630
+ INSERT OR IGNORE INTO turn_runtime_origins (
631
+ turn_id, local_session_id, agent_name, remote_session_id, created_at
632
+ ) VALUES (?, ?, ?, ?, ?)
633
+ `).run(turnId, localSessionId, agentName, remoteSessionId, new Date().toISOString());
634
+ }
635
+
636
+ getTurnRuntimeOrigin(turnId: string): TurnRuntimeOriginRecord | undefined {
637
+ const row = this.db.prepare(`
638
+ SELECT turn_id, local_session_id, agent_name, remote_session_id, created_at
639
+ FROM turn_runtime_origins
640
+ WHERE turn_id = ?
641
+ `).get(turnId) as {
642
+ turn_id: string;
643
+ local_session_id: string;
644
+ agent_name: string;
645
+ remote_session_id: string;
646
+ created_at: string;
647
+ } | undefined;
648
+ return row
649
+ ? {
650
+ turnId: row.turn_id,
651
+ localSessionId: row.local_session_id,
652
+ agentName: row.agent_name,
653
+ remoteSessionId: row.remote_session_id,
654
+ createdAt: row.created_at,
655
+ }
656
+ : undefined;
657
+ }
658
+
659
+ saveTurnParent(turnId: string, localSessionId: string, parentTurnId?: string): void {
660
+ this.db.prepare(`
661
+ INSERT INTO turn_parent_links (
662
+ turn_id, local_session_id, parent_turn_id, created_at
663
+ ) VALUES (?, ?, ?, ?)
664
+ ON CONFLICT(turn_id) DO UPDATE SET
665
+ parent_turn_id = excluded.parent_turn_id
666
+ WHERE turn_parent_links.parent_turn_id IS NULL
667
+ AND excluded.parent_turn_id IS NOT NULL
668
+ `).run(turnId, localSessionId, parentTurnId ?? null, new Date().toISOString());
669
+ }
670
+
608
671
  findLatestCompletedTurnId(localSessionId: string, contextKey: string): string | undefined {
609
672
  const row = this.db
610
673
  .prepare(`
@@ -626,6 +689,142 @@ export class StateStore {
626
689
  return row?.turn_id;
627
690
  }
628
691
 
692
+ countCompletedTurnSnapshots(localSessionId: string, contextKey: string): number {
693
+ const row = this.db.prepare(`
694
+ SELECT count(*) AS count
695
+ FROM turn_snapshots
696
+ WHERE local_session_id = ?
697
+ AND context_key = ?
698
+ AND json_extract(snapshot_json, '$.status') = 'completed'
699
+ `).get(localSessionId, contextKey) as { count: number };
700
+ return row.count;
701
+ }
702
+
703
+ listCompletedTurnSnapshots(
704
+ localSessionId: string,
705
+ contextKey: string,
706
+ limit: number,
707
+ offset = 0,
708
+ ): CompletedTurnSnapshotRecord[] {
709
+ const normalizedLimit = Math.max(1, Math.min(100, Math.trunc(limit)));
710
+ const normalizedOffset = Math.max(0, Math.trunc(offset));
711
+ const rows = this.db.prepare(`
712
+ SELECT turn_id, snapshot_json, updated_at
713
+ FROM turn_snapshots
714
+ WHERE local_session_id = ?
715
+ AND context_key = ?
716
+ AND json_extract(snapshot_json, '$.status') = 'completed'
717
+ ORDER BY
718
+ coalesce(
719
+ json_extract(snapshot_json, '$.completedAt'),
720
+ json_extract(snapshot_json, '$.startedAt'),
721
+ 0
722
+ ) DESC,
723
+ updated_at DESC
724
+ LIMIT ? OFFSET ?
725
+ `).all(localSessionId, contextKey, normalizedLimit, normalizedOffset) as Array<{
726
+ turn_id: string;
727
+ snapshot_json: string;
728
+ updated_at: string;
729
+ }>;
730
+ return rows.map((row) => ({
731
+ turnId: row.turn_id,
732
+ snapshot: JSON.parse(row.snapshot_json),
733
+ updatedAt: row.updated_at,
734
+ }));
735
+ }
736
+
737
+ listCompletedTurnGraph(
738
+ localSessionId: string,
739
+ contextKey: string,
740
+ ): CompletedTurnSnapshotRecord[] {
741
+ this.backfillTurnParents(localSessionId);
742
+ const rows = this.db.prepare(`
743
+ SELECT ts.turn_id, tpl.parent_turn_id, ts.snapshot_json, ts.updated_at
744
+ FROM turn_snapshots ts
745
+ LEFT JOIN turn_parent_links tpl ON tpl.turn_id = ts.turn_id
746
+ WHERE ts.local_session_id = ?
747
+ AND ts.context_key = ?
748
+ AND json_extract(ts.snapshot_json, '$.status') = 'completed'
749
+ ORDER BY
750
+ coalesce(
751
+ json_extract(ts.snapshot_json, '$.completedAt'),
752
+ json_extract(ts.snapshot_json, '$.startedAt'),
753
+ 0
754
+ ) DESC,
755
+ ts.updated_at DESC
756
+ `).all(localSessionId, contextKey) as Array<{
757
+ turn_id: string;
758
+ parent_turn_id: string | null;
759
+ snapshot_json: string;
760
+ updated_at: string;
761
+ }>;
762
+ return rows.map((row) => ({
763
+ turnId: row.turn_id,
764
+ parentTurnId: row.parent_turn_id ?? undefined,
765
+ snapshot: JSON.parse(row.snapshot_json),
766
+ updatedAt: row.updated_at,
767
+ }));
768
+ }
769
+
770
+ private backfillTurnParents(localSessionId: string): void {
771
+ const turns = this.db.prepare(`
772
+ SELECT
773
+ turn_id,
774
+ coalesce(
775
+ json_extract(snapshot_json, '$.startedAt'),
776
+ json_extract(snapshot_json, '$.completedAt'),
777
+ cast(strftime('%s', updated_at) AS INTEGER) * 1000,
778
+ 0
779
+ ) AS started_at
780
+ FROM turn_snapshots
781
+ WHERE local_session_id = ?
782
+ AND json_extract(snapshot_json, '$.status') = 'completed'
783
+ ORDER BY started_at ASC, updated_at ASC
784
+ `).all(localSessionId) as Array<{ turn_id: string; started_at: number }>;
785
+ if (turns.length === 0) return;
786
+
787
+ const resets = (this.db.prepare(`
788
+ SELECT payload_json, created_at
789
+ FROM audit_events
790
+ WHERE event_type = 'session_reset_to_turn'
791
+ AND json_extract(payload_json, '$.localSessionId') = ?
792
+ ORDER BY created_at ASC, id ASC
793
+ `).all(localSessionId) as Array<{ payload_json: string; created_at: string }>).flatMap((row) => {
794
+ try {
795
+ const payload = JSON.parse(row.payload_json) as { resetTurnId?: unknown };
796
+ const resetTurnId = typeof payload.resetTurnId === "string" ? payload.resetTurnId : undefined;
797
+ const at = Date.parse(row.created_at);
798
+ return resetTurnId && Number.isFinite(at) ? [{ resetTurnId, at }] : [];
799
+ } catch {
800
+ return [];
801
+ }
802
+ });
803
+
804
+ let headTurnId: string | undefined;
805
+ let resetIndex = 0;
806
+ const upsert = this.db.prepare(`
807
+ INSERT INTO turn_parent_links (
808
+ turn_id, local_session_id, parent_turn_id, created_at
809
+ ) VALUES (?, ?, ?, ?)
810
+ ON CONFLICT(turn_id) DO UPDATE SET
811
+ parent_turn_id = excluded.parent_turn_id
812
+ WHERE turn_parent_links.parent_turn_id IS NULL
813
+ AND excluded.parent_turn_id IS NOT NULL
814
+ `);
815
+ const backfill = this.db.transaction(() => {
816
+ for (const turn of turns) {
817
+ while (resetIndex < resets.length && resets[resetIndex]!.at <= turn.started_at) {
818
+ headTurnId = resets[resetIndex]!.resetTurnId;
819
+ resetIndex += 1;
820
+ }
821
+ upsert.run(turn.turn_id, localSessionId, headTurnId ?? null, new Date().toISOString());
822
+ headTurnId = turn.turn_id;
823
+ }
824
+ });
825
+ backfill();
826
+ }
827
+
629
828
  saveTurnDelivery(
630
829
  turnId: string,
631
830
  patch: { progressMessageId?: string; lastCardHash?: string },
@@ -784,6 +983,31 @@ export class StateStore {
784
983
  return rows.map(mapSession);
785
984
  }
786
985
 
986
+ findLatestMessageIdForSession(localSessionId: string, contextKey?: string): string | undefined {
987
+ const row = this.db.prepare(`
988
+ SELECT message_id
989
+ FROM message_reactions
990
+ WHERE local_session_id = ?
991
+ AND (? IS NULL OR context_key = ?)
992
+ ORDER BY created_at DESC, rowid DESC
993
+ LIMIT 1
994
+ `).get(localSessionId, contextKey ?? null, contextKey ?? null) as { message_id: string } | undefined;
995
+ return row?.message_id;
996
+ }
997
+
998
+ findLatestMessageIdForContext(contextKey: string): string | undefined {
999
+ const row = this.db.prepare(`
1000
+ SELECT json_extract(payload_json, '$.messageId') AS message_id
1001
+ FROM audit_events
1002
+ WHERE context_key = ?
1003
+ AND event_type = 'incoming_message'
1004
+ AND json_type(payload_json, '$.messageId') = 'text'
1005
+ ORDER BY id DESC
1006
+ LIMIT 1
1007
+ `).get(contextKey) as { message_id: string } | undefined;
1008
+ return row?.message_id;
1009
+ }
1010
+
787
1011
  getServerActivityState(): {
788
1012
  runningSessions: number;
789
1013
  pendingFinalDeliveries: number;
@@ -125,4 +125,25 @@ export const migrations = [
125
125
  CREATE INDEX IF NOT EXISTS idx_queued_prompts_session_order
126
126
  ON queued_prompts(local_session_id, created_at);
127
127
  `,
128
+ `
129
+ CREATE TABLE IF NOT EXISTS turn_runtime_origins (
130
+ turn_id TEXT PRIMARY KEY,
131
+ local_session_id TEXT NOT NULL,
132
+ agent_name TEXT NOT NULL,
133
+ remote_session_id TEXT NOT NULL,
134
+ created_at TEXT NOT NULL
135
+ );
136
+ CREATE INDEX IF NOT EXISTS idx_turn_runtime_origins_session
137
+ ON turn_runtime_origins(local_session_id);
138
+ `,
139
+ `
140
+ CREATE TABLE IF NOT EXISTS turn_parent_links (
141
+ turn_id TEXT PRIMARY KEY,
142
+ local_session_id TEXT NOT NULL,
143
+ parent_turn_id TEXT,
144
+ created_at TEXT NOT NULL
145
+ );
146
+ CREATE INDEX IF NOT EXISTS idx_turn_parent_links_session
147
+ ON turn_parent_links(local_session_id);
148
+ `,
128
149
  ] as const;
@@ -1,8 +1,9 @@
1
1
  import type { Logger } from "pino";
2
2
  import { CardRenderer } from "../feishu/CardRenderer.js";
3
+ import { isThreadContextKey } from "../feishu/contextKey.js";
3
4
  import type { FeishuOutbound } from "../feishu/types.js";
4
5
  import type { StateStore } from "../state/StateStore.js";
5
- import type { SafeRestartStatus } from "./SafeRestartScheduler.js";
6
+ import type { RestartNotificationTarget, SafeRestartStatus } from "./SafeRestartScheduler.js";
6
7
 
7
8
  const DEFAULT_INITIAL_CARD_DELAY_MS = 3_000;
8
9
 
@@ -13,6 +14,7 @@ export interface SafeRestartNotifierOptions {
13
14
  export class SafeRestartNotifier {
14
15
  private readonly messageIds = new Map<string, string>();
15
16
  private readonly cardHashes = new Map<string, string>();
17
+ private readonly notificationTargets = new Map<string, RestartNotificationTarget>();
16
18
  private activeScheduleId?: number;
17
19
  private terminalScheduleId?: number;
18
20
  private pendingInitialStatus?: SafeRestartStatus;
@@ -41,8 +43,10 @@ export class SafeRestartNotifier {
41
43
  this.activeScheduleId = status.scheduleId;
42
44
  this.messageIds.clear();
43
45
  this.cardHashes.clear();
46
+ this.notificationTargets.clear();
44
47
  this.clearInitialCardTimer();
45
48
  this.pendingInitialStatus = status;
49
+ this.collectNotificationTargets(status);
46
50
  const delayMs = status.phase === "cancelled"
47
51
  ? 0
48
52
  : this.options.initialCardDelayMs ?? DEFAULT_INITIAL_CARD_DELAY_MS;
@@ -55,6 +59,7 @@ export class SafeRestartNotifier {
55
59
  }, delayMs);
56
60
  return Promise.resolve();
57
61
  }
62
+ this.collectNotificationTargets(status);
58
63
  if (this.pendingInitialStatus) {
59
64
  this.pendingInitialStatus = status;
60
65
  return Promise.resolve();
@@ -67,6 +72,10 @@ export class SafeRestartNotifier {
67
72
  await this.queue;
68
73
  }
69
74
 
75
+ getNotificationTargets(): RestartNotificationTarget[] {
76
+ return [...this.notificationTargets.values()].map((target) => ({ ...target }));
77
+ }
78
+
70
79
  private publishPendingInitialStatus(): Promise<void> {
71
80
  this.clearInitialCardTimer();
72
81
  const status = this.pendingInitialStatus;
@@ -89,7 +98,8 @@ export class SafeRestartNotifier {
89
98
  }
90
99
 
91
100
  private async publish(status: SafeRestartStatus): Promise<void> {
92
- const targets = this.store.listChatContexts("p2p");
101
+ this.collectNotificationTargets(status);
102
+ const targets = this.getNotificationTargets();
93
103
  if (targets.length === 0) return;
94
104
  const waitingTasks = status.phase === "cancelled"
95
105
  ? []
@@ -109,7 +119,7 @@ export class SafeRestartNotifier {
109
119
  });
110
120
  const cardHash = JSON.stringify(card);
111
121
 
112
- await Promise.all(targets.map(async ({ contextKey }) => {
122
+ await Promise.all(targets.map(async ({ contextKey, replyMessageId }) => {
113
123
  const messageId = this.messageIds.get(contextKey);
114
124
  if (messageId) {
115
125
  if (this.cardHashes.get(contextKey) === cardHash) return;
@@ -124,7 +134,22 @@ export class SafeRestartNotifier {
124
134
  }
125
135
  }
126
136
  try {
127
- const created = await this.outbound.sendInteractiveCard(contextKey, card);
137
+ let created: string | undefined;
138
+ if (isThreadContextKey(contextKey) && !replyMessageId) {
139
+ throw new Error("The safe-restart topic target has no reply message anchor.");
140
+ }
141
+ if (replyMessageId) {
142
+ if (!this.outbound.replyInteractiveCard) {
143
+ throw new Error("The outbound transport cannot preserve the requesting thread.");
144
+ }
145
+ created = await this.outbound.replyInteractiveCard(
146
+ contextKey,
147
+ { messageId: replyMessageId, replyInThread: true },
148
+ card,
149
+ );
150
+ } else {
151
+ created = await this.outbound.sendInteractiveCard(contextKey, card);
152
+ }
128
153
  if (created && this.activeScheduleId === status.scheduleId) {
129
154
  this.messageIds.set(contextKey, created);
130
155
  this.cardHashes.set(contextKey, cardHash);
@@ -134,4 +159,28 @@ export class SafeRestartNotifier {
134
159
  }
135
160
  }));
136
161
  }
162
+
163
+ private collectNotificationTargets(status: SafeRestartStatus): void {
164
+ const requestedTargets: RestartNotificationTarget[] = status.notificationTargets
165
+ ?? this.store.listChatContexts("p2p").map(({ contextKey }) => ({ contextKey }));
166
+ const recentTargets: RestartNotificationTarget[] = this.store.listRecentlyActiveChatContexts(
167
+ new Date(Date.now() - 60_000),
168
+ )
169
+ .filter((chat) => !isThreadContextKey(chat.contextKey))
170
+ .map(({ contextKey }) => ({ contextKey }));
171
+ for (const target of [
172
+ ...requestedTargets,
173
+ ...recentTargets,
174
+ ]) {
175
+ const contextKey = target.contextKey.trim();
176
+ if (!contextKey) continue;
177
+ const existing = this.notificationTargets.get(contextKey);
178
+ if (!existing || (!existing.replyMessageId && target.replyMessageId)) {
179
+ this.notificationTargets.set(contextKey, {
180
+ contextKey,
181
+ ...(target.replyMessageId?.trim() ? { replyMessageId: target.replyMessageId.trim() } : {}),
182
+ });
183
+ }
184
+ }
185
+ }
137
186
  }