@yeaft/webchat-agent 0.1.951 → 0.1.953

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.
@@ -0,0 +1,237 @@
1
+ /**
2
+ * output-log.js — Durable per-sub-agent event log.
3
+ *
4
+ * Why this exists:
5
+ * The original sub-agent design relied entirely on the live `onEvent`
6
+ * callback for visibility. If the UI dropped a frame, the WebSocket
7
+ * reconnected, or the user backgrounded the tab, the sub-agent's
8
+ * output was simply gone — there was no way to re-read it. The parent
9
+ * model had even less recourse: WaitAgent only returned the assistant
10
+ * text from the very last completed turn (and only at end_turn), so a
11
+ * sub-agent that ran a long tool sequence looked silent from upstairs.
12
+ *
13
+ * This module gives every sub-agent a durable JSONL log on disk. Every
14
+ * event the runner forwards through onEvent is mirrored here. The log
15
+ * path is exposed to the parent through WaitAgent/ListAgents, and the
16
+ * parent can Read it at any time. We also expose a `tail()` helper for
17
+ * wait_agent to include a small preview inline.
18
+ *
19
+ * Modelled on claude-code's `outputFile` discipline (LocalAgentTask).
20
+ *
21
+ * Format: one JSON object per line:
22
+ * { t: <ms epoch>, type: <evt.type>, ...payload }
23
+ *
24
+ * Size cap:
25
+ * Bounded by MAX_BYTES (~2 MiB). When the file would exceed the cap
26
+ * the writer rotates: rename `<id>.log` → `<id>.log.1` (overwriting any
27
+ * prior `.1`), and start a fresh `.log`. We keep exactly one rotation
28
+ * slot — no log forest — because sub-agents are ephemeral and we just
29
+ * want "the recent past" to remain readable.
30
+ *
31
+ * No external lock: each sub-agent owns its own file; we never write to
32
+ * another agent's log. Multiple processes are not a concern (Yeaft is a
33
+ * single agent process).
34
+ */
35
+
36
+ import fs from 'fs';
37
+ import path from 'path';
38
+ import os from 'os';
39
+
40
+ const DEFAULT_DIR = path.join(os.homedir(), '.yeaft', 'sub-agents');
41
+ const MAX_BYTES = 2 * 1024 * 1024; // 2 MiB before rotation
42
+ const TAIL_DEFAULT_BYTES = 8 * 1024; // 8 KiB tail preview
43
+
44
+ /**
45
+ * Resolve the log file for an agentId under the chosen base directory.
46
+ * Caller controls the dir so tests can write into a tmp dir without
47
+ * stamping on ~/.yeaft.
48
+ *
49
+ * @param {string} agentId
50
+ * @param {string} [baseDir]
51
+ * @returns {string} absolute path
52
+ */
53
+ export function resolveLogPath(agentId, baseDir = DEFAULT_DIR) {
54
+ if (!agentId || typeof agentId !== 'string') {
55
+ throw new Error('resolveLogPath: agentId is required');
56
+ }
57
+ // Defensive: never let an agentId escape the dir via traversal.
58
+ if (agentId.includes('/') || agentId.includes('\\') || agentId.includes('..')) {
59
+ throw new Error(`resolveLogPath: unsafe agentId "${agentId}"`);
60
+ }
61
+ return path.join(baseDir, `${agentId}.log`);
62
+ }
63
+
64
+ /**
65
+ * Create a log sink for an agent. Returns:
66
+ * { path, write(evt), close(), tail(maxBytes?), size() }
67
+ *
68
+ * `write` is best-effort: a disk error is logged once on stderr and
69
+ * subsequent writes silently no-op for this sink. We never want a log
70
+ * failure to crash the driver.
71
+ *
72
+ * `tail(maxBytes)` returns the last N bytes of the file (as a string).
73
+ * If the file doesn't exist yet, returns ''.
74
+ *
75
+ * @param {string} agentId
76
+ * @param {string} [baseDir]
77
+ * @returns {{ path: string, write: (evt: object) => void, close: () => void, tail: (maxBytes?: number) => string, size: () => number }}
78
+ */
79
+ export function createOutputLog(agentId, baseDir = DEFAULT_DIR) {
80
+ const filePath = resolveLogPath(agentId, baseDir);
81
+ let closed = false;
82
+ let writeFailed = false;
83
+ let currentSize = 0;
84
+
85
+ // Best-effort dir creation; if it fails the writer will go into
86
+ // writeFailed mode below.
87
+ try {
88
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
89
+ } catch (err) {
90
+ writeFailed = true;
91
+ // eslint-disable-next-line no-console
92
+ console.warn(`[sub-agent] could not create log dir ${path.dirname(filePath)}: ${err?.message || err}`);
93
+ }
94
+
95
+ // If a prior log exists for this id (process restart, name reuse with
96
+ // include_closed=false later, etc.) seed currentSize so rotation
97
+ // calculations are correct.
98
+ if (!writeFailed) {
99
+ try {
100
+ const st = fs.statSync(filePath);
101
+ currentSize = st.size;
102
+ } catch { /* file may not exist yet — that's fine */ }
103
+ }
104
+
105
+ function rotate() {
106
+ try {
107
+ const rotated = `${filePath}.1`;
108
+ try { fs.unlinkSync(rotated); } catch { /* may not exist */ }
109
+ fs.renameSync(filePath, rotated);
110
+ } catch (err) {
111
+ // Rotation failed — best-effort: truncate the current file so we
112
+ // don't grow without bound.
113
+ try { fs.truncateSync(filePath, 0); } catch { /* ignore */ }
114
+ // eslint-disable-next-line no-console
115
+ console.warn(`[sub-agent] log rotation failed for ${filePath}: ${err?.message || err}`);
116
+ }
117
+ currentSize = 0;
118
+ }
119
+
120
+ function write(evt) {
121
+ if (closed || writeFailed) return;
122
+ let line;
123
+ try {
124
+ const safe = serialize(evt);
125
+ line = JSON.stringify({ t: Date.now(), ...safe }) + '\n';
126
+ } catch {
127
+ // Unserializable event — try a minimal record so the timeline at
128
+ // least notes that "something happened".
129
+ line = JSON.stringify({ t: Date.now(), type: evt?.type || 'unknown', _unserializable: true }) + '\n';
130
+ }
131
+ try {
132
+ if (currentSize + line.length > MAX_BYTES) {
133
+ rotate();
134
+ }
135
+ fs.appendFileSync(filePath, line);
136
+ currentSize += line.length;
137
+ } catch (err) {
138
+ writeFailed = true;
139
+ // eslint-disable-next-line no-console
140
+ console.warn(`[sub-agent] log write failed for ${filePath}: ${err?.message || err}`);
141
+ }
142
+ }
143
+
144
+ function close() {
145
+ closed = true;
146
+ }
147
+
148
+ function tail(maxBytes = TAIL_DEFAULT_BYTES) {
149
+ if (writeFailed) return '';
150
+ try {
151
+ const st = fs.statSync(filePath);
152
+ if (st.size === 0) return '';
153
+ const fd = fs.openSync(filePath, 'r');
154
+ try {
155
+ const readLen = Math.min(maxBytes, st.size);
156
+ const buf = Buffer.alloc(readLen);
157
+ fs.readSync(fd, buf, 0, readLen, st.size - readLen);
158
+ // Drop a partial leading line so the tail starts on a clean
159
+ // record boundary (unless we read from the very start).
160
+ let text = buf.toString('utf8');
161
+ if (st.size > readLen) {
162
+ const nl = text.indexOf('\n');
163
+ if (nl >= 0) text = text.slice(nl + 1);
164
+ }
165
+ return text;
166
+ } finally {
167
+ try { fs.closeSync(fd); } catch { /* ignore */ }
168
+ }
169
+ } catch {
170
+ return '';
171
+ }
172
+ }
173
+
174
+ function size() {
175
+ return currentSize;
176
+ }
177
+
178
+ return { path: filePath, write, close, tail, size };
179
+ }
180
+
181
+ /**
182
+ * Trim event payloads so the log line stays bounded and serializable.
183
+ * We keep type + small-ish text payloads; drop heavyweight fields like
184
+ * full message arrays or binary buffers.
185
+ */
186
+ function serialize(evt) {
187
+ if (!evt || typeof evt !== 'object') {
188
+ return { type: 'unknown', value: evt == null ? null : String(evt) };
189
+ }
190
+ const out = { type: evt.type || 'unknown' };
191
+ if (evt.agentId) out.agentId = evt.agentId;
192
+ if (evt.agentName) out.agentName = evt.agentName;
193
+ if (typeof evt.text === 'string') {
194
+ out.text = evt.text.length > 2048 ? evt.text.slice(0, 2048) + '…' : evt.text;
195
+ }
196
+ if (typeof evt.content === 'string') {
197
+ out.content = evt.content.length > 2048 ? evt.content.slice(0, 2048) + '…' : evt.content;
198
+ }
199
+ if (evt.stopReason) out.stopReason = evt.stopReason;
200
+ if (evt.toolName) out.toolName = evt.toolName;
201
+ if (evt.toolUseId) out.toolUseId = evt.toolUseId;
202
+ if (evt.error) {
203
+ out.error = typeof evt.error === 'string'
204
+ ? evt.error
205
+ : (evt.error.message || String(evt.error));
206
+ }
207
+ if (evt.status) out.status = evt.status;
208
+ if (typeof evt.tokens === 'number') out.tokens = evt.tokens;
209
+ return out;
210
+ }
211
+
212
+ /**
213
+ * Read the whole log for an agent as an array of parsed records.
214
+ * Best-effort: lines that fail to parse are skipped (with `_raw`).
215
+ * Used by tests and by the optional /yeaft_fetch_sub_agent_log surface.
216
+ *
217
+ * @param {string} agentId
218
+ * @param {string} [baseDir]
219
+ * @returns {Array<object>}
220
+ */
221
+ export function readOutputLog(agentId, baseDir = DEFAULT_DIR) {
222
+ const filePath = resolveLogPath(agentId, baseDir);
223
+ try {
224
+ const text = fs.readFileSync(filePath, 'utf8');
225
+ const lines = text.split('\n').filter(Boolean);
226
+ const out = [];
227
+ for (const line of lines) {
228
+ try { out.push(JSON.parse(line)); }
229
+ catch { out.push({ _raw: line }); }
230
+ }
231
+ return out;
232
+ } catch {
233
+ return [];
234
+ }
235
+ }
236
+
237
+ export const _internals = { MAX_BYTES, TAIL_DEFAULT_BYTES, DEFAULT_DIR };