@keyou007/agent-bot 0.1.14 → 0.1.15-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 (72) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +29 -24
  3. package/README.zh.md +29 -24
  4. package/config.example.yaml +1 -0
  5. package/dist/cli/FeishuAppConfiguration.js +42 -3
  6. package/dist/cli/FeishuAppConfiguration.js.map +1 -1
  7. package/dist/cli/help.js +6 -6
  8. package/dist/cli/initOptions.js +1 -4
  9. package/dist/cli/initOptions.js.map +1 -1
  10. package/dist/cli.js +8 -5
  11. package/dist/cli.js.map +1 -1
  12. package/dist/feishu/CardRenderer.js +91 -45
  13. package/dist/feishu/CardRenderer.js.map +1 -1
  14. package/dist/feishu/FeishuConnector.js +39 -12
  15. package/dist/feishu/FeishuConnector.js.map +1 -1
  16. package/dist/feishu/FeishuMarkdown.js +58 -6
  17. package/dist/feishu/FeishuMarkdown.js.map +1 -1
  18. package/dist/feishu/FeishuMessageClient.js +108 -4
  19. package/dist/feishu/FeishuMessageClient.js.map +1 -1
  20. package/dist/feishu/FeishuTurnPresenter.js +24 -1
  21. package/dist/feishu/FeishuTurnPresenter.js.map +1 -1
  22. package/dist/feishu/FinalResponseBranding.js +15 -0
  23. package/dist/feishu/FinalResponseBranding.js.map +1 -0
  24. package/dist/feishu/InboundText.js +13 -0
  25. package/dist/feishu/InboundText.js.map +1 -0
  26. package/dist/feishu/MergedForwardMessage.js +317 -0
  27. package/dist/feishu/MergedForwardMessage.js.map +1 -0
  28. package/dist/logging/DailyLogStream.js +73 -0
  29. package/dist/logging/DailyLogStream.js.map +1 -0
  30. package/dist/logging/logger.js +2 -1
  31. package/dist/logging/logger.js.map +1 -1
  32. package/dist/presentation/OutboundRouter.js +21 -0
  33. package/dist/presentation/OutboundRouter.js.map +1 -1
  34. package/dist/proxy/ProxySessionController.js +536 -58
  35. package/dist/proxy/ProxySessionController.js.map +1 -1
  36. package/dist/state/StateStore.js +161 -3
  37. package/dist/state/StateStore.js.map +1 -1
  38. package/dist/state/migrations.js +12 -0
  39. package/dist/state/migrations.js.map +1 -1
  40. package/dist/supervision/SupervisorDiagnostics.js +37 -21
  41. package/dist/supervision/SupervisorDiagnostics.js.map +1 -1
  42. package/dist/supervisor.js +11 -4
  43. package/dist/supervisor.js.map +1 -1
  44. package/dist/utils/executeShellCommand.js +83 -16
  45. package/dist/utils/executeShellCommand.js.map +1 -1
  46. package/docs/technical-reference.md +9 -8
  47. package/docs/technical-reference.zh.md +9 -8
  48. package/npm-shrinkwrap.json +2 -2
  49. package/package.json +1 -1
  50. package/skills/agent-bot/SKILL.md +1 -1
  51. package/src/cli/FeishuAppConfiguration.ts +76 -3
  52. package/src/cli/help.ts +6 -6
  53. package/src/cli/initOptions.ts +1 -10
  54. package/src/cli.ts +10 -6
  55. package/src/feishu/CardRenderer.ts +116 -45
  56. package/src/feishu/FeishuConnector.ts +55 -13
  57. package/src/feishu/FeishuMarkdown.ts +64 -5
  58. package/src/feishu/FeishuMessageClient.ts +151 -12
  59. package/src/feishu/FeishuTurnPresenter.ts +28 -4
  60. package/src/feishu/FinalResponseBranding.ts +18 -0
  61. package/src/feishu/InboundText.ts +12 -0
  62. package/src/feishu/MergedForwardMessage.ts +372 -0
  63. package/src/feishu/types.ts +15 -0
  64. package/src/logging/DailyLogStream.ts +88 -0
  65. package/src/logging/logger.ts +2 -1
  66. package/src/presentation/OutboundRouter.ts +28 -0
  67. package/src/proxy/ProxySessionController.ts +642 -55
  68. package/src/state/StateStore.ts +230 -2
  69. package/src/state/migrations.ts +12 -0
  70. package/src/supervision/SupervisorDiagnostics.ts +41 -25
  71. package/src/supervisor.ts +10 -4
  72. package/src/utils/executeShellCommand.ts +103 -15
@@ -64,6 +64,17 @@ export interface TurnAnchorRecord {
64
64
  contextKey?: string;
65
65
  }
66
66
 
67
+ export interface AgentBotTurnMessageRecord extends TurnAnchorRecord {
68
+ messageKind: "progress" | "final";
69
+ }
70
+
71
+ export interface GoalCardDeliveryRecord {
72
+ localSessionId: string;
73
+ contextKey: string;
74
+ messageId: string;
75
+ updatedAt: string;
76
+ }
77
+
67
78
  export interface TurnRuntimeOriginRecord {
68
79
  turnId: string;
69
80
  localSessionId: string;
@@ -84,6 +95,7 @@ export interface QueuedPromptRecord {
84
95
  localSessionId: string;
85
96
  contextKey: string;
86
97
  text: string;
98
+ displayPrompt?: string;
87
99
  localImagePaths?: string[];
88
100
  messageId?: string;
89
101
  replyMessageId?: string;
@@ -181,6 +193,7 @@ interface QueuedPromptRow {
181
193
  local_session_id: string;
182
194
  context_key: string;
183
195
  prompt_text: string;
196
+ display_prompt: string | null;
184
197
  local_image_paths_json: string;
185
198
  message_id: string | null;
186
199
  reply_message_id: string | null;
@@ -221,6 +234,7 @@ export class StateStore {
221
234
  this.ensureTurnSnapshotColumns();
222
235
  this.ensureChatContextColumns();
223
236
  this.ensureTurnAttemptColumns();
237
+ this.ensureQueuedPromptColumns();
224
238
  this.initializeContextSessionMappings();
225
239
  this.db.exec("DROP INDEX IF EXISTS idx_sessions_remote_session_id_unique");
226
240
  this.reconcileDuplicateRemoteSessions();
@@ -235,6 +249,46 @@ export class StateStore {
235
249
  `);
236
250
  }
237
251
 
252
+ saveGoalCardDelivery(localSessionId: string, contextKey: string, messageId: string): void {
253
+ const updatedAt = new Date().toISOString();
254
+ this.db.prepare(`
255
+ INSERT INTO goal_card_deliveries (local_session_id, context_key, message_id, updated_at)
256
+ VALUES (?, ?, ?, ?)
257
+ ON CONFLICT(local_session_id, context_key) DO UPDATE SET
258
+ message_id = excluded.message_id,
259
+ updated_at = excluded.updated_at
260
+ `).run(localSessionId, contextKey, messageId, updatedAt);
261
+ }
262
+
263
+ getGoalCardDelivery(localSessionId: string, contextKey: string): GoalCardDeliveryRecord | undefined {
264
+ const row = this.db.prepare(`
265
+ SELECT local_session_id, context_key, message_id, updated_at
266
+ FROM goal_card_deliveries
267
+ WHERE local_session_id = ? AND context_key = ?
268
+ `).get(localSessionId, contextKey) as {
269
+ local_session_id: string;
270
+ context_key: string;
271
+ message_id: string;
272
+ updated_at: string;
273
+ } | undefined;
274
+ return row ? goalCardDeliveryFromRow(row) : undefined;
275
+ }
276
+
277
+ listGoalCardDeliveries(localSessionId: string): GoalCardDeliveryRecord[] {
278
+ const rows = this.db.prepare(`
279
+ SELECT local_session_id, context_key, message_id, updated_at
280
+ FROM goal_card_deliveries
281
+ WHERE local_session_id = ?
282
+ ORDER BY updated_at ASC
283
+ `).all(localSessionId) as Array<{
284
+ local_session_id: string;
285
+ context_key: string;
286
+ message_id: string;
287
+ updated_at: string;
288
+ }>;
289
+ return rows.map(goalCardDeliveryFromRow);
290
+ }
291
+
238
292
  close(): void {
239
293
  this.db.close();
240
294
  }
@@ -374,6 +428,7 @@ export class StateStore {
374
428
  localSessionId: string;
375
429
  contextKey: string;
376
430
  text: string;
431
+ displayPrompt?: string;
377
432
  localImagePaths?: string[];
378
433
  messageId?: string;
379
434
  replyMessageId?: string;
@@ -381,14 +436,15 @@ export class StateStore {
381
436
  const createdAt = new Date().toISOString();
382
437
  this.db.prepare(`
383
438
  INSERT INTO queued_prompts (
384
- prompt_id, local_session_id, context_key, prompt_text, local_image_paths_json,
439
+ prompt_id, local_session_id, context_key, prompt_text, display_prompt, local_image_paths_json,
385
440
  message_id, reply_message_id, created_at
386
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
441
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
387
442
  `).run(
388
443
  input.promptId,
389
444
  input.localSessionId,
390
445
  input.contextKey,
391
446
  input.text,
447
+ input.displayPrompt ?? null,
392
448
  JSON.stringify(input.localImagePaths ?? []),
393
449
  input.messageId ?? null,
394
450
  input.replyMessageId ?? null,
@@ -1049,6 +1105,91 @@ export class StateStore {
1049
1105
  }));
1050
1106
  }
1051
1107
 
1108
+ listTaskTurnGraph(localSessionId: string): CompletedTurnSnapshotRecord[] {
1109
+ this.backfillTurnParents(localSessionId);
1110
+ const records = new Map(
1111
+ this.listCompletedTurnGraphRows(localSessionId).map((record) => [record.turnId, record]),
1112
+ );
1113
+ let ancestorTurnId = this.findForkSourceTurnId(localSessionId)
1114
+ ?? this.findCrossSessionParentTurnId(localSessionId);
1115
+ const visited = new Set<string>();
1116
+
1117
+ while (ancestorTurnId && !visited.has(ancestorTurnId)) {
1118
+ visited.add(ancestorTurnId);
1119
+ const ancestor = this.getCompletedTurnGraphRecord(ancestorTurnId);
1120
+ if (!ancestor) break;
1121
+ records.set(ancestor.turnId, ancestor);
1122
+ ancestorTurnId = ancestor.parentTurnId;
1123
+ }
1124
+
1125
+ return [...records.values()].sort(compareCompletedTurnSnapshots);
1126
+ }
1127
+
1128
+ private listCompletedTurnGraphRows(localSessionId: string): CompletedTurnSnapshotRecord[] {
1129
+ const rows = this.db.prepare(`
1130
+ SELECT ts.turn_id, tpl.parent_turn_id, ts.snapshot_json, ts.updated_at
1131
+ FROM turn_snapshots ts
1132
+ LEFT JOIN turn_parent_links tpl ON tpl.turn_id = ts.turn_id
1133
+ WHERE ts.local_session_id = ?
1134
+ AND json_extract(ts.snapshot_json, '$.status') = 'completed'
1135
+ `).all(localSessionId) as Array<{
1136
+ turn_id: string;
1137
+ parent_turn_id: string | null;
1138
+ snapshot_json: string;
1139
+ updated_at: string;
1140
+ }>;
1141
+ return rows.map(mapCompletedTurnSnapshot);
1142
+ }
1143
+
1144
+ private getCompletedTurnGraphRecord(turnId: string): CompletedTurnSnapshotRecord | undefined {
1145
+ const owner = this.db.prepare(`
1146
+ SELECT local_session_id
1147
+ FROM turn_snapshots
1148
+ WHERE turn_id = ?
1149
+ AND json_extract(snapshot_json, '$.status') = 'completed'
1150
+ `).get(turnId) as { local_session_id: string } | undefined;
1151
+ if (!owner) return undefined;
1152
+ this.backfillTurnParents(owner.local_session_id);
1153
+ const row = this.db.prepare(`
1154
+ SELECT ts.turn_id, tpl.parent_turn_id, ts.snapshot_json, ts.updated_at
1155
+ FROM turn_snapshots ts
1156
+ LEFT JOIN turn_parent_links tpl ON tpl.turn_id = ts.turn_id
1157
+ WHERE ts.turn_id = ?
1158
+ AND json_extract(ts.snapshot_json, '$.status') = 'completed'
1159
+ `).get(turnId) as {
1160
+ turn_id: string;
1161
+ parent_turn_id: string | null;
1162
+ snapshot_json: string;
1163
+ updated_at: string;
1164
+ } | undefined;
1165
+ return row ? mapCompletedTurnSnapshot(row) : undefined;
1166
+ }
1167
+
1168
+ private findForkSourceTurnId(localSessionId: string): string | undefined {
1169
+ const row = this.db.prepare(`
1170
+ SELECT json_extract(payload_json, '$.sourceTurnId') AS source_turn_id
1171
+ FROM audit_events
1172
+ WHERE event_type IN ('session_forked', 'thread_forked')
1173
+ AND json_extract(payload_json, '$.forkedLocalSessionId') = ?
1174
+ ORDER BY id DESC
1175
+ LIMIT 1
1176
+ `).get(localSessionId) as { source_turn_id: string | null } | undefined;
1177
+ return row?.source_turn_id ?? undefined;
1178
+ }
1179
+
1180
+ private findCrossSessionParentTurnId(localSessionId: string): string | undefined {
1181
+ const row = this.db.prepare(`
1182
+ SELECT links.parent_turn_id
1183
+ FROM turn_parent_links links
1184
+ JOIN turn_snapshots parent ON parent.turn_id = links.parent_turn_id
1185
+ WHERE links.local_session_id = ?
1186
+ AND parent.local_session_id <> ?
1187
+ ORDER BY links.created_at ASC
1188
+ LIMIT 1
1189
+ `).get(localSessionId, localSessionId) as { parent_turn_id: string } | undefined;
1190
+ return row?.parent_turn_id;
1191
+ }
1192
+
1052
1193
  private backfillTurnParents(localSessionId: string): void {
1053
1194
  const turns = this.db.prepare(`
1054
1195
  SELECT
@@ -1586,6 +1727,35 @@ export class StateStore {
1586
1727
  : undefined;
1587
1728
  }
1588
1729
 
1730
+ findAgentBotTurnMessageById(messageId: string): AgentBotTurnMessageRecord | undefined {
1731
+ const row = this.db.prepare(`
1732
+ SELECT deliveries.turn_id, snapshots.local_session_id,
1733
+ CASE WHEN deliveries.progress_message_id = ? THEN 'progress' ELSE 'final' END AS message_kind
1734
+ FROM turn_deliveries AS deliveries
1735
+ JOIN turn_snapshots AS snapshots ON snapshots.turn_id = deliveries.turn_id
1736
+ WHERE deliveries.progress_message_id = ?
1737
+ OR EXISTS (
1738
+ SELECT 1
1739
+ FROM json_each(deliveries.final_message_ids_json)
1740
+ WHERE json_each.value = ?
1741
+ )
1742
+ ORDER BY deliveries.updated_at DESC
1743
+ LIMIT 1
1744
+ `).get(messageId, messageId, messageId) as {
1745
+ turn_id: string;
1746
+ local_session_id: string;
1747
+ message_kind: "progress" | "final";
1748
+ } | undefined;
1749
+ return row
1750
+ ? {
1751
+ turnId: row.turn_id,
1752
+ localSessionId: row.local_session_id,
1753
+ contextKey: this.getTurnContextKey(row.turn_id),
1754
+ messageKind: row.message_kind,
1755
+ }
1756
+ : undefined;
1757
+ }
1758
+
1589
1759
  private initializeContextSessionMappings(): void {
1590
1760
  this.db.exec(`
1591
1761
  INSERT OR IGNORE INTO context_sessions (context_key, local_session_id, created_at, updated_at)
@@ -1749,6 +1919,15 @@ export class StateStore {
1749
1919
  this.db.exec("ALTER TABLE turn_attempts ADD COLUMN retry_count INTEGER NOT NULL DEFAULT 0");
1750
1920
  }
1751
1921
  }
1922
+
1923
+ private ensureQueuedPromptColumns(): void {
1924
+ const existing = new Set(
1925
+ (this.db.pragma("table_info(queued_prompts)") as Array<{ name: string }>).map((column) => column.name),
1926
+ );
1927
+ if (!existing.has("display_prompt")) {
1928
+ this.db.exec("ALTER TABLE queued_prompts ADD COLUMN display_prompt TEXT");
1929
+ }
1930
+ }
1752
1931
  }
1753
1932
 
1754
1933
  const MAX_TASK_TITLE_LENGTH = 120;
@@ -1826,6 +2005,20 @@ function mapMessageReaction(row: MessageReactionRow): MessageReactionRecord {
1826
2005
  };
1827
2006
  }
1828
2007
 
2008
+ function goalCardDeliveryFromRow(row: {
2009
+ local_session_id: string;
2010
+ context_key: string;
2011
+ message_id: string;
2012
+ updated_at: string;
2013
+ }): GoalCardDeliveryRecord {
2014
+ return {
2015
+ localSessionId: row.local_session_id,
2016
+ contextKey: row.context_key,
2017
+ messageId: row.message_id,
2018
+ updatedAt: row.updated_at,
2019
+ };
2020
+ }
2021
+
1829
2022
  function mapQueuedPrompt(row: QueuedPromptRow): QueuedPromptRecord {
1830
2023
  const parsedPaths = JSON.parse(row.local_image_paths_json) as unknown;
1831
2024
  const localImagePaths = Array.isArray(parsedPaths)
@@ -1836,6 +2029,7 @@ function mapQueuedPrompt(row: QueuedPromptRow): QueuedPromptRecord {
1836
2029
  localSessionId: row.local_session_id,
1837
2030
  contextKey: row.context_key,
1838
2031
  text: row.prompt_text,
2032
+ displayPrompt: row.display_prompt ?? undefined,
1839
2033
  localImagePaths: localImagePaths.length > 0 ? localImagePaths : undefined,
1840
2034
  messageId: row.message_id ?? undefined,
1841
2035
  replyMessageId: row.reply_message_id ?? undefined,
@@ -1867,6 +2061,40 @@ function mapTurnAttempt(row: TurnAttemptRow): TurnAttemptRecord {
1867
2061
  };
1868
2062
  }
1869
2063
 
2064
+ function mapCompletedTurnSnapshot(row: {
2065
+ turn_id: string;
2066
+ parent_turn_id: string | null;
2067
+ snapshot_json: string;
2068
+ updated_at: string;
2069
+ }): CompletedTurnSnapshotRecord {
2070
+ return {
2071
+ turnId: row.turn_id,
2072
+ parentTurnId: row.parent_turn_id ?? undefined,
2073
+ snapshot: JSON.parse(row.snapshot_json),
2074
+ updatedAt: row.updated_at,
2075
+ };
2076
+ }
2077
+
2078
+ function compareCompletedTurnSnapshots(
2079
+ left: CompletedTurnSnapshotRecord,
2080
+ right: CompletedTurnSnapshotRecord,
2081
+ ): number {
2082
+ const timeDifference = completedTurnTimestamp(right) - completedTurnTimestamp(left);
2083
+ return timeDifference || right.updatedAt.localeCompare(left.updatedAt);
2084
+ }
2085
+
2086
+ function completedTurnTimestamp(record: CompletedTurnSnapshotRecord): number {
2087
+ const snapshot = record.snapshot && typeof record.snapshot === "object"
2088
+ ? record.snapshot as { completedAt?: unknown; startedAt?: unknown }
2089
+ : undefined;
2090
+ const timestamp = typeof snapshot?.completedAt === "number"
2091
+ ? snapshot.completedAt
2092
+ : typeof snapshot?.startedAt === "number"
2093
+ ? snapshot.startedAt
2094
+ : Date.parse(record.updatedAt);
2095
+ return Number.isFinite(timestamp) ? timestamp : 0;
2096
+ }
2097
+
1870
2098
  function isIncompleteTurnAttemptStatus(status: TurnAttemptStatus): boolean {
1871
2099
  return status === "accepted" || status === "running" || status === "recovering";
1872
2100
  }
@@ -118,6 +118,7 @@ export const migrations = [
118
118
  local_session_id TEXT NOT NULL,
119
119
  context_key TEXT NOT NULL,
120
120
  prompt_text TEXT NOT NULL,
121
+ display_prompt TEXT,
121
122
  local_image_paths_json TEXT NOT NULL DEFAULT '[]',
122
123
  message_id TEXT,
123
124
  reply_message_id TEXT,
@@ -172,4 +173,15 @@ export const migrations = [
172
173
  CREATE INDEX IF NOT EXISTS idx_turn_attempts_turn
173
174
  ON turn_attempts(turn_id);
174
175
  `,
176
+ `
177
+ CREATE TABLE IF NOT EXISTS goal_card_deliveries (
178
+ local_session_id TEXT NOT NULL,
179
+ context_key TEXT NOT NULL,
180
+ message_id TEXT NOT NULL,
181
+ updated_at TEXT NOT NULL,
182
+ PRIMARY KEY (local_session_id, context_key)
183
+ );
184
+ CREATE INDEX IF NOT EXISTS idx_goal_card_deliveries_session
185
+ ON goal_card_deliveries(local_session_id);
186
+ `,
175
187
  ] as const;
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import type { AppConfig } from "../config/schema.js";
4
+ import { DailyLogStream, dailyLogPath } from "../logging/DailyLogStream.js";
4
5
 
5
6
  const MAX_LOG_BYTES = 10 * 1024 * 1024;
6
7
  const LOG_BACKUP_COUNT = 3;
@@ -42,8 +43,12 @@ type ExtendedProcessReport = typeof process.report & {
42
43
 
43
44
  export class SupervisorDiagnostics {
44
45
  readonly paths: SupervisorDiagnosticsPaths;
46
+ private workerStderrStream?: DailyLogStream;
45
47
 
46
- constructor(config: Pick<AppConfig, "logging" | "storage">) {
48
+ constructor(
49
+ config: Pick<AppConfig, "logging" | "storage">,
50
+ private readonly clock: () => Date = () => new Date(),
51
+ ) {
47
52
  this.paths = resolveSupervisorDiagnosticsPaths(config);
48
53
  }
49
54
 
@@ -51,13 +56,13 @@ export class SupervisorDiagnostics {
51
56
  try {
52
57
  ensurePrivateDirectory(path.dirname(this.paths.supervisorLogPath));
53
58
  ensurePrivateDirectory(this.paths.crashReportDirectory);
54
- rotateLogFile(this.paths.supervisorLogPath);
55
- rotateLogFile(this.paths.workerStderrPath);
59
+ rotateLogFile(this.currentSupervisorLogPath());
60
+ rotateLogFile(this.currentWorkerStderrPath());
56
61
  this.configureCurrentProcessReports();
57
62
  this.writeEvent("diagnostics_initialized", {
58
63
  nodeVersion: process.versions.node,
59
- supervisorLogPath: this.paths.supervisorLogPath,
60
- workerStderrPath: this.paths.workerStderrPath,
64
+ supervisorLogPath: this.currentSupervisorLogPath(),
65
+ workerStderrPath: this.currentWorkerStderrPath(),
61
66
  crashReportDirectory: this.paths.crashReportDirectory,
62
67
  });
63
68
  } catch (error) {
@@ -73,40 +78,51 @@ export class SupervisorDiagnostics {
73
78
  ...data,
74
79
  })}\n`;
75
80
  try {
76
- fs.appendFileSync(this.paths.supervisorLogPath, line, { encoding: "utf8", mode: 0o600 });
77
- restrictFilePermissions(this.paths.supervisorLogPath);
81
+ const logPath = this.currentSupervisorLogPath();
82
+ rotateLogFile(logPath);
83
+ fs.appendFileSync(logPath, line, { encoding: "utf8", mode: 0o600 });
84
+ restrictFilePermissions(logPath);
78
85
  } catch (error) {
79
86
  writeFallbackDiagnostic(event, error);
80
87
  }
81
88
  }
82
89
 
83
- openWorkerStderr(): number | "ignore" {
90
+ openWorkerStderr(): DailyLogStream | "ignore" {
84
91
  try {
85
- rotateLogFile(this.paths.workerStderrPath);
86
- fs.appendFileSync(
87
- this.paths.workerStderrPath,
88
- `${JSON.stringify({ event: "worker_started", time: new Date().toISOString() })}\n`,
89
- { encoding: "utf8", mode: 0o600 },
90
- );
91
- restrictFilePermissions(this.paths.workerStderrPath);
92
- return fs.openSync(this.paths.workerStderrPath, "a", 0o600);
92
+ const stream = new DailyLogStream(this.paths.workerStderrPath, {
93
+ clock: this.clock,
94
+ mode: 0o600,
95
+ beforeOpen: (filePath) => rotateLogFile(filePath),
96
+ });
97
+ stream.on("error", (error) => {
98
+ this.writeEvent("worker_stderr_write_failed", { error: errorMessage(error) });
99
+ });
100
+ stream.write(`${JSON.stringify({ event: "worker_started", time: this.clock().toISOString() })}\n`);
101
+ this.workerStderrStream = stream;
102
+ return stream;
93
103
  } catch (error) {
94
104
  this.writeEvent("worker_stderr_open_failed", { error: errorMessage(error) });
95
105
  return "ignore";
96
106
  }
97
107
  }
98
108
 
99
- closeWorkerStderr(target: number | "ignore"): void {
100
- if (typeof target !== "number") return;
101
- try {
102
- fs.closeSync(target);
103
- } catch (error) {
104
- this.writeEvent("worker_stderr_close_failed", { error: errorMessage(error) });
105
- }
109
+ closeWorkerStderr(target: DailyLogStream | "ignore"): Promise<void> {
110
+ if (target === "ignore") return Promise.resolve();
111
+ return new Promise((resolve) => target.end(resolve));
112
+ }
113
+
114
+ currentSupervisorLogPath(date = this.clock()): string {
115
+ return dailyLogPath(this.paths.supervisorLogPath, date);
116
+ }
117
+
118
+ currentWorkerStderrPath(date = this.clock()): string {
119
+ return dailyLogPath(this.paths.workerStderrPath, date);
106
120
  }
107
121
 
108
122
  recordCrash(context: WorkerCrashContext): WorkerCrashRecord | undefined {
109
123
  try {
124
+ const exitedAt = new Date(context.exitedAt);
125
+ const crashDate = Number.isNaN(exitedAt.getTime()) ? this.clock() : exitedAt;
110
126
  const record: WorkerCrashRecord = {
111
127
  version: 1,
112
128
  ...context,
@@ -114,8 +130,8 @@ export class SupervisorDiagnostics {
114
130
  nodeVersion: process.versions.node,
115
131
  executablePath: process.execPath,
116
132
  cwd: process.cwd(),
117
- supervisorLogPath: this.paths.supervisorLogPath,
118
- workerStderrPath: this.paths.workerStderrPath,
133
+ supervisorLogPath: this.currentSupervisorLogPath(crashDate),
134
+ workerStderrPath: this.workerStderrStream?.filePath ?? this.currentWorkerStderrPath(crashDate),
119
135
  crashReportDirectory: this.paths.crashReportDirectory,
120
136
  diagnosticReports: findDiagnosticReports(
121
137
  this.paths.crashReportDirectory,
package/src/supervisor.ts CHANGED
@@ -70,18 +70,24 @@ async function startChild(): Promise<void> {
70
70
  AGENT_BOT_SUPERVISED: "1",
71
71
  AGENT_BOT_RESTART_REASON: restartReason,
72
72
  }, restartNotificationTargets),
73
- stdio: ["ignore", "ignore", workerStderr],
73
+ stdio: ["ignore", "ignore", "pipe"],
74
74
  windowsHide: true,
75
75
  });
76
- } finally {
77
- diagnostics.closeWorkerStderr(workerStderr);
76
+ if (workerStderr === "ignore") {
77
+ child.stderr?.resume();
78
+ } else {
79
+ child.stderr?.pipe(workerStderr);
80
+ }
81
+ } catch (error) {
82
+ void diagnostics.closeWorkerStderr(workerStderr);
83
+ throw error;
78
84
  }
79
85
  const workerPid = child.pid;
80
86
  writeSupervisorLog("started", {
81
87
  pid: child.pid,
82
88
  restartReason,
83
89
  restartNotificationTargets,
84
- workerStderrPath: diagnostics.paths.workerStderrPath,
90
+ workerStderrPath: diagnostics.currentWorkerStderrPath(),
85
91
  crashReportDirectory: diagnostics.paths.crashReportDirectory,
86
92
  });
87
93
 
@@ -11,10 +11,16 @@ export interface ShellCommandResult {
11
11
  outputTruncated: boolean;
12
12
  }
13
13
 
14
+ export type ShellCommandOutputSnapshot = Pick<
15
+ ShellCommandResult,
16
+ "stdout" | "stderr" | "outputTruncated"
17
+ >;
18
+
14
19
  export interface ShellCommandOptions {
15
20
  timeoutMs?: number;
16
21
  maxOutputBytes?: number;
17
22
  platform?: NodeJS.Platform;
23
+ onOutput?: (snapshot: ShellCommandOutputSnapshot) => void;
18
24
  }
19
25
 
20
26
  export function executeShellCommand(
@@ -40,8 +46,16 @@ export function executeShellCommand(
40
46
  child.kill();
41
47
  }, timeoutMs);
42
48
 
43
- child.stdout.on("data", (chunk: Buffer) => output.append("stdout", chunk));
44
- child.stderr.on("data", (chunk: Buffer) => output.append("stderr", chunk));
49
+ const appendOutput = (stream: "stdout" | "stderr", chunk: Buffer): void => {
50
+ output.append(stream, chunk);
51
+ try {
52
+ options.onOutput?.(output.result());
53
+ } catch {
54
+ // Output presentation must never interrupt the child process.
55
+ }
56
+ };
57
+ child.stdout.on("data", (chunk: Buffer) => appendOutput("stdout", chunk));
58
+ child.stderr.on("data", (chunk: Buffer) => appendOutput("stderr", chunk));
45
59
  child.once("error", (error) => {
46
60
  clearTimeout(timeout);
47
61
  reject(error);
@@ -68,30 +82,104 @@ function prepareShellCommand(command: string, platform: NodeJS.Platform): { comm
68
82
  }
69
83
 
70
84
  class BoundedOutput {
71
- private readonly stdout: Buffer[] = [];
72
- private readonly stderr: Buffer[] = [];
73
- private retainedBytes = 0;
85
+ private retained: OutputChunk[] = [];
86
+ private readonly seenBytes = { stdout: 0, stderr: 0 };
87
+ private readonly headBytes: number;
88
+ private readonly tailBytes: number;
74
89
  private truncated = false;
75
90
 
76
- constructor(private readonly maxBytes: number) {}
91
+ constructor(maxBytes: number) {
92
+ const normalizedMaxBytes = Math.max(1, Math.floor(maxBytes));
93
+ this.headBytes = Math.ceil(normalizedMaxBytes / 3);
94
+ this.tailBytes = normalizedMaxBytes - this.headBytes;
95
+ }
77
96
 
78
97
  append(stream: "stdout" | "stderr", chunk: Buffer): void {
79
- const remaining = Math.max(0, this.maxBytes - this.retainedBytes);
80
- if (remaining === 0) {
98
+ if (chunk.length === 0) return;
99
+ this.seenBytes[stream] += chunk.length;
100
+
101
+ if (!this.truncated) {
102
+ const next = [...this.retained, { stream, data: chunk }];
103
+ if (outputChunksLength(next) <= this.headBytes + this.tailBytes) {
104
+ this.retained = next;
105
+ return;
106
+ }
81
107
  this.truncated = true;
108
+ this.retained = [
109
+ ...takeOutputChunkHead(next, this.headBytes),
110
+ ...takeOutputChunkTail(next, this.tailBytes),
111
+ ];
82
112
  return;
83
113
  }
84
- const retained = chunk.length <= remaining ? chunk : chunk.subarray(0, remaining);
85
- this[stream].push(retained);
86
- this.retainedBytes += retained.length;
87
- if (retained.length < chunk.length) this.truncated = true;
114
+
115
+ const head = takeOutputChunkHead(this.retained, this.headBytes);
116
+ const previousTail = takeOutputChunkTail(this.retained, this.tailBytes);
117
+ const tail = takeOutputChunkTail([...previousTail, { stream, data: chunk }], this.tailBytes);
118
+ this.retained = [...head, ...tail];
88
119
  }
89
120
 
90
- result(): Pick<ShellCommandResult, "stdout" | "stderr" | "outputTruncated"> {
121
+ result(): ShellCommandOutputSnapshot {
91
122
  return {
92
- stdout: Buffer.concat(this.stdout).toString("utf8"),
93
- stderr: Buffer.concat(this.stderr).toString("utf8"),
123
+ stdout: this.streamResult("stdout"),
124
+ stderr: this.streamResult("stderr"),
94
125
  outputTruncated: this.truncated,
95
126
  };
96
127
  }
128
+
129
+ private streamResult(stream: "stdout" | "stderr"): string {
130
+ if (!this.truncated) {
131
+ return Buffer.concat(
132
+ this.retained.filter((chunk) => chunk.stream === stream).map((chunk) => chunk.data),
133
+ ).toString("utf8");
134
+ }
135
+ const head = takeOutputChunkHead(this.retained, this.headBytes)
136
+ .filter((chunk) => chunk.stream === stream);
137
+ const tail = takeOutputChunkTail(this.retained, this.tailBytes)
138
+ .filter((chunk) => chunk.stream === stream);
139
+
140
+ const retainedBytes = outputChunksLength([...head, ...tail]);
141
+ const marker = this.seenBytes[stream] > retainedBytes
142
+ ? Buffer.from("\n... output truncated ...\n", "utf8")
143
+ : Buffer.alloc(0);
144
+ return Buffer.concat([
145
+ ...head.map((chunk) => chunk.data),
146
+ marker,
147
+ ...tail.map((chunk) => chunk.data),
148
+ ]).toString("utf8");
149
+ }
150
+ }
151
+
152
+ interface OutputChunk {
153
+ stream: "stdout" | "stderr";
154
+ data: Buffer;
155
+ }
156
+
157
+ function outputChunksLength(chunks: OutputChunk[]): number {
158
+ return chunks.reduce((total, chunk) => total + chunk.data.length, 0);
159
+ }
160
+
161
+ function takeOutputChunkHead(chunks: OutputChunk[], maxBytes: number): OutputChunk[] {
162
+ const retained: OutputChunk[] = [];
163
+ let remaining = maxBytes;
164
+ for (const chunk of chunks) {
165
+ if (remaining <= 0) break;
166
+ const data = chunk.data.length <= remaining ? chunk.data : chunk.data.subarray(0, remaining);
167
+ retained.push({ stream: chunk.stream, data });
168
+ remaining -= data.length;
169
+ }
170
+ return retained;
171
+ }
172
+
173
+ function takeOutputChunkTail(chunks: OutputChunk[], maxBytes: number): OutputChunk[] {
174
+ const retained: OutputChunk[] = [];
175
+ let remaining = maxBytes;
176
+ for (let index = chunks.length - 1; index >= 0 && remaining > 0; index -= 1) {
177
+ const chunk = chunks[index]!;
178
+ const data = chunk.data.length <= remaining
179
+ ? chunk.data
180
+ : chunk.data.subarray(chunk.data.length - remaining);
181
+ retained.unshift({ stream: chunk.stream, data });
182
+ remaining -= data.length;
183
+ }
184
+ return retained;
97
185
  }