@keyou007/agent-bot 0.1.15-alpha.0 → 0.1.15-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 (56) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +23 -20
  3. package/README.zh.md +23 -20
  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.js +4 -1
  8. package/dist/cli.js.map +1 -1
  9. package/dist/feishu/CardRenderer.js +114 -16
  10. package/dist/feishu/CardRenderer.js.map +1 -1
  11. package/dist/feishu/FeishuConnector.js +21 -6
  12. package/dist/feishu/FeishuConnector.js.map +1 -1
  13. package/dist/feishu/FeishuMarkdown.js +58 -6
  14. package/dist/feishu/FeishuMarkdown.js.map +1 -1
  15. package/dist/feishu/FeishuMessageClient.js +33 -4
  16. package/dist/feishu/FeishuMessageClient.js.map +1 -1
  17. package/dist/feishu/FeishuTurnPresenter.js +7 -6
  18. package/dist/feishu/FeishuTurnPresenter.js.map +1 -1
  19. package/dist/feishu/FinalResponseBranding.js +15 -0
  20. package/dist/feishu/FinalResponseBranding.js.map +1 -0
  21. package/dist/logging/DailyLogStream.js +73 -0
  22. package/dist/logging/DailyLogStream.js.map +1 -0
  23. package/dist/logging/logger.js +2 -1
  24. package/dist/logging/logger.js.map +1 -1
  25. package/dist/proxy/ProxySessionController.js +270 -40
  26. package/dist/proxy/ProxySessionController.js.map +1 -1
  27. package/dist/state/StateStore.js +126 -0
  28. package/dist/state/StateStore.js.map +1 -1
  29. package/dist/state/migrations.js +11 -0
  30. package/dist/state/migrations.js.map +1 -1
  31. package/dist/supervision/SupervisorDiagnostics.js +37 -21
  32. package/dist/supervision/SupervisorDiagnostics.js.map +1 -1
  33. package/dist/supervisor.js +11 -4
  34. package/dist/supervisor.js.map +1 -1
  35. package/dist/utils/executeShellCommand.js +83 -16
  36. package/dist/utils/executeShellCommand.js.map +1 -1
  37. package/docs/technical-reference.md +9 -8
  38. package/docs/technical-reference.zh.md +9 -8
  39. package/npm-shrinkwrap.json +2 -2
  40. package/package.json +1 -1
  41. package/src/cli/FeishuAppConfiguration.ts +76 -3
  42. package/src/cli.ts +6 -1
  43. package/src/feishu/CardRenderer.ts +150 -17
  44. package/src/feishu/FeishuConnector.ts +32 -7
  45. package/src/feishu/FeishuMarkdown.ts +64 -5
  46. package/src/feishu/FeishuMessageClient.ts +41 -12
  47. package/src/feishu/FeishuTurnPresenter.ts +9 -6
  48. package/src/feishu/FinalResponseBranding.ts +18 -0
  49. package/src/logging/DailyLogStream.ts +88 -0
  50. package/src/logging/logger.ts +2 -1
  51. package/src/proxy/ProxySessionController.ts +292 -39
  52. package/src/state/StateStore.ts +180 -0
  53. package/src/state/migrations.ts +11 -0
  54. package/src/supervision/SupervisorDiagnostics.ts +41 -25
  55. package/src/supervisor.ts +10 -4
  56. package/src/utils/executeShellCommand.ts +103 -15
@@ -68,6 +68,13 @@ export interface AgentBotTurnMessageRecord extends TurnAnchorRecord {
68
68
  messageKind: "progress" | "final";
69
69
  }
70
70
 
71
+ export interface GoalCardDeliveryRecord {
72
+ localSessionId: string;
73
+ contextKey: string;
74
+ messageId: string;
75
+ updatedAt: string;
76
+ }
77
+
71
78
  export interface TurnRuntimeOriginRecord {
72
79
  turnId: string;
73
80
  localSessionId: string;
@@ -242,6 +249,46 @@ export class StateStore {
242
249
  `);
243
250
  }
244
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
+
245
292
  close(): void {
246
293
  this.db.close();
247
294
  }
@@ -1058,6 +1105,91 @@ export class StateStore {
1058
1105
  }));
1059
1106
  }
1060
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
+
1061
1193
  private backfillTurnParents(localSessionId: string): void {
1062
1194
  const turns = this.db.prepare(`
1063
1195
  SELECT
@@ -1873,6 +2005,20 @@ function mapMessageReaction(row: MessageReactionRow): MessageReactionRecord {
1873
2005
  };
1874
2006
  }
1875
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
+
1876
2022
  function mapQueuedPrompt(row: QueuedPromptRow): QueuedPromptRecord {
1877
2023
  const parsedPaths = JSON.parse(row.local_image_paths_json) as unknown;
1878
2024
  const localImagePaths = Array.isArray(parsedPaths)
@@ -1915,6 +2061,40 @@ function mapTurnAttempt(row: TurnAttemptRow): TurnAttemptRecord {
1915
2061
  };
1916
2062
  }
1917
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
+
1918
2098
  function isIncompleteTurnAttemptStatus(status: TurnAttemptStatus): boolean {
1919
2099
  return status === "accepted" || status === "running" || status === "recovering";
1920
2100
  }
@@ -173,4 +173,15 @@ export const migrations = [
173
173
  CREATE INDEX IF NOT EXISTS idx_turn_attempts_turn
174
174
  ON turn_attempts(turn_id);
175
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
+ `,
176
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
  }