|
1
|
+ |
import { readFileSync, statSync } from "node:fs";
|
|
2
|
+ |
|
|
3
|
+ |
import { parseOpencodeEvent } from "./coder-delegate.js";
|
|
4
|
+ |
|
|
5
|
+ |
/**
|
|
6
|
+ |
* A child's own transcript, read back for the interface to show.
|
|
7
|
+ |
*
|
|
8
|
+ |
* Every harness writes one as its child runs — the fleet hands each child a
|
|
9
|
+ |
* path and the harness appends to it, so a child that is killed still leaves
|
|
10
|
+ |
* everything it had done behind. This reads that file so the reader can open a
|
|
11
|
+ |
* child and watch it work rather than waiting for the one paragraph it returns
|
|
12
|
+ |
* at the end.
|
|
13
|
+ |
*
|
|
14
|
+ |
* Two shapes arrive here and neither is negotiable. The self-hosted harness
|
|
15
|
+ |
* writes its own small records (`session`, `tool`, `tool_result`, `text`);
|
|
16
|
+ |
* `opencode` writes its own event stream, which `parseOpencodeEvent` already
|
|
17
|
+ |
* knows how to read because the fleet reads it live. Both are normalized to
|
|
18
|
+ |
* the same handful of entries, so the screen renders one thing.
|
|
19
|
+ |
*
|
|
20
|
+ |
* A file is re-read when it has grown and reused when it has not. A child is
|
|
21
|
+ |
* appended to several times a second and the screen repaints at least as
|
|
22
|
+ |
* often; re-parsing a finished child's whole transcript on every frame is work
|
|
23
|
+ |
* that buys nothing.
|
|
24
|
+ |
*/
|
|
25
|
+ |
|
|
26
|
+ |
export type ChildEntry =
|
|
27
|
+ |
| { readonly kind: "started"; readonly model: string; readonly cwd: string }
|
|
28
|
+ |
| { readonly kind: "tool"; readonly name: string; readonly target: string | undefined }
|
|
29
|
+ |
| { readonly kind: "output"; readonly text: string }
|
|
30
|
+ |
| { readonly kind: "text"; readonly text: string }
|
|
31
|
+ |
| { readonly kind: "error"; readonly text: string };
|
|
32
|
+ |
|
|
33
|
+ |
interface Cached {
|
|
34
|
+ |
readonly size: number;
|
|
35
|
+ |
readonly entries: ReadonlyArray<ChildEntry>;
|
|
36
|
+ |
}
|
|
37
|
+ |
|
|
38
|
+ |
/**
|
|
39
|
+ |
* What has been read, by path, bounded.
|
|
40
|
+ |
*
|
|
41
|
+ |
* A session's children are few, but nothing here prunes on its own and a cache
|
|
42
|
+ |
* that only grows is one that eventually holds every transcript of a long
|
|
43
|
+ |
* session in memory. The oldest entry goes when the cap is reached; re-reading
|
|
44
|
+ |
* it costs one parse.
|
|
45
|
+ |
*/
|
|
46
|
+ |
const CACHE_LIMIT = 32;
|
|
47
|
+ |
const cache = new Map<string, Cached>();
|
|
48
|
+ |
|
|
49
|
+ |
const remember = (path: string, entry: Cached): void => {
|
|
50
|
+ |
cache.delete(path);
|
|
51
|
+ |
cache.set(path, entry);
|
|
52
|
+ |
if (cache.size > CACHE_LIMIT) {
|
|
53
|
+ |
const oldest = cache.keys().next();
|
|
54
|
+ |
if (!oldest.done) cache.delete(oldest.value);
|
|
55
|
+ |
}
|
|
56
|
+ |
};
|
|
57
|
+ |
|
|
58
|
+ |
/**
|
|
59
|
+ |
* The child's transcript at this path, or an empty list.
|
|
60
|
+ |
*
|
|
61
|
+ |
* Never throws. A child that has not written yet, a path that was removed, and
|
|
62
|
+ |
* a line that is half-written because the harness is mid-append are all the
|
|
63
|
+ |
* same thing to a reader watching a running child: less than there will be in
|
|
64
|
+ |
* a moment.
|
|
65
|
+ |
*/
|
|
66
|
+ |
export const readChildTranscript = (path: string | undefined): ReadonlyArray<ChildEntry> => {
|
|
67
|
+ |
if (path === undefined) return [];
|
|
68
|
+ |
|
|
69
|
+ |
let size: number;
|
|
70
|
+ |
try {
|
|
71
|
+ |
size = statSync(path).size;
|
|
72
|
+ |
} catch {
|
|
73
|
+ |
return [];
|
|
74
|
+ |
}
|
|
75
|
+ |
|
|
76
|
+ |
const seen = cache.get(path);
|
|
77
|
+ |
if (seen !== undefined && seen.size === size) return seen.entries;
|
|
78
|
+ |
|
|
79
|
+ |
let contents: string;
|
|
80
|
+ |
try {
|
|
81
|
+ |
contents = readFileSync(path, "utf8");
|
|
82
|
+ |
} catch {
|
|
83
|
+ |
return seen?.entries ?? [];
|
|
84
|
+ |
}
|
|
85
|
+ |
|
|
86
|
+ |
const entries = contents.split("\n").flatMap((line) => {
|
|
87
|
+ |
const entry = parseLine(line);
|
|
88
|
+ |
return entry === undefined ? [] : [entry];
|
|
89
|
+ |
});
|
|
90
|
+ |
|
|
91
|
+ |
remember(path, { size, entries });
|
|
92
|
+ |
return entries;
|
|
93
|
+ |
};
|
|
94
|
+ |
|
|
95
|
+ |
const parseLine = (line: string): ChildEntry | undefined => {
|
|
96
|
+ |
const trimmed = line.trim();
|
|
97
|
+ |
if (trimmed.length === 0 || !trimmed.startsWith("{")) return undefined;
|
|
98
|
+ |
|
|
99
|
+ |
let record: Record<string, unknown>;
|
|
100
|
+ |
try {
|
|
101
|
+ |
record = JSON.parse(trimmed) as Record<string, unknown>;
|
|
102
|
+ |
} catch {
|
|
103
|
+ |
// A half-written last line, because the harness is appending as this
|
|
104
|
+ |
// reads. It will parse on the next frame.
|
|
105
|
+ |
return undefined;
|
|
106
|
+ |
}
|
|
107
|
+ |
|
|
108
|
+ |
const own = fromSelfHarness(record);
|
|
109
|
+ |
if (own !== undefined) return own;
|
|
110
|
+ |
|
|
111
|
+ |
// Anything else is a harness with its own event stream, and `opencode`'s is
|
|
112
|
+ |
// the one the fleet already reads live.
|
|
113
|
+ |
const event = parseOpencodeEvent(trimmed);
|
|
114
|
+ |
if (event === undefined) return undefined;
|
|
115
|
+ |
|
|
116
|
+ |
switch (event.type) {
|
|
117
|
+ |
case "tool":
|
|
118
|
+ |
return { kind: "tool", name: event.name, target: event.target };
|
|
119
|
+ |
case "text":
|
|
120
|
+ |
return { kind: "text", text: event.value };
|
|
121
|
+ |
case "error":
|
|
122
|
+ |
return { kind: "error", text: event.message };
|
|
123
|
+ |
default:
|
|
124
|
+ |
return undefined;
|
|
125
|
+ |
}
|
|
126
|
+ |
};
|
|
127
|
+ |
|
|
128
|
+ |
const fromSelfHarness = (record: Record<string, unknown>): ChildEntry | undefined => {
|
|
129
|
+ |
const type = record["type"];
|
|
130
|
+ |
|
|
131
|
+ |
if (type === "session") {
|
|
132
|
+ |
return {
|
|
133
|
+ |
kind: "started",
|
|
134
|
+ |
model: text(record["model"]) ?? "unknown",
|
|
135
|
+ |
cwd: text(record["cwd"]) ?? "",
|
|
136
|
+ |
};
|
|
137
|
+ |
}
|
|
138
|
+ |
|
|
139
|
+ |
if (type === "tool") {
|
|
140
|
+ |
const name = text(record["name"]);
|
|
141
|
+ |
if (name === undefined) return undefined;
|
|
142
|
+ |
return { kind: "tool", name, target: target(record["arguments"]) };
|
|
143
|
+ |
}
|
|
144
|
+ |
|
|
145
|
+ |
if (type === "tool_result") {
|
|
146
|
+ |
const output = text(record["output"]);
|
|
147
|
+ |
return output === undefined ? undefined : { kind: "output", text: output };
|
|
148
|
+ |
}
|
|
149
|
+ |
|
|
150
|
+ |
if (type === "text") {
|
|
151
|
+ |
const value = text(record["value"]);
|
|
152
|
+ |
return value === undefined ? undefined : { kind: "text", text: value };
|
|
153
|
+ |
}
|
|
154
|
+ |
|
|
155
|
+ |
return undefined;
|
|
156
|
+ |
};
|
|
157
|
+ |
|
|
158
|
+ |
/** The one argument worth showing beside a tool's name, if there is one. */
|
|
159
|
+ |
const target = (args: unknown): string | undefined => {
|
|
160
|
+ |
if (typeof args !== "object" || args === null) return undefined;
|
|
161
|
+ |
const record = args as Record<string, unknown>;
|
|
162
|
+ |
for (const key of ["command", "path", "file", "pattern", "query"]) {
|
|
163
|
+ |
const value = record[key];
|
|
164
|
+ |
if (typeof value === "string" && value.length > 0) return value;
|
|
165
|
+ |
}
|
|
166
|
+ |
return undefined;
|
|
167
|
+ |
};
|
|
168
|
+ |
|
|
169
|
+ |
const text = (value: unknown): string | undefined =>
|
|
170
|
+ |
typeof value === "string" && value.length > 0 ? value : undefined;
|