@keyou007/agent-bot 0.1.13-alpha.0 → 0.1.13-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 (66) hide show
  1. package/.env.example +2 -2
  2. package/CHANGELOG.md +9 -0
  3. package/README.md +10 -4
  4. package/README.zh.md +10 -4
  5. package/dist/cli/ServerStarter.js +17 -2
  6. package/dist/cli/ServerStarter.js.map +1 -1
  7. package/dist/cli/controlProtocol.js.map +1 -1
  8. package/dist/cli/help.js +12 -2
  9. package/dist/cli/help.js.map +1 -1
  10. package/dist/cli.js +39 -0
  11. package/dist/cli.js.map +1 -1
  12. package/dist/commands/CommandRouter.js +5 -0
  13. package/dist/commands/CommandRouter.js.map +1 -1
  14. package/dist/config/FeishuUserOpenIdStore.js +76 -0
  15. package/dist/config/FeishuUserOpenIdStore.js.map +1 -0
  16. package/dist/config/loadConfig.js +3 -0
  17. package/dist/config/loadConfig.js.map +1 -1
  18. package/dist/feishu/CardRenderer.js +82 -22
  19. package/dist/feishu/CardRenderer.js.map +1 -1
  20. package/dist/index.js +103 -23
  21. package/dist/index.js.map +1 -1
  22. package/dist/presentation/turnGraph.js +36 -0
  23. package/dist/presentation/turnGraph.js.map +1 -0
  24. package/dist/proxy/ProxySessionController.js +270 -47
  25. package/dist/proxy/ProxySessionController.js.map +1 -1
  26. package/dist/startup/StartupNotifier.js +16 -7
  27. package/dist/startup/StartupNotifier.js.map +1 -1
  28. package/dist/startup/startFeishu.js +2 -2
  29. package/dist/startup/startFeishu.js.map +1 -1
  30. package/dist/state/StateStore.js +169 -0
  31. package/dist/state/StateStore.js.map +1 -1
  32. package/dist/state/migrations.js +21 -0
  33. package/dist/state/migrations.js.map +1 -1
  34. package/dist/supervision/SafeRestartNotifier.js +46 -3
  35. package/dist/supervision/SafeRestartNotifier.js.map +1 -1
  36. package/dist/supervision/SafeRestartScheduler.js +65 -6
  37. package/dist/supervision/SafeRestartScheduler.js.map +1 -1
  38. package/dist/supervision/replacementSupervisor.js +27 -2
  39. package/dist/supervision/replacementSupervisor.js.map +1 -1
  40. package/dist/supervisor.js +27 -2
  41. package/dist/supervisor.js.map +1 -1
  42. package/docs/technical-reference.md +11 -4
  43. package/docs/technical-reference.zh.md +11 -4
  44. package/npm-shrinkwrap.json +2 -2
  45. package/package.json +1 -1
  46. package/skills/agent-bot/SKILL.md +10 -4
  47. package/src/cli/ServerStarter.ts +25 -2
  48. package/src/cli/controlProtocol.ts +6 -1
  49. package/src/cli/help.ts +12 -2
  50. package/src/cli.ts +57 -0
  51. package/src/commands/CommandRouter.ts +4 -0
  52. package/src/commands/commandTypes.ts +1 -0
  53. package/src/config/FeishuUserOpenIdStore.ts +81 -0
  54. package/src/config/loadConfig.ts +3 -0
  55. package/src/feishu/CardRenderer.ts +109 -22
  56. package/src/index.ts +161 -21
  57. package/src/presentation/turnGraph.ts +48 -0
  58. package/src/proxy/ProxySessionController.ts +323 -55
  59. package/src/startup/StartupNotifier.ts +23 -7
  60. package/src/startup/startFeishu.ts +3 -2
  61. package/src/state/StateStore.ts +221 -0
  62. package/src/state/migrations.ts +21 -0
  63. package/src/supervision/SafeRestartNotifier.ts +53 -4
  64. package/src/supervision/SafeRestartScheduler.ts +78 -7
  65. package/src/supervision/replacementSupervisor.ts +27 -1
  66. package/src/supervisor.ts +33 -2
@@ -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,50 @@ 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 OR IGNORE INTO turn_parent_links (
662
+ turn_id, local_session_id, parent_turn_id, created_at
663
+ ) VALUES (?, ?, ?, ?)
664
+ `).run(turnId, localSessionId, parentTurnId ?? null, new Date().toISOString());
665
+ }
666
+
608
667
  findLatestCompletedTurnId(localSessionId: string, contextKey: string): string | undefined {
609
668
  const row = this.db
610
669
  .prepare(`
@@ -626,6 +685,143 @@ export class StateStore {
626
685
  return row?.turn_id;
627
686
  }
628
687
 
688
+ countCompletedTurnSnapshots(localSessionId: string, contextKey: string): number {
689
+ const row = this.db.prepare(`
690
+ SELECT count(*) AS count
691
+ FROM turn_snapshots
692
+ WHERE local_session_id = ?
693
+ AND context_key = ?
694
+ AND json_extract(snapshot_json, '$.status') = 'completed'
695
+ `).get(localSessionId, contextKey) as { count: number };
696
+ return row.count;
697
+ }
698
+
699
+ listCompletedTurnSnapshots(
700
+ localSessionId: string,
701
+ contextKey: string,
702
+ limit: number,
703
+ offset = 0,
704
+ ): CompletedTurnSnapshotRecord[] {
705
+ const normalizedLimit = Math.max(1, Math.min(100, Math.trunc(limit)));
706
+ const normalizedOffset = Math.max(0, Math.trunc(offset));
707
+ const rows = this.db.prepare(`
708
+ SELECT turn_id, snapshot_json, updated_at
709
+ FROM turn_snapshots
710
+ WHERE local_session_id = ?
711
+ AND context_key = ?
712
+ AND json_extract(snapshot_json, '$.status') = 'completed'
713
+ ORDER BY
714
+ coalesce(
715
+ json_extract(snapshot_json, '$.completedAt'),
716
+ json_extract(snapshot_json, '$.startedAt'),
717
+ 0
718
+ ) DESC,
719
+ updated_at DESC
720
+ LIMIT ? OFFSET ?
721
+ `).all(localSessionId, contextKey, normalizedLimit, normalizedOffset) as Array<{
722
+ turn_id: string;
723
+ snapshot_json: string;
724
+ updated_at: string;
725
+ }>;
726
+ return rows.map((row) => ({
727
+ turnId: row.turn_id,
728
+ snapshot: JSON.parse(row.snapshot_json),
729
+ updatedAt: row.updated_at,
730
+ }));
731
+ }
732
+
733
+ listCompletedTurnGraph(
734
+ localSessionId: string,
735
+ contextKey: string,
736
+ ): CompletedTurnSnapshotRecord[] {
737
+ this.backfillTurnParents(localSessionId);
738
+ const rows = this.db.prepare(`
739
+ SELECT ts.turn_id, tpl.parent_turn_id, ts.snapshot_json, ts.updated_at
740
+ FROM turn_snapshots ts
741
+ LEFT JOIN turn_parent_links tpl ON tpl.turn_id = ts.turn_id
742
+ WHERE ts.local_session_id = ?
743
+ AND ts.context_key = ?
744
+ AND json_extract(ts.snapshot_json, '$.status') = 'completed'
745
+ ORDER BY
746
+ coalesce(
747
+ json_extract(ts.snapshot_json, '$.completedAt'),
748
+ json_extract(ts.snapshot_json, '$.startedAt'),
749
+ 0
750
+ ) DESC,
751
+ ts.updated_at DESC
752
+ `).all(localSessionId, contextKey) as Array<{
753
+ turn_id: string;
754
+ parent_turn_id: string | null;
755
+ snapshot_json: string;
756
+ updated_at: string;
757
+ }>;
758
+ return rows.map((row) => ({
759
+ turnId: row.turn_id,
760
+ parentTurnId: row.parent_turn_id ?? undefined,
761
+ snapshot: JSON.parse(row.snapshot_json),
762
+ updatedAt: row.updated_at,
763
+ }));
764
+ }
765
+
766
+ private backfillTurnParents(localSessionId: string): void {
767
+ const turns = this.db.prepare(`
768
+ SELECT
769
+ turn_id,
770
+ coalesce(
771
+ json_extract(snapshot_json, '$.startedAt'),
772
+ json_extract(snapshot_json, '$.completedAt'),
773
+ cast(strftime('%s', updated_at) AS INTEGER) * 1000,
774
+ 0
775
+ ) AS started_at
776
+ FROM turn_snapshots
777
+ WHERE local_session_id = ?
778
+ AND json_extract(snapshot_json, '$.status') = 'completed'
779
+ ORDER BY started_at ASC, updated_at ASC
780
+ `).all(localSessionId) as Array<{ turn_id: string; started_at: number }>;
781
+ if (turns.length === 0) return;
782
+
783
+ const existing = new Set((this.db.prepare(`
784
+ SELECT turn_id FROM turn_parent_links WHERE local_session_id = ?
785
+ `).all(localSessionId) as Array<{ turn_id: string }>).map((row) => row.turn_id));
786
+ const resets = (this.db.prepare(`
787
+ SELECT payload_json, created_at
788
+ FROM audit_events
789
+ WHERE event_type = 'session_reset_to_turn'
790
+ AND json_extract(payload_json, '$.localSessionId') = ?
791
+ ORDER BY created_at ASC, id ASC
792
+ `).all(localSessionId) as Array<{ payload_json: string; created_at: string }>).flatMap((row) => {
793
+ try {
794
+ const payload = JSON.parse(row.payload_json) as { resetTurnId?: unknown };
795
+ const resetTurnId = typeof payload.resetTurnId === "string" ? payload.resetTurnId : undefined;
796
+ const at = Date.parse(row.created_at);
797
+ return resetTurnId && Number.isFinite(at) ? [{ resetTurnId, at }] : [];
798
+ } catch {
799
+ return [];
800
+ }
801
+ });
802
+
803
+ let headTurnId: string | undefined;
804
+ let resetIndex = 0;
805
+ const insert = this.db.prepare(`
806
+ INSERT OR IGNORE INTO turn_parent_links (
807
+ turn_id, local_session_id, parent_turn_id, created_at
808
+ ) VALUES (?, ?, ?, ?)
809
+ `);
810
+ const backfill = this.db.transaction(() => {
811
+ for (const turn of turns) {
812
+ while (resetIndex < resets.length && resets[resetIndex]!.at <= turn.started_at) {
813
+ headTurnId = resets[resetIndex]!.resetTurnId;
814
+ resetIndex += 1;
815
+ }
816
+ if (!existing.has(turn.turn_id)) {
817
+ insert.run(turn.turn_id, localSessionId, headTurnId ?? null, new Date().toISOString());
818
+ }
819
+ headTurnId = turn.turn_id;
820
+ }
821
+ });
822
+ backfill();
823
+ }
824
+
629
825
  saveTurnDelivery(
630
826
  turnId: string,
631
827
  patch: { progressMessageId?: string; lastCardHash?: string },
@@ -784,6 +980,31 @@ export class StateStore {
784
980
  return rows.map(mapSession);
785
981
  }
786
982
 
983
+ findLatestMessageIdForSession(localSessionId: string, contextKey?: string): string | undefined {
984
+ const row = this.db.prepare(`
985
+ SELECT message_id
986
+ FROM message_reactions
987
+ WHERE local_session_id = ?
988
+ AND (? IS NULL OR context_key = ?)
989
+ ORDER BY created_at DESC, rowid DESC
990
+ LIMIT 1
991
+ `).get(localSessionId, contextKey ?? null, contextKey ?? null) as { message_id: string } | undefined;
992
+ return row?.message_id;
993
+ }
994
+
995
+ findLatestMessageIdForContext(contextKey: string): string | undefined {
996
+ const row = this.db.prepare(`
997
+ SELECT json_extract(payload_json, '$.messageId') AS message_id
998
+ FROM audit_events
999
+ WHERE context_key = ?
1000
+ AND event_type = 'incoming_message'
1001
+ AND json_type(payload_json, '$.messageId') = 'text'
1002
+ ORDER BY id DESC
1003
+ LIMIT 1
1004
+ `).get(contextKey) as { message_id: string } | undefined;
1005
+ return row?.message_id;
1006
+ }
1007
+
787
1008
  getServerActivityState(): {
788
1009
  runningSessions: number;
789
1010
  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
  }
@@ -11,9 +11,15 @@ export type SafeRestartPhase =
11
11
  | "restarting"
12
12
  | "cancelled";
13
13
 
14
+ export interface RestartNotificationTarget {
15
+ contextKey: string;
16
+ replyMessageId?: string;
17
+ }
18
+
14
19
  export interface SafeRestartStatus {
15
20
  scheduleId: number;
16
21
  reason: string;
22
+ notificationTargets?: RestartNotificationTarget[];
17
23
  phase: SafeRestartPhase;
18
24
  activity: ServerActivityState;
19
25
  remainingMs?: number;
@@ -21,7 +27,7 @@ export interface SafeRestartStatus {
21
27
 
22
28
  export interface SafeRestartSchedulerOptions {
23
29
  readActivity(): ServerActivityState;
24
- onReady(reason: string): void | Promise<void>;
30
+ onReady(reason: string, notificationTargets: RestartNotificationTarget[]): void | Promise<void>;
25
31
  onStatus?(status: SafeRestartStatus): void | Promise<void>;
26
32
  onStatusError?(error: unknown): void;
27
33
  quietPeriodMs?: number;
@@ -33,6 +39,7 @@ export class SafeRestartScheduler {
33
39
  private polling?: Promise<void>;
34
40
  private pollAgain = false;
35
41
  private reason?: string;
42
+ private notificationTargets: RestartNotificationTarget[] = [];
36
43
  private idleSince?: number;
37
44
  private idleInboundAt?: string;
38
45
  private scheduleId = 0;
@@ -47,9 +54,13 @@ export class SafeRestartScheduler {
47
54
  return this.reason;
48
55
  }
49
56
 
50
- schedule(reason: string): boolean {
57
+ schedule(reason: string, notificationTarget?: RestartNotificationTarget): boolean {
51
58
  const newlyScheduled = !this.reason;
52
- if (newlyScheduled) this.scheduleId += 1;
59
+ if (newlyScheduled) {
60
+ this.scheduleId += 1;
61
+ this.notificationTargets = [];
62
+ }
63
+ this.addNotificationTarget(notificationTarget);
53
64
  this.reason = reason;
54
65
  this.idleSince = undefined;
55
66
  this.idleInboundAt = undefined;
@@ -73,10 +84,12 @@ export class SafeRestartScheduler {
73
84
  const reason = this.reason;
74
85
  if (!reason || this.scheduleId !== expectedScheduleId) return false;
75
86
  const activity = this.options.readActivity();
87
+ const notificationTargets = this.notificationTargetsSnapshot();
76
88
  this.clear();
77
89
  await this.emitStatus({
78
90
  scheduleId: expectedScheduleId,
79
91
  reason,
92
+ notificationTargets,
80
93
  phase: "cancelled",
81
94
  activity,
82
95
  });
@@ -87,6 +100,7 @@ export class SafeRestartScheduler {
87
100
  if (this.timer) clearInterval(this.timer);
88
101
  this.timer = undefined;
89
102
  this.reason = undefined;
103
+ this.notificationTargets = [];
90
104
  this.idleSince = undefined;
91
105
  this.idleInboundAt = undefined;
92
106
  }
@@ -101,6 +115,7 @@ export class SafeRestartScheduler {
101
115
  await this.emitStatus({
102
116
  scheduleId: this.scheduleId,
103
117
  reason,
118
+ notificationTargets: this.notificationTargetsSnapshot(),
104
119
  phase: state.runningSessions > 0 ? "waiting_tasks" : "waiting_delivery",
105
120
  activity: state,
106
121
  });
@@ -111,12 +126,26 @@ export class SafeRestartScheduler {
111
126
  if (this.idleSince === undefined || this.idleInboundAt !== latestInboundAt) {
112
127
  this.idleSince = Date.now();
113
128
  this.idleInboundAt = latestInboundAt;
114
- await this.emitStatus({ scheduleId: this.scheduleId, reason, phase: "countdown", activity: state, remainingMs: quietPeriodMs });
129
+ await this.emitStatus({
130
+ scheduleId: this.scheduleId,
131
+ reason,
132
+ notificationTargets: this.notificationTargetsSnapshot(),
133
+ phase: "countdown",
134
+ activity: state,
135
+ remainingMs: quietPeriodMs,
136
+ });
115
137
  return;
116
138
  }
117
139
  const remainingMs = Math.max(0, quietPeriodMs - (Date.now() - this.idleSince));
118
140
  if (remainingMs > 0) {
119
- await this.emitStatus({ scheduleId: this.scheduleId, reason, phase: "countdown", activity: state, remainingMs });
141
+ await this.emitStatus({
142
+ scheduleId: this.scheduleId,
143
+ reason,
144
+ notificationTargets: this.notificationTargetsSnapshot(),
145
+ phase: "countdown",
146
+ activity: state,
147
+ remainingMs,
148
+ });
120
149
  return;
121
150
  }
122
151
  const confirmed = this.options.readActivity();
@@ -131,6 +160,7 @@ export class SafeRestartScheduler {
131
160
  await this.emitStatus({
132
161
  scheduleId: this.scheduleId,
133
162
  reason,
163
+ notificationTargets: this.notificationTargetsSnapshot(),
134
164
  phase: confirmed.runningSessions > 0 ? "waiting_tasks" : "waiting_delivery",
135
165
  activity: confirmed,
136
166
  });
@@ -138,6 +168,7 @@ export class SafeRestartScheduler {
138
168
  await this.emitStatus({
139
169
  scheduleId: this.scheduleId,
140
170
  reason,
171
+ notificationTargets: this.notificationTargetsSnapshot(),
141
172
  phase: "countdown",
142
173
  activity: confirmed,
143
174
  remainingMs: quietPeriodMs,
@@ -146,9 +177,17 @@ export class SafeRestartScheduler {
146
177
  return;
147
178
  }
148
179
  const scheduleId = this.scheduleId;
180
+ const notificationTargets = this.notificationTargetsSnapshot();
149
181
  this.clear();
150
- await this.emitStatus({ scheduleId, reason, phase: "restarting", activity: confirmed, remainingMs: 0 });
151
- await this.options.onReady(reason);
182
+ await this.emitStatus({
183
+ scheduleId,
184
+ reason,
185
+ notificationTargets,
186
+ phase: "restarting",
187
+ activity: confirmed,
188
+ remainingMs: 0,
189
+ });
190
+ await this.options.onReady(reason, notificationTargets);
152
191
  }
153
192
 
154
193
  private requestPoll(): void {
@@ -175,4 +214,36 @@ export class SafeRestartScheduler {
175
214
  this.options.onStatusError?.(error);
176
215
  });
177
216
  }
217
+
218
+ private addNotificationTarget(value: RestartNotificationTarget | undefined): void {
219
+ const target = normalizedNotificationTarget(value);
220
+ if (!target) return;
221
+ const existingIndex = this.notificationTargets.findIndex(
222
+ (candidate) => candidate.contextKey === target.contextKey,
223
+ );
224
+ if (existingIndex < 0) {
225
+ this.notificationTargets.push(target);
226
+ return;
227
+ }
228
+ const existing = this.notificationTargets[existingIndex]!;
229
+ if (!existing.replyMessageId && target.replyMessageId) {
230
+ this.notificationTargets[existingIndex] = target;
231
+ }
232
+ }
233
+
234
+ private notificationTargetsSnapshot(): RestartNotificationTarget[] {
235
+ return this.notificationTargets.map((target) => ({ ...target }));
236
+ }
237
+ }
238
+
239
+ function normalizedNotificationTarget(
240
+ value: RestartNotificationTarget | undefined,
241
+ ): RestartNotificationTarget | undefined {
242
+ const contextKey = value?.contextKey.trim();
243
+ if (!contextKey) return undefined;
244
+ const replyMessageId = value?.replyMessageId?.trim();
245
+ return {
246
+ contextKey,
247
+ ...(replyMessageId ? { replyMessageId } : {}),
248
+ };
178
249
  }
@@ -1,10 +1,36 @@
1
+ export const RESTART_GROUP_CONTEXTS_ENV = "AGENT_BOT_RESTART_GROUP_CONTEXTS";
2
+
1
3
  export function replacementSupervisorEnvironment(
2
4
  restartReason: string,
3
5
  environment: NodeJS.ProcessEnv = process.env,
6
+ restartGroupContextKeys: readonly string[] = [],
4
7
  ): NodeJS.ProcessEnv {
5
- return {
8
+ const result: NodeJS.ProcessEnv = {
6
9
  ...environment,
7
10
  AGENT_BOT_START_DELAY_MS: "250",
8
11
  AGENT_BOT_RESTART_REASON: restartReason,
9
12
  };
13
+ const normalized = normalizedContextKeys(restartGroupContextKeys);
14
+ if (normalized.length > 0) {
15
+ result[RESTART_GROUP_CONTEXTS_ENV] = JSON.stringify(normalized);
16
+ } else {
17
+ delete result[RESTART_GROUP_CONTEXTS_ENV];
18
+ }
19
+ return result;
20
+ }
21
+
22
+ export function restartGroupContextKeysFromEnvironment(value: string | undefined): string[] {
23
+ if (!value?.trim()) return [];
24
+ try {
25
+ const parsed = JSON.parse(value) as unknown;
26
+ return Array.isArray(parsed)
27
+ ? normalizedContextKeys(parsed.filter((entry): entry is string => typeof entry === "string"))
28
+ : [];
29
+ } catch {
30
+ return [];
31
+ }
32
+ }
33
+
34
+ function normalizedContextKeys(values: readonly string[]): string[] {
35
+ return [...new Set(values.map((value) => value.trim()).filter(Boolean))];
10
36
  }