rinco-pi-hud 1.0.0
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.
- package/LICENSE +21 -0
- package/NOTICE +18 -0
- package/README.md +204 -0
- package/docs/CONTRIBUTING.md +110 -0
- package/docs/README.zh-CN.md +204 -0
- package/docs/footer.md +139 -0
- package/extensions/hud/commands/settings.ts +765 -0
- package/extensions/hud/config/config-counts.ts +87 -0
- package/extensions/hud/config/config.ts +65 -0
- package/extensions/hud/config/file-io.ts +128 -0
- package/extensions/hud/config/normalize.ts +374 -0
- package/extensions/hud/config/persist.ts +277 -0
- package/extensions/hud/config/schema.ts +303 -0
- package/extensions/hud/footer/format.ts +279 -0
- package/extensions/hud/footer/index.ts +543 -0
- package/extensions/hud/footer/layout.ts +183 -0
- package/extensions/hud/footer/render-labels.ts +305 -0
- package/extensions/hud/index.ts +466 -0
- package/extensions/hud/segments/extension-status.ts +88 -0
- package/extensions/hud/segments/git.ts +384 -0
- package/extensions/hud/segments/mcp-status.ts +25 -0
- package/extensions/hud/segments/package-version.ts +651 -0
- package/extensions/hud/segments/project-state.ts +67 -0
- package/extensions/hud/segments/runtime-definitions.ts +695 -0
- package/extensions/hud/segments/runtime.ts +167 -0
- package/extensions/hud/segments/skill-activity.ts +152 -0
- package/extensions/hud/session/context.ts +19 -0
- package/extensions/hud/session/lifecycle.ts +60 -0
- package/extensions/hud/session/live-context.ts +75 -0
- package/extensions/hud/state/index.ts +67 -0
- package/extensions/hud/state/project-refresh.ts +104 -0
- package/extensions/hud/state/telemetry.ts +261 -0
- package/extensions/hud/telemetry/codex-usage/LICENSE +21 -0
- package/extensions/hud/telemetry/codex-usage/app-server-client.ts +231 -0
- package/extensions/hud/telemetry/codex-usage/format.ts +278 -0
- package/extensions/hud/telemetry/codex-usage/index.ts +462 -0
- package/extensions/hud/telemetry/codex-usage/normalize.ts +264 -0
- package/extensions/hud/telemetry/codex-usage/query.ts +207 -0
- package/extensions/hud/telemetry/codex-usage/safety.ts +27 -0
- package/extensions/hud/telemetry/codex-usage/types.ts +137 -0
- package/extensions/hud/telemetry/format.ts +434 -0
- package/extensions/hud/telemetry/token-switch.ts +114 -0
- package/extensions/hud/ui/icons.ts +239 -0
- package/extensions/hud/ui/style.ts +276 -0
- package/package.json +56 -0
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { stripVTControlCharacters } from "node:util";
|
|
2
|
+
|
|
3
|
+
export const TRACKED_TOOL_NAMES = ["read", "write", "edit", "bash", "grep", "ls", "find"] as const;
|
|
4
|
+
|
|
5
|
+
export type TrackedToolName = (typeof TRACKED_TOOL_NAMES)[number];
|
|
6
|
+
export type ToolStatus = "running" | "completed" | "error";
|
|
7
|
+
export type AgentRunStatus = "running" | "completed";
|
|
8
|
+
|
|
9
|
+
export interface ToolRecord {
|
|
10
|
+
toolCallId: string;
|
|
11
|
+
name: string;
|
|
12
|
+
target?: string;
|
|
13
|
+
status: ToolStatus;
|
|
14
|
+
startTime: number;
|
|
15
|
+
endTime?: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface AgentRunRecord {
|
|
19
|
+
status: AgentRunStatus;
|
|
20
|
+
startTime: number;
|
|
21
|
+
endTime?: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface TelemetryState {
|
|
25
|
+
sessionName: string | undefined;
|
|
26
|
+
turnIndex: number;
|
|
27
|
+
thinkingLevel: string;
|
|
28
|
+
modelSupportsReasoning: boolean;
|
|
29
|
+
tools: readonly ToolRecord[];
|
|
30
|
+
agentRuns: readonly AgentRunRecord[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface TelemetryInitialValues {
|
|
34
|
+
sessionName?: string;
|
|
35
|
+
turnIndex?: number;
|
|
36
|
+
thinkingLevel?: string;
|
|
37
|
+
modelSupportsReasoning?: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type TelemetryUpdate =
|
|
41
|
+
| ({ type: "metadata" } & TelemetryInitialValues)
|
|
42
|
+
| { type: "turn-start"; turnIndex?: number }
|
|
43
|
+
| {
|
|
44
|
+
type: "tool-call";
|
|
45
|
+
toolCallId: string;
|
|
46
|
+
name: string;
|
|
47
|
+
args?: Record<string, unknown> | null;
|
|
48
|
+
at: number;
|
|
49
|
+
}
|
|
50
|
+
| { type: "tool-result"; toolCallId: string; isError: boolean; at: number }
|
|
51
|
+
| { type: "agent-start"; at: number }
|
|
52
|
+
| { type: "agent-end"; at: number };
|
|
53
|
+
|
|
54
|
+
export interface TelemetryStats {
|
|
55
|
+
completedToolCounts: Record<TrackedToolName, number>;
|
|
56
|
+
recentRunningTools: readonly ToolRecord[];
|
|
57
|
+
activeAgentRuns: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const trackedToolNames = new Set<string>(TRACKED_TOOL_NAMES);
|
|
61
|
+
const TOOL_HISTORY_LIMIT = 500;
|
|
62
|
+
const TOOL_HISTORY_RETAINED = 400;
|
|
63
|
+
const AGENT_HISTORY_LIMIT = 500;
|
|
64
|
+
const AGENT_HISTORY_RETAINED = 400;
|
|
65
|
+
const MAX_EXTERNAL_TEXT_LENGTH = 2048;
|
|
66
|
+
const thinkingLevels = new Set(["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
|
|
67
|
+
|
|
68
|
+
function sanitizeExternalText(value: string, maxLength = 256): string {
|
|
69
|
+
return stripVTControlCharacters(value.slice(0, MAX_EXTERNAL_TEXT_LENGTH))
|
|
70
|
+
.replace(/[\r\n\t\f\v]+/g, " ")
|
|
71
|
+
.replace(/[\u0000-\u001f\u007f-\u009f]/g, "")
|
|
72
|
+
.replace(/ +/g, " ")
|
|
73
|
+
.trim()
|
|
74
|
+
.slice(0, maxLength);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function normalizeThinkingLevel(value: string | undefined): string {
|
|
78
|
+
return value && thinkingLevels.has(value) ? value : "off";
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function createTelemetryState(initial: TelemetryInitialValues = {}): TelemetryState {
|
|
82
|
+
return {
|
|
83
|
+
sessionName: initial.sessionName ? sanitizeExternalText(initial.sessionName) || undefined : undefined,
|
|
84
|
+
turnIndex: initial.turnIndex ?? 0,
|
|
85
|
+
thinkingLevel: normalizeThinkingLevel(initial.thinkingLevel),
|
|
86
|
+
modelSupportsReasoning: initial.modelSupportsReasoning ?? false,
|
|
87
|
+
tools: [],
|
|
88
|
+
agentRuns: [],
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function resetTelemetryState(
|
|
93
|
+
_state: TelemetryState,
|
|
94
|
+
initial: TelemetryInitialValues = {},
|
|
95
|
+
): TelemetryState {
|
|
96
|
+
return createTelemetryState(initial);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function sanitizeToolTarget(value: string): string | undefined {
|
|
100
|
+
return sanitizeExternalText(value, 512) || undefined;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Look up the sanitized `target` (e.g. file path) recorded for a tool call at
|
|
105
|
+
* `tool_execution_start` time. `ToolExecutionEndEvent` does not carry `args`,
|
|
106
|
+
* so callers that need the original arguments (e.g. to detect which file a
|
|
107
|
+
* `read` call touched) must recover them from telemetry state instead of the
|
|
108
|
+
* end event.
|
|
109
|
+
*/
|
|
110
|
+
export function findToolTarget(state: TelemetryState, toolCallId: string): string | undefined {
|
|
111
|
+
const sanitizedId = sanitizeExternalText(toolCallId, 256);
|
|
112
|
+
for (let index = state.tools.length - 1; index >= 0; index -= 1) {
|
|
113
|
+
const tool = state.tools[index];
|
|
114
|
+
if (tool?.toolCallId === sanitizedId) return tool.target;
|
|
115
|
+
}
|
|
116
|
+
return undefined;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function getToolTarget(args: Record<string, unknown> | null | undefined): string | undefined {
|
|
120
|
+
if (!args) return undefined;
|
|
121
|
+
const value = typeof args.path === "string"
|
|
122
|
+
? args.path
|
|
123
|
+
: typeof args.filePath === "string"
|
|
124
|
+
? args.filePath
|
|
125
|
+
: undefined;
|
|
126
|
+
return value === undefined ? undefined : sanitizeToolTarget(value);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function updateMetadata(
|
|
130
|
+
state: TelemetryState,
|
|
131
|
+
update: Extract<TelemetryUpdate, { type: "metadata" }>,
|
|
132
|
+
): TelemetryState {
|
|
133
|
+
return {
|
|
134
|
+
...state,
|
|
135
|
+
...(Object.hasOwn(update, "sessionName")
|
|
136
|
+
? {
|
|
137
|
+
sessionName: update.sessionName
|
|
138
|
+
? sanitizeExternalText(update.sessionName) || undefined
|
|
139
|
+
: undefined,
|
|
140
|
+
}
|
|
141
|
+
: {}),
|
|
142
|
+
...(Object.hasOwn(update, "turnIndex") ? { turnIndex: update.turnIndex ?? 0 } : {}),
|
|
143
|
+
...(Object.hasOwn(update, "thinkingLevel")
|
|
144
|
+
? { thinkingLevel: normalizeThinkingLevel(update.thinkingLevel) }
|
|
145
|
+
: {}),
|
|
146
|
+
...(Object.hasOwn(update, "modelSupportsReasoning")
|
|
147
|
+
? { modelSupportsReasoning: update.modelSupportsReasoning ?? false }
|
|
148
|
+
: {}),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function addTool(
|
|
153
|
+
state: TelemetryState,
|
|
154
|
+
update: Extract<TelemetryUpdate, { type: "tool-call" }>,
|
|
155
|
+
): TelemetryState {
|
|
156
|
+
const target = getToolTarget(update.args);
|
|
157
|
+
const record: ToolRecord = {
|
|
158
|
+
toolCallId: sanitizeExternalText(update.toolCallId, 256),
|
|
159
|
+
name: sanitizeExternalText(update.name, 64) || "tool",
|
|
160
|
+
...(target === undefined ? {} : { target }),
|
|
161
|
+
status: "running",
|
|
162
|
+
startTime: update.at,
|
|
163
|
+
};
|
|
164
|
+
let tools = [...state.tools, record];
|
|
165
|
+
if (tools.length > TOOL_HISTORY_LIMIT) tools = tools.slice(-TOOL_HISTORY_RETAINED);
|
|
166
|
+
return { ...state, tools };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function finishTool(
|
|
170
|
+
state: TelemetryState,
|
|
171
|
+
update: Extract<TelemetryUpdate, { type: "tool-result" }>,
|
|
172
|
+
): TelemetryState {
|
|
173
|
+
const toolCallId = sanitizeExternalText(update.toolCallId, 256);
|
|
174
|
+
let matchIndex = -1;
|
|
175
|
+
for (let index = state.tools.length - 1; index >= 0; index -= 1) {
|
|
176
|
+
const tool = state.tools[index];
|
|
177
|
+
if (tool?.toolCallId === toolCallId && tool.status === "running") {
|
|
178
|
+
matchIndex = index;
|
|
179
|
+
break;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (matchIndex < 0) return state;
|
|
183
|
+
|
|
184
|
+
const tools = [...state.tools];
|
|
185
|
+
tools[matchIndex] = {
|
|
186
|
+
...tools[matchIndex]!,
|
|
187
|
+
status: update.isError ? "error" : "completed",
|
|
188
|
+
endTime: update.at,
|
|
189
|
+
};
|
|
190
|
+
return { ...state, tools };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function finishLatestAgentRun(state: TelemetryState, at: number): TelemetryState {
|
|
194
|
+
let matchIndex = -1;
|
|
195
|
+
for (let index = state.agentRuns.length - 1; index >= 0; index -= 1) {
|
|
196
|
+
if (state.agentRuns[index]?.status === "running") {
|
|
197
|
+
matchIndex = index;
|
|
198
|
+
break;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
if (matchIndex < 0) return state;
|
|
202
|
+
|
|
203
|
+
const agentRuns = [...state.agentRuns];
|
|
204
|
+
agentRuns[matchIndex] = { ...agentRuns[matchIndex]!, status: "completed", endTime: at };
|
|
205
|
+
return { ...state, agentRuns };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function updateTelemetryState(state: TelemetryState, update: TelemetryUpdate): TelemetryState {
|
|
209
|
+
switch (update.type) {
|
|
210
|
+
case "metadata":
|
|
211
|
+
return updateMetadata(state, update);
|
|
212
|
+
case "turn-start":
|
|
213
|
+
return { ...state, turnIndex: update.turnIndex ?? state.turnIndex + 1 };
|
|
214
|
+
case "tool-call":
|
|
215
|
+
return addTool(state, update);
|
|
216
|
+
case "tool-result":
|
|
217
|
+
return finishTool(state, update);
|
|
218
|
+
case "agent-start": {
|
|
219
|
+
let agentRuns = [...state.agentRuns, { status: "running" as const, startTime: update.at }];
|
|
220
|
+
if (agentRuns.length > AGENT_HISTORY_LIMIT) {
|
|
221
|
+
agentRuns = agentRuns.slice(-AGENT_HISTORY_RETAINED);
|
|
222
|
+
}
|
|
223
|
+
return { ...state, agentRuns };
|
|
224
|
+
}
|
|
225
|
+
case "agent-end":
|
|
226
|
+
return finishLatestAgentRun(state, update.at);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export function getTelemetryStats(state: TelemetryState): TelemetryStats {
|
|
231
|
+
const completedToolCounts: Record<TrackedToolName, number> = {
|
|
232
|
+
read: 0,
|
|
233
|
+
write: 0,
|
|
234
|
+
edit: 0,
|
|
235
|
+
bash: 0,
|
|
236
|
+
grep: 0,
|
|
237
|
+
ls: 0,
|
|
238
|
+
find: 0,
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
for (const tool of state.tools) {
|
|
242
|
+
if (tool.status === "completed" && trackedToolNames.has(tool.name)) {
|
|
243
|
+
completedToolCounts[tool.name as TrackedToolName] += 1;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const recentRunningTools = state.tools
|
|
248
|
+
.filter((tool) => tool.status === "running")
|
|
249
|
+
.slice(-2)
|
|
250
|
+
.map((tool) => ({ ...tool }));
|
|
251
|
+
const activeAgentRuns = state.agentRuns.reduce(
|
|
252
|
+
(count, run) => count + (run.status === "running" ? 1 : 0),
|
|
253
|
+
0,
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
return { completedToolCounts, recentRunningTools, activeAgentRuns };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export const createTelemetry = createTelemetryState;
|
|
260
|
+
export const resetTelemetry = resetTelemetryState;
|
|
261
|
+
export const updateTelemetry = updateTelemetryState;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 narumiruna
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
|
|
2
|
+
import { normalizeAppServerResponse } from "./normalize.ts";
|
|
3
|
+
import { safeCodexErrorMessage } from "./safety.ts";
|
|
4
|
+
import type {
|
|
5
|
+
AppServerRateLimitResponse,
|
|
6
|
+
CodexUsageReport,
|
|
7
|
+
PendingRpc,
|
|
8
|
+
RpcResponse,
|
|
9
|
+
} from "./types.ts";
|
|
10
|
+
|
|
11
|
+
const MAX_ERROR_BODY_CHARS = 600;
|
|
12
|
+
const MAX_RPC_LINE_BYTES = 64 * 1024;
|
|
13
|
+
|
|
14
|
+
export async function queryViaCodexAppServer(
|
|
15
|
+
timeoutMs: number,
|
|
16
|
+
signal?: AbortSignal,
|
|
17
|
+
): Promise<CodexUsageReport> {
|
|
18
|
+
const client = new CodexAppServerClient(timeoutMs, signal);
|
|
19
|
+
try {
|
|
20
|
+
await client.start();
|
|
21
|
+
await client.request("initialize", {
|
|
22
|
+
clientInfo: {
|
|
23
|
+
name: "pi_codex_usage",
|
|
24
|
+
title: "Pi Codex Usage",
|
|
25
|
+
version: "0.1.0",
|
|
26
|
+
},
|
|
27
|
+
capabilities: {
|
|
28
|
+
experimentalApi: false,
|
|
29
|
+
requestAttestation: false,
|
|
30
|
+
optOutNotificationMethods: [],
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
client.notify("initialized");
|
|
34
|
+
const result = await client.request("account/rateLimits/read", undefined);
|
|
35
|
+
return normalizeAppServerResponse(
|
|
36
|
+
assertObject(result, "account/rateLimits/read result") as AppServerRateLimitResponse,
|
|
37
|
+
Date.now(),
|
|
38
|
+
);
|
|
39
|
+
} finally {
|
|
40
|
+
client.dispose();
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
class CodexAppServerClient {
|
|
45
|
+
private child?: ChildProcessWithoutNullStreams;
|
|
46
|
+
private nextId = 1;
|
|
47
|
+
private stderr = "";
|
|
48
|
+
private stdoutBuffer = "";
|
|
49
|
+
private readonly pending = new Map<number, PendingRpc>();
|
|
50
|
+
private startPromise?: Promise<void>;
|
|
51
|
+
private exitError?: Error;
|
|
52
|
+
private readonly timeoutMs: number;
|
|
53
|
+
private readonly signal?: AbortSignal;
|
|
54
|
+
private readonly abortHandler = () => {
|
|
55
|
+
this.rejectAll(new Error("Codex app-server query was cancelled."));
|
|
56
|
+
this.dispose();
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
constructor(timeoutMs: number, signal?: AbortSignal) {
|
|
60
|
+
this.timeoutMs = timeoutMs;
|
|
61
|
+
this.signal = signal;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
start(): Promise<void> {
|
|
65
|
+
if (this.startPromise) return this.startPromise;
|
|
66
|
+
if (this.signal?.aborted) return Promise.reject(new Error("Codex app-server query was cancelled."));
|
|
67
|
+
this.signal?.addEventListener("abort", this.abortHandler, { once: true });
|
|
68
|
+
|
|
69
|
+
this.startPromise = new Promise((resolve, reject) => {
|
|
70
|
+
const child = spawn("codex", ["app-server", "--listen", "stdio://"], {
|
|
71
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
72
|
+
});
|
|
73
|
+
this.child = child;
|
|
74
|
+
|
|
75
|
+
const startupTimeout = setTimeout(() => {
|
|
76
|
+
reject(
|
|
77
|
+
new Error(
|
|
78
|
+
`Timed out after ${Math.round(this.timeoutMs / 1000)}s starting codex app-server.`,
|
|
79
|
+
),
|
|
80
|
+
);
|
|
81
|
+
}, this.timeoutMs);
|
|
82
|
+
|
|
83
|
+
child.once("spawn", () => {
|
|
84
|
+
clearTimeout(startupTimeout);
|
|
85
|
+
resolve();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
child.once("error", (error) => {
|
|
89
|
+
clearTimeout(startupTimeout);
|
|
90
|
+
reject(new Error(`Failed to start codex app-server: ${error.message}`));
|
|
91
|
+
this.rejectAll(error);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
child.once("exit", (code, signal) => {
|
|
95
|
+
this.exitError = new Error(
|
|
96
|
+
`codex app-server exited before completing the request (code ${code ?? "unknown"}, signal ${signal ?? "none"}).`,
|
|
97
|
+
);
|
|
98
|
+
this.rejectAll(this.exitError);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
child.stderr.setEncoding("utf8");
|
|
102
|
+
child.stderr.on("data", (chunk: string) => {
|
|
103
|
+
this.stderr = truncateEnd(`${this.stderr}${chunk.slice(0, MAX_ERROR_BODY_CHARS)}`, MAX_ERROR_BODY_CHARS);
|
|
104
|
+
});
|
|
105
|
+
child.stderr.on("error", () => undefined);
|
|
106
|
+
child.stdin.on("error", (error) => this.rejectAll(error));
|
|
107
|
+
child.stdout.setEncoding("utf8");
|
|
108
|
+
child.stdout.on("data", (chunk: string) => this.handleChunk(chunk));
|
|
109
|
+
child.stdout.on("error", (error) => this.rejectAll(error));
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
return this.startPromise;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
request(method: string, params: unknown): Promise<unknown> {
|
|
116
|
+
const child = this.child;
|
|
117
|
+
if (!child?.stdin.writable) {
|
|
118
|
+
throw new Error("codex app-server is not running.");
|
|
119
|
+
}
|
|
120
|
+
if (this.exitError) throw this.exitError;
|
|
121
|
+
|
|
122
|
+
const id = this.nextId++;
|
|
123
|
+
const payload = params === undefined ? { method, id } : { method, id, params };
|
|
124
|
+
const response = new Promise<unknown>((resolve, reject) => {
|
|
125
|
+
const timeout = setTimeout(() => {
|
|
126
|
+
this.pending.delete(id);
|
|
127
|
+
reject(
|
|
128
|
+
new Error(`Timed out after ${Math.round(this.timeoutMs / 1000)}s waiting for ${method}.`),
|
|
129
|
+
);
|
|
130
|
+
}, this.timeoutMs);
|
|
131
|
+
|
|
132
|
+
this.pending.set(id, {
|
|
133
|
+
resolve: (value) => {
|
|
134
|
+
clearTimeout(timeout);
|
|
135
|
+
resolve(value);
|
|
136
|
+
},
|
|
137
|
+
reject: (error) => {
|
|
138
|
+
clearTimeout(timeout);
|
|
139
|
+
reject(error);
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
child.stdin.write(`${JSON.stringify(payload)}\n`);
|
|
145
|
+
return response;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
notify(method: string): void {
|
|
149
|
+
const child = this.child;
|
|
150
|
+
if (!child?.stdin.writable) return;
|
|
151
|
+
child.stdin.write(`${JSON.stringify({ method })}\n`);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
dispose(): void {
|
|
155
|
+
this.signal?.removeEventListener("abort", this.abortHandler);
|
|
156
|
+
for (const [id, pending] of this.pending) {
|
|
157
|
+
pending.reject(new Error(`codex app-server request ${id} cancelled.`));
|
|
158
|
+
}
|
|
159
|
+
this.pending.clear();
|
|
160
|
+
|
|
161
|
+
const child = this.child;
|
|
162
|
+
if (!child) return;
|
|
163
|
+
child.stdin.end();
|
|
164
|
+
if (child.exitCode === null && child.signalCode === null) {
|
|
165
|
+
child.kill("SIGTERM");
|
|
166
|
+
const forceKill = setTimeout(() => {
|
|
167
|
+
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
|
|
168
|
+
}, 1000);
|
|
169
|
+
forceKill.unref?.();
|
|
170
|
+
}
|
|
171
|
+
this.child = undefined;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
private handleChunk(chunk: string): void {
|
|
175
|
+
this.stdoutBuffer += chunk.slice(0, MAX_RPC_LINE_BYTES + 1);
|
|
176
|
+
if (this.stdoutBuffer.length > MAX_RPC_LINE_BYTES) {
|
|
177
|
+
this.rejectAll(new Error("codex app-server response exceeded the size limit."));
|
|
178
|
+
this.dispose();
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
let newline = this.stdoutBuffer.indexOf("\n");
|
|
182
|
+
while (newline >= 0) {
|
|
183
|
+
const line = this.stdoutBuffer.slice(0, newline);
|
|
184
|
+
this.stdoutBuffer = this.stdoutBuffer.slice(newline + 1);
|
|
185
|
+
this.handleLine(line);
|
|
186
|
+
newline = this.stdoutBuffer.indexOf("\n");
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
private handleLine(line: string): void {
|
|
191
|
+
let parsed: RpcResponse;
|
|
192
|
+
try {
|
|
193
|
+
parsed = JSON.parse(line) as RpcResponse;
|
|
194
|
+
} catch {
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (typeof parsed.id !== "number") return;
|
|
199
|
+
const pending = this.pending.get(parsed.id);
|
|
200
|
+
if (!pending) return;
|
|
201
|
+
this.pending.delete(parsed.id);
|
|
202
|
+
|
|
203
|
+
if (parsed.error) {
|
|
204
|
+
const message =
|
|
205
|
+
typeof parsed.error.message === "string"
|
|
206
|
+
? safeCodexErrorMessage(parsed.error.message)
|
|
207
|
+
: "unknown error";
|
|
208
|
+
pending.reject(new Error(`codex app-server request failed: ${message}`));
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
pending.resolve(parsed.result);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
private rejectAll(error: Error): void {
|
|
216
|
+
for (const pending of this.pending.values()) pending.reject(error);
|
|
217
|
+
this.pending.clear();
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function assertObject(value: unknown, description: string): Record<string, unknown> {
|
|
222
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
223
|
+
throw new Error(`${description} was not an object.`);
|
|
224
|
+
}
|
|
225
|
+
return value as Record<string, unknown>;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function truncateEnd(value: string, maxChars: number): string {
|
|
229
|
+
if (value.length <= maxChars) return value;
|
|
230
|
+
return `${value.slice(0, maxChars - 1)}…`;
|
|
231
|
+
}
|