Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 100 additions & 11 deletions packages/adapter-cli/src/generated/adapter-helpers.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
// @generated by kern v3.5.8 — DO NOT EDIT. Source: src/kern/adapter-helpers.kern

// @kern-source: adapter-helpers:416
// @kern-source: adapter-helpers:481

import type { EngineDefinition, EngineMode, EngineModeConfig, ImageAttachment, EngineIsolationPlan } from '@kernlang/agon-core';

import { EngineNotFoundError, loadConfig, engineHealth, classifyDispatchFailure, resolveIsolationMode, planEngineIsolation, agonPath } from '@kernlang/agon-core';

import { statSync, mkdirSync, existsSync, copyFileSync, chmodSync, unlinkSync } from 'node:fs';
import { statSync, mkdirSync, existsSync, copyFileSync, chmodSync, unlinkSync, readFileSync, mkdtempSync, rmSync } from 'node:fs';

import { join, dirname, basename } from 'node:path';

import { homedir } from 'node:os';
import { homedir, tmpdir } from 'node:os';

/**
* Centralized engine-health recording for any dispatch return point. exitCode 0 + not timed out → mark ok (clears prior quarantine). Otherwise classify and quarantine only on auth-failed/unreachable — timeouts and generic failures stay in rotation since they may be transient.
Expand Down Expand Up @@ -373,9 +373,58 @@ export function composeClaudePtyPrompt(prompt: string, systemPrompt?: string): s
}

/**
* Drive interactive `claude` under a pty so the subscription billing path is used. Lazy-imports @kernlang/agon-enginesruns a python3 daemon (kern_engines.cli.daemon) over stdio JSON-RPC, no native node deps. cwd is plumbed through so worktree dispatches land in the right repo; systemPrompt is prepended to the prompt; extraArgv (model/effort launch flags) is forwarded to the engine exec. Never throws — returns unavailable:true on any unexpected failure so kern callers can fall through to legacy paths with a simple !result.unavailable check.
* Structured answer-channel transport for ONE-SHOT claude exec dispatch (council/forge/brainstorm/tribunal/synthesis members), bypassing the flaky TUI scrape. ON by default ('file'claude writes its answer via its native Write tool to a temp file we read as authoritative). AGON_CLAUDE_ANSWER_CHANNEL: 'off'/'0'/'false' → off (raw scrape, for debugging); 'mcp' → the DeliverAnswer MCP variant (reserved); anything else (incl. unset) → 'file'. Never worse than the scrape — we fall back to it when claude doesn't deliver.
*/
// @kern-source: adapter-helpers:332
export function answerChannelMode(): string {
const v = (process.env.AGON_CLAUDE_ANSWER_CHANNEL || '').trim().toLowerCase();
if (v === 'off' || v === '0' || v === 'false') return 'off';
if (v === 'mcp') return 'mcp';
return 'file';
}

/**
* Appended to a one-shot claude prompt under file answer-channel mode: instruct claude to deliver its COMPLETE answer by writing it (markdown only, no commentary) to answerFile with its native Write tool. This is what makes the answer arrive as a clean file instead of a scraped TUI frame.
*/
// @kern-source: adapter-helpers:341
export function fileChannelInstruction(answerFile: string): string {
return `\n\n---\n[ANSWER DELIVERY — REQUIRED] After composing your COMPLETE final answer, use your Write tool to write that answer — markdown only, no preamble, no commentary, nothing but the answer itself — to this exact file path:\n${answerFile}\nThat file is the ONLY channel by which your answer is captured. Write it exactly once, at the very end. Do NOT skip it.`;
}

/**
* Read the authoritative answer a one-shot claude wrote to the channel file. Returns '' when the file is absent/empty (caller falls back to the scrape). Accepts raw markdown (file mode, native Write) OR a {text} JSON envelope (mcp mode, DeliverAnswer) transparently.
*/
// @kern-source: adapter-helpers:347
export function readAnswerChannelFile(answerFile: string): string {
try {
if (!existsSync(answerFile)) return '';
const raw = readFileSync(answerFile, 'utf-8');
if (!raw.trim()) return '';
const t = raw.trim();
if (t.startsWith('{')) {
try {
const parsed = JSON.parse(t);
if (parsed && typeof parsed.text === 'string' && parsed.text.trim()) return parsed.text;
} catch { /* not a JSON envelope — treat as raw markdown */ }
}
return raw;
} catch { return ''; }
}

/**
* Create a fresh temp dir + answer file for file-mode delivery and append the delivery instruction to the composed prompt. Returns the augmented prompt, the answer file path to read after the turn, and the dir to remove afterwards.
*/
// @kern-source: adapter-helpers:365
export function setupFileAnswerChannel(composed: string): { prompt:string, answerFile:string, dir:string } {
const dir = mkdtempSync(join(tmpdir(), 'agon-ac-'));
const answerFile = join(dir, 'answer.md');
return { prompt: composed + fileChannelInstruction(answerFile), answerFile, dir };
}

/**
* Drive interactive `claude` under a pty so the subscription billing path is used. Lazy-imports @kernlang/agon-engines — runs a python3 daemon (kern_engines.cli.daemon) over stdio JSON-RPC, no native node deps. cwd is plumbed through so worktree dispatches land in the right repo; systemPrompt is prepended to the prompt; extraArgv (model/effort launch flags) is forwarded to the engine exec. When AGON_CLAUDE_ANSWER_CHANNEL=file and mode=exec, claude is asked to Write its answer to a temp file we read as the AUTHORITATIVE result (clean — no TUI scrape), falling back to the scrape if the file is absent. Never throws — returns unavailable:true on any unexpected failure so kern callers can fall through to legacy paths with a simple !result.unavailable check.
*/
// @kern-source: adapter-helpers:373
export async function runClaudePtyDispatch(prompt: string, timeoutSec: number, signal?: AbortSignal, mode?: 'exec'|'agent', cwd?: string, systemPrompt?: string, env?: Record<string,string>, extraArgv?: string[]): Promise<{exitCode:number,stdout:string,stderr:string,durationMs:number,timedOut:boolean,unavailable?:boolean}> {
const start = Date.now();
try {
Expand Down Expand Up @@ -421,18 +470,41 @@ export async function runClaudePtyDispatch(prompt: string, timeoutSec: number, s
signal.addEventListener('abort', onAbort, { once: true });
}

// File answer-channel: only for one-shot exec dispatch (council/forge/
// brainstorm/tribunal members). claude writes its answer to a temp file we
// read as authoritative, bypassing the flaky scrape. Off by default.
const useFileChannel = (mode ?? 'exec') === 'exec' && answerChannelMode() === 'file';
let channelDir = '';
try {
const composed = composeClaudePtyPrompt(prompt, systemPrompt);
const text = await session.ask(composed, timeoutSec * 1000);
let composed = composeClaudePtyPrompt(prompt, systemPrompt);
let answerFile = '';
if (useFileChannel) {
const ch = setupFileAnswerChannel(composed);
composed = ch.prompt; answerFile = ch.answerFile; channelDir = ch.dir;
}
const scraped = await session.ask(composed, timeoutSec * 1000);
// Prefer the authoritative channel file; fall back to the scrape if claude
// didn't deliver (it sometimes won't) so we never regress to empty.
const channelText = useFileChannel ? readAnswerChannelFile(answerFile) : '';
if (useFileChannel && process.env.AGON_DEBUG) console.error(`[agon] answer-channel(file): ${channelText ? 'hit ' + channelText.length + 'ch' : 'miss → scrape fallback'}`);
const text = channelText || scraped;
return {
exitCode: 0,
stdout: text,
stderr: '',
stderr: channelText ? '' : (useFileChannel ? 'answer-channel: file empty, used scrape fallback' : ''),
durationMs: Date.now() - start,
timedOut: false,
};
} catch (e: any) {
const isTimeout = e?.kind === 'timeout' || e?.name === 'ClaudeSessionTimeout';
// Even on a hard timeout, claude may have already written the channel file
// before the TUI wedged — salvage it rather than returning empty.
if (useFileChannel && channelDir) {
const salvaged = readAnswerChannelFile(join(channelDir, 'answer.md'));
if (salvaged) {
return { exitCode: 0, stdout: salvaged, stderr: 'answer-channel: salvaged from file after ask error', durationMs: Date.now() - start, timedOut: false };
}
}
return {
exitCode: isTimeout ? 124 : 1,
stdout: '',
Expand All @@ -443,6 +515,7 @@ export async function runClaudePtyDispatch(prompt: string, timeoutSec: number, s
} finally {
if (signal) signal.removeEventListener('abort', onAbort);
await session.close().catch(() => undefined);
if (channelDir) { try { rmSync(channelDir, { recursive: true, force: true }); } catch { /* best-effort */ } }
}
} catch (e: any) {
// Last-ditch: any unforeseen error becomes unavailable so the KERN
Expand Down Expand Up @@ -506,20 +579,31 @@ export async function* runClaudePtyStreamDispatch(prompt: string, timeoutSec: nu
signal.addEventListener('abort', onAbort, { once: true });
}

// File answer-channel (one-shot exec only): stream the scraped deltas for live
// UX, but read the FINAL answer from the authoritative file claude writes.
const useFileChannel = (mode ?? 'exec') === 'exec' && answerChannelMode() === 'file';
let channelDir = '';
const collected: string[] = [];
try {
const composed = composeClaudePtyPrompt(prompt, systemPrompt);
let composed = composeClaudePtyPrompt(prompt, systemPrompt);
let answerFile = '';
if (useFileChannel) {
const ch = setupFileAnswerChannel(composed);
composed = ch.prompt; answerFile = ch.answerFile; channelDir = ch.dir;
}
const gen = session.askStream(composed, timeoutSec * 1000);
while (true) {
const next = await gen.next();
if (next.done) {
const text = typeof next.value === 'string' && next.value
const scraped = typeof next.value === 'string' && next.value
? next.value
: collected.join('');
const channelText = useFileChannel ? readAnswerChannelFile(answerFile) : '';
if (useFileChannel && process.env.AGON_DEBUG) console.error(`[agon] answer-channel(file): ${channelText ? 'hit ' + channelText.length + 'ch' : 'miss → scrape fallback'}`);
return {
exitCode: 0,
stdout: text,
stderr: '',
stdout: channelText || scraped,
stderr: channelText ? '' : (useFileChannel ? 'answer-channel: file empty, used scrape fallback' : ''),
durationMs: Date.now() - start,
timedOut: false,
};
Expand All @@ -529,6 +613,10 @@ export async function* runClaudePtyStreamDispatch(prompt: string, timeoutSec: nu
}
} catch (e: any) {
const isTimeout = e?.kind === 'timeout' || e?.name === 'ClaudeSessionTimeout';
const salvaged = (useFileChannel && channelDir) ? readAnswerChannelFile(join(channelDir, 'answer.md')) : '';
if (salvaged) {
return { exitCode: 0, stdout: salvaged, stderr: 'answer-channel: salvaged from file after ask error', durationMs: Date.now() - start, timedOut: false };
}
return {
exitCode: isTimeout ? 124 : 1,
stdout: collected.join(''),
Expand All @@ -539,6 +627,7 @@ export async function* runClaudePtyStreamDispatch(prompt: string, timeoutSec: nu
} finally {
if (signal) signal.removeEventListener('abort', onAbort);
await session.close().catch(() => undefined);
if (channelDir) { try { rmSync(channelDir, { recursive: true, force: true }); } catch { /* best-effort */ } }
}
} catch (e: any) {
// Outer guard for anything the inner blocks miss.
Expand Down
Loading