@neta-art/cohub-cli 7.0.1 → 7.1.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.
@@ -0,0 +1,382 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { createReadStream } from "node:fs";
3
+ import { mkdir, open, readdir, rename, rm, stat } from "node:fs/promises";
4
+ import { join } from "node:path";
5
+ import { StringDecoder } from "node:string_decoder";
6
+ const DEFAULT_LOG_FLUSH_INTERVAL_MS = 250;
7
+ const MAX_PENDING_LOG_BYTES = 4 * 1024 * 1024;
8
+ const DEFAULT_MAX_LOG_FILE_BYTES = 8 * 1024 * 1024;
9
+ const DEFAULT_MAX_TOTAL_LOG_BYTES = 64 * 1024 * 1024;
10
+ const DEFAULT_MAX_LOG_FILE_AGE_MS = 60 * 60 * 1_000;
11
+ const MAX_VALUE_STRING_LENGTH = 4_096;
12
+ const MAX_EVENT_BYTES = 48 * 1024;
13
+ const SENSITIVE_KEY = /authorization|cookie|password|secret|token|access[_-]?key|refresh[_-]?token/i;
14
+ const TRACEPARENT = /^[0-9a-f]{2}-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$/i;
15
+ const diagnosticsDirectoryName = "diagnostics";
16
+ export const runtimeDiagnosticsDirectory = (root) => join(root, diagnosticsDirectoryName);
17
+ export const runtimeDiagnosticsPath = (root, runtimeId) => join(runtimeDiagnosticsDirectory(root), `${runtimeId}.jsonl`);
18
+ function isRecord(value) {
19
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
20
+ }
21
+ function redactUrl(value) {
22
+ try {
23
+ const url = new URL(value);
24
+ for (const key of [...url.searchParams.keys()]) {
25
+ if (SENSITIVE_KEY.test(key))
26
+ url.searchParams.set(key, "[REDACTED]");
27
+ }
28
+ return url.toString();
29
+ }
30
+ catch {
31
+ return value;
32
+ }
33
+ }
34
+ export function redactDiagnosticString(value) {
35
+ const shortened = value.length > MAX_VALUE_STRING_LENGTH
36
+ ? `${value.slice(0, MAX_VALUE_STRING_LENGTH)}…`
37
+ : value;
38
+ return shortened
39
+ .replace(/\bBearer\s+[^\s,;]+/gi, "Bearer [REDACTED]")
40
+ .replace(/\b(rediss?|https?|wss?):\/\/[^\s]+/gi, (url) => redactUrl(url))
41
+ .replace(/\b(authorization|password|secret|token|access[_-]?key|refresh[_-]?token)([\s:=]+)([^\s,;]+)/gi, "$1$2[REDACTED]");
42
+ }
43
+ function redactValue(value, seen = new WeakSet(), depth = 0) {
44
+ if (value == null || typeof value === "boolean" || typeof value === "number")
45
+ return value;
46
+ if (typeof value === "string")
47
+ return redactDiagnosticString(value);
48
+ if (depth > 5)
49
+ return "[Truncated]";
50
+ if (typeof value !== "object")
51
+ return String(value);
52
+ if (seen.has(value))
53
+ return "[Circular]";
54
+ seen.add(value);
55
+ if (Array.isArray(value))
56
+ return value.slice(0, 64).map((item) => redactValue(item, seen, depth + 1));
57
+ const output = {};
58
+ for (const [key, nested] of Object.entries(value)) {
59
+ output[key] = SENSITIVE_KEY.test(key) ? "[REDACTED]" : redactValue(nested, seen, depth + 1);
60
+ }
61
+ return output;
62
+ }
63
+ function normalizeDiagnosticTraceContext(value) {
64
+ if (!isRecord(value))
65
+ return undefined;
66
+ const requestId = typeof value.requestId === "string" && /^[a-zA-Z0-9._:-]{1,128}$/.test(value.requestId)
67
+ ? value.requestId
68
+ : undefined;
69
+ const traceId = typeof value.traceId === "string" && /^[0-9a-f]{32}$/i.test(value.traceId) ? value.traceId : undefined;
70
+ const spanId = typeof value.spanId === "string" && /^[0-9a-f]{16}$/i.test(value.spanId) ? value.spanId : undefined;
71
+ const traceparent = typeof value.traceparent === "string" && TRACEPARENT.test(value.traceparent) ? value.traceparent : undefined;
72
+ if (!requestId && !traceId && !spanId && !traceparent)
73
+ return undefined;
74
+ return {
75
+ ...(requestId ? { requestId } : {}),
76
+ ...(traceId ? { traceId } : {}),
77
+ ...(spanId ? { spanId } : {}),
78
+ ...(traceparent ? { traceparent } : {}),
79
+ };
80
+ }
81
+ export function serializeDiagnosticError(error) {
82
+ if (!(error instanceof Error))
83
+ return { message: redactDiagnosticString(String(error)) };
84
+ const source = error;
85
+ const result = {
86
+ message: error.message || error.name || "Unknown error",
87
+ ...(error.name ? { name: error.name } : {}),
88
+ ...(error.stack ? { stack: error.stack } : {}),
89
+ };
90
+ for (const key of ["code", "status", "syscall", "hostname"]) {
91
+ const value = source[key];
92
+ if (typeof value === "string" || typeof value === "number")
93
+ result[key] = value;
94
+ }
95
+ const cause = isRecord(source.cause) ? source.cause : null;
96
+ const traceContext = normalizeDiagnosticTraceContext(source.traceContext);
97
+ if (traceContext)
98
+ result.traceContext = traceContext;
99
+ if (typeof cause?.code === "string")
100
+ result.causeCode = cause.code;
101
+ if (typeof cause?.message === "string")
102
+ result.causeMessage = cause.message;
103
+ if (typeof cause?.syscall === "string")
104
+ result.causeSyscall = cause.syscall;
105
+ if (typeof cause?.hostname === "string")
106
+ result.causeHostname = cause.hostname;
107
+ return redactValue(result);
108
+ }
109
+ function compactEvent(event) {
110
+ let result = redactValue(event);
111
+ if (Buffer.byteLength(JSON.stringify(result), "utf8") <= MAX_EVENT_BYTES)
112
+ return result;
113
+ result = {
114
+ ...result,
115
+ data: { truncated: true },
116
+ ...(result.error ? { error: { ...result.error, stack: undefined } } : {}),
117
+ };
118
+ if (Buffer.byteLength(JSON.stringify(result), "utf8") <= MAX_EVENT_BYTES)
119
+ return result;
120
+ return {
121
+ ...result,
122
+ data: undefined,
123
+ error: result.error ? { message: result.error.message, name: result.error.name } : undefined,
124
+ };
125
+ }
126
+ function compareDiagnosticEvents(left, right) {
127
+ if (left.runtimeId === right.runtimeId)
128
+ return left.sequence - right.sequence;
129
+ return left.timestamp.localeCompare(right.timestamp) || left.runtimeId.localeCompare(right.runtimeId) || left.sequence - right.sequence;
130
+ }
131
+ export class RuntimeDiagnostics {
132
+ runtimeId;
133
+ directory;
134
+ logPath;
135
+ spaceId;
136
+ component;
137
+ logFlushIntervalMs;
138
+ maxLogFileBytes;
139
+ maxTotalLogBytes;
140
+ startedAtMs = Date.now();
141
+ ready;
142
+ logFile = null;
143
+ logFileSize = 0;
144
+ logFileStartedAt = 0;
145
+ writeChain = Promise.resolve();
146
+ writeBuffer = [];
147
+ writeBufferBytes = 0;
148
+ droppedWriteEvents = 0;
149
+ writeTimer = null;
150
+ sequence = 0;
151
+ closed = false;
152
+ constructor(options) {
153
+ this.runtimeId = options.runtimeId ?? randomUUID();
154
+ this.spaceId = options.spaceId;
155
+ this.component = options.component ?? "runtime";
156
+ this.logFlushIntervalMs = options.logFlushIntervalMs ?? DEFAULT_LOG_FLUSH_INTERVAL_MS;
157
+ this.maxLogFileBytes = options.maxLogFileBytes ?? DEFAULT_MAX_LOG_FILE_BYTES;
158
+ this.maxTotalLogBytes = options.maxTotalLogBytes ?? DEFAULT_MAX_TOTAL_LOG_BYTES;
159
+ this.directory = runtimeDiagnosticsDirectory(options.root);
160
+ this.logPath = runtimeDiagnosticsPath(options.root, this.runtimeId);
161
+ this.ready = mkdir(this.directory, { recursive: true, mode: 0o700 }).then(() => undefined);
162
+ }
163
+ log(level, event, data, context = {}) {
164
+ if (this.closed)
165
+ return;
166
+ const traceContext = normalizeDiagnosticTraceContext(context.traceContext ?? (context.requestId ? { requestId: context.requestId } : undefined));
167
+ const rawError = data?.error;
168
+ const diagnosticError = rawError instanceof Error
169
+ ? serializeDiagnosticError(rawError)
170
+ : isRecord(rawError) && typeof rawError.message === "string"
171
+ ? redactValue(rawError)
172
+ : typeof rawError === "string"
173
+ ? { message: redactDiagnosticString(rawError) }
174
+ : undefined;
175
+ const eventTraceContext = traceContext ?? normalizeDiagnosticTraceContext(diagnosticError?.traceContext);
176
+ const eventData = data ? Object.fromEntries(Object.entries(data).filter(([key]) => key !== "error")) : undefined;
177
+ const value = compactEvent({
178
+ schemaVersion: 1,
179
+ sequence: this.sequence++,
180
+ timestamp: new Date().toISOString(),
181
+ elapsedMs: Date.now() - this.startedAtMs,
182
+ level,
183
+ component: context.component ?? this.component,
184
+ event,
185
+ runtimeId: this.runtimeId,
186
+ ...(context.connectionId ? { connectionId: context.connectionId } : {}),
187
+ spaceId: context.spaceId ?? this.spaceId,
188
+ ...(context.sessionId ? { sessionId: context.sessionId } : {}),
189
+ ...(context.turnId ? { turnId: context.turnId } : {}),
190
+ ...(context.harness ? { harness: context.harness } : {}),
191
+ ...(eventTraceContext ? { traceContext: eventTraceContext } : {}),
192
+ ...(eventData && Object.keys(eventData).length > 0 ? { data: eventData } : {}),
193
+ ...(diagnosticError ? { error: diagnosticError } : {}),
194
+ });
195
+ this.enqueueWrite(value);
196
+ }
197
+ async close() {
198
+ this.closed = true;
199
+ if (this.writeTimer)
200
+ clearTimeout(this.writeTimer);
201
+ this.writeTimer = null;
202
+ await this.flushLogBuffer();
203
+ await this.writeChain;
204
+ if (this.logFile) {
205
+ const file = this.logFile;
206
+ this.logFile = null;
207
+ await file.sync().catch(() => undefined);
208
+ await file.close().catch(() => undefined);
209
+ }
210
+ }
211
+ enqueueWrite(event) {
212
+ let nextEvent = event;
213
+ if (this.droppedWriteEvents > 0) {
214
+ nextEvent = { ...event, data: { ...(event.data ?? {}), droppedDiagnosticsBeforeWrite: this.droppedWriteEvents } };
215
+ this.droppedWriteEvents = 0;
216
+ }
217
+ const line = `${JSON.stringify(nextEvent)}\n`;
218
+ const bytes = Buffer.byteLength(line, "utf8");
219
+ while (this.writeBufferBytes + bytes > MAX_PENDING_LOG_BYTES && this.writeBuffer.length > 0) {
220
+ const index = this.writeBuffer.findIndex(({ event: queued }) => queued.level === "debug" || queued.level === "info");
221
+ const removed = this.writeBuffer.splice(index >= 0 ? index : 0, 1)[0];
222
+ if (removed) {
223
+ this.writeBufferBytes -= removed.bytes;
224
+ this.droppedWriteEvents += 1;
225
+ }
226
+ }
227
+ if (this.writeBufferBytes + bytes > MAX_PENDING_LOG_BYTES && nextEvent.level !== "error") {
228
+ this.droppedWriteEvents += 1;
229
+ return;
230
+ }
231
+ this.writeBuffer.push({ event: nextEvent, line, bytes });
232
+ this.writeBufferBytes += bytes;
233
+ if (nextEvent.level === "error" || this.writeBufferBytes >= 64 * 1024)
234
+ void this.flushLogBuffer();
235
+ else
236
+ this.scheduleLogFlush();
237
+ }
238
+ scheduleLogFlush() {
239
+ if (this.writeTimer || this.closed)
240
+ return;
241
+ this.writeTimer = setTimeout(() => {
242
+ this.writeTimer = null;
243
+ void this.flushLogBuffer();
244
+ }, this.logFlushIntervalMs);
245
+ }
246
+ async flushLogBuffer() {
247
+ if (this.writeBuffer.length === 0)
248
+ return this.writeChain;
249
+ const entries = this.writeBuffer;
250
+ this.writeBuffer = [];
251
+ this.writeBufferBytes = 0;
252
+ this.writeChain = this.writeChain.then(() => this.writeEntries(entries)).catch(() => {
253
+ // Local diagnostics must never take down the Runtime.
254
+ });
255
+ return this.writeChain;
256
+ }
257
+ async writeEntries(entries) {
258
+ await this.ready;
259
+ for (const entry of entries) {
260
+ await this.ensureLogFile();
261
+ const shouldRotate = this.logFileSize > 0 && (this.logFileSize + entry.bytes > this.maxLogFileBytes ||
262
+ Date.now() - this.logFileStartedAt >= DEFAULT_MAX_LOG_FILE_AGE_MS);
263
+ if (shouldRotate) {
264
+ await this.rotateLog(entry.event.sequence);
265
+ await this.ensureLogFile();
266
+ }
267
+ await this.logFile?.write(entry.line);
268
+ this.logFileSize += entry.bytes;
269
+ }
270
+ await this.logFile?.sync();
271
+ }
272
+ async ensureLogFile() {
273
+ if (this.logFile)
274
+ return;
275
+ this.logFile = await open(this.logPath, "a", 0o600);
276
+ await this.logFile.chmod(0o600).catch(() => undefined);
277
+ this.logFileSize = (await stat(this.logPath)).size;
278
+ this.logFileStartedAt = Date.now();
279
+ }
280
+ async rotateLog(nextSequence) {
281
+ const file = this.logFile;
282
+ this.logFile = null;
283
+ if (file) {
284
+ await file.sync();
285
+ await file.close();
286
+ }
287
+ await rename(this.logPath, join(this.directory, `${this.runtimeId}-${nextSequence}-${Date.now()}.jsonl`));
288
+ this.logFileSize = 0;
289
+ this.logFileStartedAt = 0;
290
+ await this.enforceLogCapacity();
291
+ }
292
+ async enforceLogCapacity() {
293
+ const names = await readdir(this.directory).catch((error) => {
294
+ if (error.code === "ENOENT")
295
+ return [];
296
+ throw error;
297
+ });
298
+ const files = await Promise.all(names.filter((name) => name.endsWith(".jsonl") && name !== `${this.runtimeId}.jsonl`).map(async (name) => {
299
+ const path = join(this.directory, name);
300
+ const info = await stat(path).catch(() => null);
301
+ return info ? { path, size: info.size, mtimeMs: info.mtimeMs } : null;
302
+ }));
303
+ let total = this.logFileSize + files.reduce((sum, file) => sum + (file?.size ?? 0), 0);
304
+ for (const file of files.filter((value) => value !== null).sort((left, right) => left.mtimeMs - right.mtimeMs)) {
305
+ if (total <= this.maxTotalLogBytes)
306
+ break;
307
+ await rm(file.path, { force: true });
308
+ total -= file.size;
309
+ }
310
+ }
311
+ }
312
+ export class RuntimeDiagnosticReader {
313
+ root;
314
+ files = new Map();
315
+ lastSequenceByRuntime = new Map();
316
+ initialized = false;
317
+ constructor(root) {
318
+ this.root = root;
319
+ }
320
+ async read(options = {}) {
321
+ const directory = runtimeDiagnosticsDirectory(this.root);
322
+ const names = await readdir(directory).catch((error) => {
323
+ if (error.code === "ENOENT")
324
+ return [];
325
+ throw error;
326
+ });
327
+ const fileEntries = await Promise.all(names.filter((value) => value.endsWith(".jsonl")).map(async (name) => {
328
+ const path = join(directory, name);
329
+ const info = await stat(path).catch(() => null);
330
+ return info ? { path, size: info.size, mtimeMs: info.mtimeMs } : null;
331
+ }));
332
+ const events = [];
333
+ for (const file of fileEntries.filter((value) => value !== null).sort((left, right) => left.mtimeMs - right.mtimeMs)) {
334
+ const state = this.files.get(file.path) ?? { offset: 0, partial: "", decoder: new StringDecoder("utf8") };
335
+ if (file.size < state.offset) {
336
+ state.offset = 0;
337
+ state.partial = "";
338
+ state.decoder = new StringDecoder("utf8");
339
+ }
340
+ const stream = createReadStream(file.path, { start: state.offset });
341
+ let bytesRead = 0;
342
+ let text = state.partial;
343
+ for await (const chunk of stream) {
344
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
345
+ bytesRead += buffer.length;
346
+ text += state.decoder.write(buffer);
347
+ }
348
+ state.offset += bytesRead;
349
+ const lines = text.split("\n");
350
+ state.partial = lines.pop() ?? "";
351
+ this.files.set(file.path, state);
352
+ for (const line of lines) {
353
+ if (!line.trim())
354
+ continue;
355
+ try {
356
+ const value = JSON.parse(line);
357
+ if (isRecord(value) && value.schemaVersion === 1 && typeof value.sequence === "number")
358
+ events.push(value);
359
+ }
360
+ catch {
361
+ // Ignore only the damaged line; source files remain untouched.
362
+ }
363
+ }
364
+ }
365
+ events.sort(compareDiagnosticEvents);
366
+ const fresh = events.filter((event) => event.sequence > (this.lastSequenceByRuntime.get(event.runtimeId) ?? -1));
367
+ for (const event of fresh)
368
+ this.lastSequenceByRuntime.set(event.runtimeId, event.sequence);
369
+ if (!this.initialized) {
370
+ this.initialized = true;
371
+ const limit = options.limit ?? 100;
372
+ return limit > 0 ? fresh.slice(-limit) : fresh;
373
+ }
374
+ return fresh;
375
+ }
376
+ }
377
+ export async function readRuntimeDiagnosticEvents(root, options = {}) {
378
+ return new RuntimeDiagnosticReader(root).read(options);
379
+ }
380
+ export function isValidRuntimeTraceparent(value) {
381
+ return typeof value === "string" && TRACEPARENT.test(value);
382
+ }
@@ -1,6 +1,7 @@
1
1
  import type { ContentBlock, RuntimeCapabilities, RuntimeExecutionEvent, RuntimeTurnInput } from "@neta-art/cohub";
2
2
  import { type JsonRecord } from "./json-rpc.js";
3
3
  import type { RuntimeSessionStore, NativeSession } from "./session-store.js";
4
+ import { type RuntimeDiagnostics, type RuntimeDiagnosticContext } from "./diagnostics.js";
4
5
  export type HarnessOptions = {
5
6
  pi?: string;
6
7
  codex?: string;
@@ -14,6 +15,6 @@ export type HarnessResult = {
14
15
  export declare function piContent(value: unknown): ContentBlock[];
15
16
  export declare function promptText(content: ContentBlock[]): string;
16
17
  export declare function discoverHarnesses(harnesses: ("pi" | "codex")[], options: HarnessOptions, cwd: string): Promise<RuntimeCapabilities>;
17
- export declare function executePi(input: RuntimeTurnInput, options: HarnessOptions, cwd: string, store: RuntimeSessionStore, emit: (event: RuntimeExecutionEvent) => void, signal: AbortSignal): Promise<HarnessResult>;
18
+ export declare function executePi(input: RuntimeTurnInput, options: HarnessOptions, cwd: string, store: RuntimeSessionStore, emit: (event: RuntimeExecutionEvent) => void, signal: AbortSignal, diagnostics?: RuntimeDiagnostics, diagnosticContext?: RuntimeDiagnosticContext): Promise<HarnessResult>;
18
19
  export declare function codexItemContent(item: JsonRecord): ContentBlock[];
19
- export declare function executeCodex(input: RuntimeTurnInput, options: HarnessOptions, cwd: string, store: RuntimeSessionStore, emit: (event: RuntimeExecutionEvent) => void, signal: AbortSignal): Promise<HarnessResult>;
20
+ export declare function executeCodex(input: RuntimeTurnInput, options: HarnessOptions, cwd: string, store: RuntimeSessionStore, emit: (event: RuntimeExecutionEvent) => void, signal: AbortSignal, diagnostics?: RuntimeDiagnostics, diagnosticContext?: RuntimeDiagnosticContext): Promise<HarnessResult>;
@@ -2,6 +2,7 @@ import { JsonRpcProcess, record } from "./json-rpc.js";
2
2
  import { codexModelCatalog } from "./model-catalog.js";
3
3
  import { codexTokenTotals, codexUsage, subtractCodexTokens } from "./codex-usage.js";
4
4
  import { downloadPublicImage } from "../safe-remote-image.js";
5
+ import { serializeDiagnosticError } from "./diagnostics.js";
5
6
  const runtimeEnvironment = (input) => ({ COHUB_SPACE_ID: input.spaceId, COHUB_SESSION_ID: input.sessionId, COHUB_TURN_ID: input.turnId });
6
7
  const array = (value) => Array.isArray(value) ? value : [];
7
8
  const text = (value) => typeof value === "string" ? value : "";
@@ -47,8 +48,8 @@ function createAbortEscalation(rpc, signal, interrupt) {
47
48
  };
48
49
  }
49
50
  /** Native files stay authoritative; archival failure only degrades cross-host resume. */
50
- async function finishHarnessTurn(store, state, message, resume, turnId) {
51
- const archive = await store.archive(state, turnId).catch((error) => { console.error("Native archive unavailable; local files retained:", error); return null; });
51
+ async function finishHarnessTurn(store, state, message, resume, turnId, diagnosticContext) {
52
+ const archive = await store.archive(state, turnId, diagnosticContext).catch((error) => { console.error("Native archive unavailable; local files retained:", error); return null; });
52
53
  return { state, event: { type: "turn.end", message, resume, archive } };
53
54
  }
54
55
  export async function discoverHarnesses(harnesses, options, cwd) {
@@ -88,7 +89,7 @@ export async function discoverHarnesses(harnesses, options, cwd) {
88
89
  }));
89
90
  return { harnesses, models };
90
91
  }
91
- export async function executePi(input, options, cwd, store, emit, signal) {
92
+ export async function executePi(input, options, cwd, store, emit, signal, diagnostics, diagnosticContext) {
92
93
  if (input.accessMode === "read_only")
93
94
  throw new Error("Pi cannot enforce read-only access; select Cohub or Codex");
94
95
  signal.throwIfAborted();
@@ -99,6 +100,12 @@ export async function executePi(input, options, cwd, store, emit, signal) {
99
100
  let currentContent = [];
100
101
  let last = { ordinal: 0, content: [] };
101
102
  const abortEscalation = createAbortEscalation(rpc, signal, () => { void rpc.request("abort").catch(() => undefined); });
103
+ const stopFailureLogging = diagnostics
104
+ ? rpc.onFailure((error) => diagnostics.log("error", "harness.rpc_process_failed", { error: serializeDiagnosticError(error) }, { ...diagnosticContext, component: "harness" }))
105
+ : () => undefined;
106
+ const stopTimeoutLogging = diagnostics
107
+ ? rpc.onTimeout((method, timeoutMs) => diagnostics.log("error", "harness.rpc_timeout", { method, timeoutMs }, { ...diagnosticContext, component: "harness" }))
108
+ : () => undefined;
102
109
  try {
103
110
  if (input.model) {
104
111
  let provider = input.provider;
@@ -193,11 +200,19 @@ export async function executePi(input, options, cwd, store, emit, signal) {
193
200
  }
194
201
  finally {
195
202
  abortEscalation.clear();
203
+ stopFailureLogging();
204
+ stopTimeoutLogging();
196
205
  await rpc.close();
197
206
  }
198
207
  if (signal.aborted)
199
208
  last = { ...last, stopReason: "aborted" };
200
- return finishHarnessTurn(store, state, last, resume, input.turnId);
209
+ return finishHarnessTurn(store, state, last, resume, input.turnId, {
210
+ component: "harness",
211
+ sessionId: input.sessionId,
212
+ turnId: input.turnId,
213
+ harness: "pi",
214
+ traceContext: input.traceContext,
215
+ });
201
216
  }
202
217
  export function codexItemContent(item) {
203
218
  if (item.type === "agentMessage" || item.type === "plan")
@@ -216,11 +231,17 @@ export function codexItemContent(item) {
216
231
  { type: "tool_result", tool_use_id: id, content: typeof item.aggregatedOutput === "string" ? item.aggregatedOutput : JSON.stringify(item.result ?? item.error ?? item.changes ?? item), is_error: item.status === "failed" || typeof item.exitCode === "number" && item.exitCode !== 0 },
217
232
  ];
218
233
  }
219
- export async function executeCodex(input, options, cwd, store, emit, signal) {
234
+ export async function executeCodex(input, options, cwd, store, emit, signal, diagnostics, diagnosticContext) {
220
235
  signal.throwIfAborted();
221
236
  const { state, resume } = await store.prepare(input, cwd, signal);
222
237
  signal.throwIfAborted();
223
238
  const rpc = new JsonRpcProcess(options.codex || "codex", ["app-server", "--listen", "stdio://"], cwd, "codex", runtimeEnvironment(input));
239
+ const stopFailureLogging = diagnostics
240
+ ? rpc.onFailure((error) => diagnostics.log("error", "harness.rpc_process_failed", { error: serializeDiagnosticError(error) }, { ...diagnosticContext, component: "harness" }))
241
+ : () => undefined;
242
+ const stopTimeoutLogging = diagnostics
243
+ ? rpc.onTimeout((method, timeoutMs) => diagnostics.log("error", "harness.rpc_timeout", { method, timeoutMs }, { ...diagnosticContext, component: "harness" }))
244
+ : () => undefined;
224
245
  let nativeTurnId = null;
225
246
  let ordinal = -1;
226
247
  const ordinals = new Map();
@@ -250,10 +271,12 @@ export async function executeCodex(input, options, cwd, store, emit, signal) {
250
271
  ...(input.accessMode === "read_only" ? { sandbox: "read-only" } : {}),
251
272
  };
252
273
  const opened = resume === "native"
253
- ? await rpc.request("thread/resume", { ...threadOptions, threadId: state.nativeSessionId, excludeTurns: true })
274
+ ? await rpc.request("thread/resume", { ...threadOptions, threadId: state.nativeSessionId, path: state.path, excludeTurns: true })
254
275
  : resume === "restored"
255
276
  ? await rpc.request("thread/fork", { ...threadOptions, threadId: state.nativeSessionId, path: state.path, excludeTurns: true })
256
- : await rpc.request("thread/start", threadOptions);
277
+ : resume === "handoff"
278
+ ? await rpc.request("thread/resume", { ...threadOptions, threadId: state.nativeSessionId, path: state.path, excludeTurns: true })
279
+ : await rpc.request("thread/start", threadOptions);
257
280
  const thread = record(opened.thread);
258
281
  if (typeof thread.id !== "string" || typeof thread.path !== "string")
259
282
  throw new Error("Codex did not provide a durable native thread");
@@ -393,11 +416,19 @@ export async function executeCodex(input, options, cwd, store, emit, signal) {
393
416
  }
394
417
  finally {
395
418
  abortEscalation.clear();
419
+ stopFailureLogging();
420
+ stopTimeoutLogging();
396
421
  await rpc.close();
397
422
  }
398
423
  if (signal.aborted)
399
424
  final = { ...final, stopReason: "aborted" };
400
425
  if (usage)
401
426
  final = { ...final, usage };
402
- return finishHarnessTurn(store, state, final, resume, input.turnId);
427
+ return finishHarnessTurn(store, state, final, resume, input.turnId, {
428
+ component: "harness",
429
+ sessionId: input.sessionId,
430
+ turnId: input.turnId,
431
+ harness: "codex",
432
+ traceContext: input.traceContext,
433
+ });
403
434
  }
@@ -20,6 +20,7 @@ export declare class JsonRpcProcess {
20
20
  private pending;
21
21
  private listeners;
22
22
  private failureListeners;
23
+ private timeoutListeners;
23
24
  private failure;
24
25
  private nextId;
25
26
  private stderr;
@@ -32,6 +33,7 @@ export declare class JsonRpcProcess {
32
33
  request(method: string, params?: JsonRecord, timeoutMs?: number): Promise<JsonRecord>;
33
34
  onEvent(listener: (event: JsonRecord) => void): () => boolean;
34
35
  onFailure(listener: (error: Error) => void): () => boolean;
36
+ onTimeout(listener: (method: string, timeoutMs: number) => void): () => boolean;
35
37
  close(): Promise<void>;
36
38
  private closeProcessGroup;
37
39
  }
@@ -53,6 +53,7 @@ export class JsonRpcProcess {
53
53
  pending = new Map();
54
54
  listeners = new Set();
55
55
  failureListeners = new Set();
56
+ timeoutListeners = new Set();
56
57
  failure = null;
57
58
  nextId = 0;
58
59
  stderr = "";
@@ -123,7 +124,12 @@ export class JsonRpcProcess {
123
124
  return Promise.reject(this.failure);
124
125
  const id = String(++this.nextId);
125
126
  return new Promise((resolve, reject) => {
126
- const timer = setTimeout(() => { this.pending.delete(id); reject(new Error(`${method} timed out`)); }, timeoutMs);
127
+ const timer = setTimeout(() => {
128
+ this.pending.delete(id);
129
+ for (const listener of this.timeoutListeners)
130
+ listener(method, timeoutMs);
131
+ reject(new Error(`${method} timed out`));
132
+ }, timeoutMs);
127
133
  this.pending.set(id, { resolve, reject, timer });
128
134
  try {
129
135
  this.write(this.mode === "pi" ? { ...params, id, type: method } : { id, method, params });
@@ -143,6 +149,10 @@ export class JsonRpcProcess {
143
149
  listener(this.failure); });
144
150
  return () => this.failureListeners.delete(listener);
145
151
  }
152
+ onTimeout(listener) {
153
+ this.timeoutListeners.add(listener);
154
+ return () => this.timeoutListeners.delete(listener);
155
+ }
146
156
  close() {
147
157
  this.closing ??= this.closeProcessGroup();
148
158
  return this.closing;
@@ -159,6 +169,7 @@ export class JsonRpcProcess {
159
169
  this.fail(new Error("RPC process closed"));
160
170
  this.listeners.clear();
161
171
  this.failureListeners.clear();
172
+ this.timeoutListeners.clear();
162
173
  }
163
174
  await this.closed;
164
175
  }
@@ -0,0 +1,34 @@
1
+ import { type NativeProjection, type ProjectionTarget } from "@neta-art/cohub";
2
+ import { type ProjectionSourceTurn, type SessionTurnProjectionClient } from "./turn-projection.js";
3
+ export type ProjectionCursorState = {
4
+ throughSequence: number | null;
5
+ throughTurnId: string | null;
6
+ sourceFingerprint: string | null;
7
+ };
8
+ export type ProjectionStoreInput = {
9
+ spaceId: string;
10
+ sessionId: string;
11
+ turnId: string;
12
+ nativeSessionId: string;
13
+ cwd: string;
14
+ provider?: string | null;
15
+ target: ProjectionTarget;
16
+ throughTurnId: string | null;
17
+ cursor: ProjectionCursorState | null;
18
+ };
19
+ export type ProjectionStoreResult = {
20
+ projection: NativeProjection;
21
+ turns: ProjectionSourceTurn[];
22
+ append: boolean;
23
+ cursor: ProjectionCursorState;
24
+ };
25
+ export declare function rebindProjectionNativeSession(result: ProjectionStoreResult, nativeSessionId: string): ProjectionStoreResult;
26
+ /** Reads durable Cohub turns and materializes one harness-specific native projection batch. */
27
+ export declare class ProjectionStore {
28
+ private readonly source;
29
+ constructor(source: SessionTurnProjectionClient);
30
+ project(input: ProjectionStoreInput, signal?: AbortSignal): Promise<ProjectionStoreResult>;
31
+ private sourceSequence;
32
+ cursorForTurn(sessionId: string, turnId: string, signal?: AbortSignal): Promise<ProjectionCursorState>;
33
+ private readTurns;
34
+ }