Skip to content
Open
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
2 changes: 2 additions & 0 deletions engines/claude.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@
"companion": {
"protocol": "stream-json",
"serverCmd": ["--print", "--verbose", "--input-format", "stream-json", "--output-format", "stream-json", "--max-turns", "1"],
"systemPromptFlag": "--system-prompt",
"textOnlyArgs": ["--tools", ""],
"features": {
"threadResume": false,
"nativeReview": false,
Expand Down
43 changes: 24 additions & 19 deletions packages/adapter-cli/src/generated/adapter-helpers.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// @generated by kern v4.5.0 — DO NOT EDIT. Source: src/kern/adapter-helpers.kern

// @kern-source: adapter-helpers:520
// @kern-source: adapter-helpers:525

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

Expand Down Expand Up @@ -324,12 +324,13 @@ export function resolveAgentArgs(engine: EngineDefinition, permissionLevel: 'ful
}

/**
* Extract text content from Claude stream-json NDJSON output. Returns plain text with system/hook messages removed.
* Extract text content from Claude stream-json NDJSON output. The final result event is the authoritative answer when present — concatenating it with the assistant text blocks duplicates the answer, and in multi-turn tool runs the early assistant texts are tool preambles ('I'll quickly verify...'), not the answer. Assistant texts remain the fallback for streams the result never closed (timeout kill, max-tokens is_error).
*/
// @kern-source: adapter-helpers:297
export function stripStreamJson(stdout: string): string {
const lines = stdout.split('\n');
const textParts: string[] = [];
const assistantParts: string[] = [];
let resultText = '';

for (const line of lines) {
const trimmed = line.trim();
Expand All @@ -339,27 +340,31 @@ export function stripStreamJson(stdout: string): string {
// assistant message → extract text blocks
if (msg.type === 'assistant' && msg.message?.content) {
for (const block of msg.message.content) {
if (block.type === 'text' && block.text) textParts.push(block.text);
if (block.type === 'text' && block.text) assistantParts.push(block.text);
}
}
// result → extract result text
// result → the final answer (last one wins if several appear). Only a
// string result is authoritative; non-string payloads (objects) keep
// the old stringify-into-parts behavior instead of masking the
// assistant text with '{}'.
else if (msg.type === 'result' && msg.result && !msg.is_error) {
textParts.push(typeof msg.result === 'string' ? msg.result : JSON.stringify(msg.result));
if (typeof msg.result === 'string') resultText = msg.result;
else assistantParts.push(JSON.stringify(msg.result));
}
// system, hook, etc. → skip
} catch {
// Not JSON — raw text from non-Claude engines, keep as-is
textParts.push(trimmed);
assistantParts.push(trimmed);
}
}

return textParts.join('\n');
return resultText.trim() ? resultText : assistantParts.join('\n');
}

/**
* Check if engine exec mode outputs stream-json NDJSON.
*/
// @kern-source: adapter-helpers:328
// @kern-source: adapter-helpers:333
export function usesStreamJson(engine: EngineDefinition): boolean {
const args = engine.exec?.args ?? [];
return args.includes('stream-json') || args.some((a: string) => a === '--output-format' && args[args.indexOf(a) + 1] === 'stream-json');
Expand All @@ -368,12 +373,12 @@ export function usesStreamJson(engine: EngineDefinition): boolean {
/**
* Only Codex-style JSON-RPC app-server is reliable for one-shot forge agent dispatch. Claude stream-json and Gemini ACP are reliable as persistent Cesar sessions, but the one-shot companion wrapper can hang or return empty, so forge should use their direct non-interactive CLI agent commands.
*/
// @kern-source: adapter-helpers:334
// @kern-source: adapter-helpers:339
export function shouldUseCompanionForAgent(engine: EngineDefinition): boolean {
return engine.companion?.protocol === 'jsonrpc';
}

// @kern-source: adapter-helpers:339
// @kern-source: adapter-helpers:344
export function checkEnvVars(engine: EngineDefinition): string|null {
if (!engine.env) return null;
for (const [envVar, config] of Object.entries(engine.env)) {
Expand All @@ -387,7 +392,7 @@ export function checkEnvVars(engine: EngineDefinition): string|null {
/**
* Drive `claude` via the kern-engines PTY subscription TUI? DEFAULT is now NO — claude dispatches via `claude --print` (-p), which Anthropic no longer meters under the subscription and is far more reliable than scraping the TUI. Opt back INTO the PTY brain with AGON_CLAUDE_PTY=1 / `agon config claudeBackend pty` (single source of truth: claudeBrainUsesPty). Claude is identified by id OR binary === 'claude' so this agrees with the persistent-session and cesar/session gates. cwd (when known) lets a project-local config override apply.
*/
// @kern-source: adapter-helpers:350
// @kern-source: adapter-helpers:355
export function shouldUseClaudePty(engine: EngineDefinition, cwd?: string): boolean {
if (engine.id !== 'claude' && engine.binary !== 'claude') {
return false;
Expand All @@ -398,7 +403,7 @@ export function shouldUseClaudePty(engine: EngineDefinition, cwd?: string): bool
/**
* When a caller passed a systemPrompt, prepend it to the prompt with the same [System Instructions]/[User Message] framing the legacy non-flag CLI path uses. Claude's TUI has no --system-prompt equivalent we can apply through stdin, so this is the honest way to keep forge/tribunal/brainstorm system instructions intact on the pty path.
*/
// @kern-source: adapter-helpers:357
// @kern-source: adapter-helpers:362
export function composeClaudePtyPrompt(prompt: string, systemPrompt?: string): string {
if (!systemPrompt) return prompt;
return `[System Instructions]\n${systemPrompt}\n\n[User Message]\n${prompt}`;
Expand All @@ -407,7 +412,7 @@ export function composeClaudePtyPrompt(prompt: string, systemPrompt?: string): s
/**
* 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:364
// @kern-source: adapter-helpers:369
export function answerChannelMode(): string {
const v = (process.env.AGON_CLAUDE_ANSWER_CHANNEL || '').trim().toLowerCase();
if (v === 'off' || v === '0' || v === 'false') return 'off';
Expand All @@ -418,7 +423,7 @@ export function answerChannelMode(): string {
/**
* Whether the file answer-channel applies for a given one-shot dispatch mode. TRUE for both exec (council/forge/brainstorm/tribunal/synthesis members) AND agent (forge one-shot agent dispatch) — both lose long-form output to TUI scrape corruption and benefit from the authoritative file. Gated by answerChannelMode()==='file'. Defaults mode to 'exec'.
*/
// @kern-source: adapter-helpers:373
// @kern-source: adapter-helpers:378
export function useFileChannelForMode(mode?: 'exec'|'agent'): boolean {
const m = mode ?? 'exec';
return (m === 'exec' || m === 'agent') && answerChannelMode() === 'file';
Expand All @@ -427,15 +432,15 @@ export function useFileChannelForMode(mode?: 'exec'|'agent'): boolean {
/**
* 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:380
// @kern-source: adapter-helpers:385
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:386
// @kern-source: adapter-helpers:391
export function readAnswerChannelFile(answerFile: string): string {
try {
if (!existsSync(answerFile)) return '';
Expand All @@ -455,7 +460,7 @@ export function readAnswerChannelFile(answerFile: string): string {
/**
* 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:404
// @kern-source: adapter-helpers:409
export function setupFileAnswerChannel(composed: string): { prompt:string, answerFile:string, dir:string } {
const dir = mkdtempSync(join(tmpdir(), 'agon-ac-'));
const answerFile = join(dir, 'answer.md');
Expand All @@ -465,7 +470,7 @@ export function setupFileAnswerChannel(composed: string): { prompt:string, answe
/**
* 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 is exec OR agent, 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:412
// @kern-source: adapter-helpers:417
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
2 changes: 1 addition & 1 deletion packages/adapter-cli/src/generated/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ export class CliAdapter implements EngineAdapter {
}
// Try companion protocol (JSONRPC app-server) first — faster, more stable
if (options.engine.companion) {
const companionResult = await companionDispatch({ config: options.engine.companion, binaryPath: binaryPath, prompt: options.prompt, cwd: options.cwd, timeout: options.timeout, mode: (options.mode === 'agent') ? 'agent' : ((options.mode === 'review') ? 'review' : 'exec'), model: resolveModel(options.engine, options.cwd) ?? undefined, signal: options.signal, systemPrompt: options.systemPrompt, env: iso.env });
const companionResult = await companionDispatch({ config: options.engine.companion, binaryPath: binaryPath, prompt: options.prompt, cwd: options.cwd, timeout: options.timeout, mode: (options.mode === 'agent') ? 'agent' : ((options.mode === 'review') ? 'review' : 'exec'), model: resolveModel(options.engine, options.cwd) ?? undefined, signal: options.signal, systemPrompt: options.systemPrompt, textOnly: options.textOnly, env: iso.env });
// Exit code 2 = companion not available, fall through to CLI spawn
// Also fall through if companion returned empty output (stream-json capture failure)
if (companionResult.exitCode !== 2 && companionResult.stdout.trim()) {
Expand Down
19 changes: 12 additions & 7 deletions packages/adapter-cli/src/kern/adapter-helpers.kern
Original file line number Diff line number Diff line change
Expand Up @@ -295,10 +295,11 @@ fn name=resolveAgentArgs params="engine:EngineDefinition, permissionLevel:'full'
>>>

fn name=stripStreamJson params="stdout:string" returns=string
doc "Extract text content from Claude stream-json NDJSON output. Returns plain text with system/hook messages removed."
doc "Extract text content from Claude stream-json NDJSON output. The final result event is the authoritative answer when present — concatenating it with the assistant text blocks duplicates the answer, and in multi-turn tool runs the early assistant texts are tool preambles ('I'll quickly verify...'), not the answer. Assistant texts remain the fallback for streams the result never closed (timeout kill, max-tokens is_error)."
handler <<<
const lines = stdout.split('\n');
const textParts: string[] = [];
const assistantParts: string[] = [];
let resultText = '';

for (const line of lines) {
const trimmed = line.trim();
Expand All @@ -308,21 +309,25 @@ fn name=stripStreamJson params="stdout:string" returns=string
// assistant message → extract text blocks
if (msg.type === 'assistant' && msg.message?.content) {
for (const block of msg.message.content) {
if (block.type === 'text' && block.text) textParts.push(block.text);
if (block.type === 'text' && block.text) assistantParts.push(block.text);
}
}
// result → extract result text
// result → the final answer (last one wins if several appear). Only a
// string result is authoritative; non-string payloads (objects) keep
// the old stringify-into-parts behavior instead of masking the
// assistant text with '{}'.
else if (msg.type === 'result' && msg.result && !msg.is_error) {
textParts.push(typeof msg.result === 'string' ? msg.result : JSON.stringify(msg.result));
if (typeof msg.result === 'string') resultText = msg.result;
else assistantParts.push(JSON.stringify(msg.result));
}
// system, hook, etc. → skip
} catch {
// Not JSON — raw text from non-Claude engines, keep as-is
textParts.push(trimmed);
assistantParts.push(trimmed);
}
}

return textParts.join('\n');
return resultText.trim() ? resultText : assistantParts.join('\n');
>>>

fn name=usesStreamJson params="engine:EngineDefinition" returns=boolean
Expand Down
2 changes: 1 addition & 1 deletion packages/adapter-cli/src/kern/adapter.kern
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ service name=CliAdapter implements=EngineAdapter
return value="ptyResult"
comment raw="// Try companion protocol (JSONRPC app-server) first — faster, more stable"
if cond="options.engine.companion"
let name=companionResult value="await companionDispatch({ config: options.engine.companion, binaryPath, prompt: options.prompt, cwd: options.cwd, timeout: options.timeout, mode: options.mode === 'agent' ? 'agent' : options.mode === 'review' ? 'review' : 'exec', model: resolveModel(options.engine, options.cwd) ?? undefined, signal: options.signal, systemPrompt: options.systemPrompt, env: iso.env, })"
let name=companionResult value="await companionDispatch({ config: options.engine.companion, binaryPath, prompt: options.prompt, cwd: options.cwd, timeout: options.timeout, mode: options.mode === 'agent' ? 'agent' : options.mode === 'review' ? 'review' : 'exec', model: resolveModel(options.engine, options.cwd) ?? undefined, signal: options.signal, systemPrompt: options.systemPrompt, textOnly: options.textOnly, env: iso.env, })"
comment raw="// Exit code 2 = companion not available, fall through to CLI spawn"
comment raw="// Also fall through if companion returned empty output (stream-json capture failure)"
if cond="companionResult.exitCode !== 2 && companionResult.stdout.trim()"
Expand Down
11 changes: 11 additions & 0 deletions packages/cli/src/commands/brainstorm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ export const brainstormCommand = defineCommand({
description: 'Timeout in seconds',
default: '120',
},
style: {
type: 'string',
description: "'divergent' (default: seats get distinct stances, synthesis keeps 2-3 directions) or 'grounded' (convergent, file-path-anchored single answer)",
default: 'divergent',
},
label: {
type: 'string',
description: 'Human-readable suffix baked into the run dir name (orchestrators: distinguish parallel runs without grep).',
Expand All @@ -53,6 +58,10 @@ export const brainstormCommand = defineCommand({
? args.engines.split(',').map((s) => s.trim())
: filterDefaultOrchestrationEngines(registry.activeIds(config));

if (args.style !== 'divergent' && args.style !== 'grounded') {
throw new Error(`Unknown --style "${args.style}" — use 'divergent' or 'grounded'.`);
}

if (args.quiet) process.env.AGON_QUIET = '1';
const startedAt = new Date().toISOString();
const { path: outputDir } = createRunDir({
Expand All @@ -64,6 +73,7 @@ export const brainstormCommand = defineCommand({
if (!quiet) {
header(`Brainstorm: ${args.question}`);
info(`Engines: ${available.join(', ')}`);
info(`Style: ${args.style}`);
}

const seatState = new Map<string, { ok: boolean; detail: string }>();
Expand All @@ -72,6 +82,7 @@ export const brainstormCommand = defineCommand({
result = await runBrainstorm({
question: args.question,
engines: available,
style: args.style,
registry,
adapter,
timeout: parseInt(args.timeout, 10),
Expand Down
Loading
Loading