karajan-code 1.8.0 → 1.9.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "karajan-code",
3
- "version": "1.8.0",
3
+ "version": "1.9.0",
4
4
  "description": "Local multi-agent coding orchestrator with TDD, SonarQube, and code review pipeline",
5
5
  "type": "module",
6
6
  "license": "AGPL-3.0",
@@ -2,13 +2,91 @@ import { BaseAgent } from "./base-agent.js";
2
2
  import { runCommand } from "../utils/process.js";
3
3
  import { resolveBin } from "./resolve-bin.js";
4
4
 
5
+ /**
6
+ * Extract the final text result from stream-json NDJSON output.
7
+ * Each line is a JSON object. We collect assistant text content from
8
+ * "result" messages and fall back to accumulating "content_block_delta" text.
9
+ */
10
+ function extractTextFromStreamJson(raw) {
11
+ const lines = (raw || "").split("\n").filter(Boolean);
12
+ // Try to find a "result" message with the final text
13
+ for (let i = lines.length - 1; i >= 0; i--) {
14
+ try {
15
+ const obj = JSON.parse(lines[i]);
16
+ if (obj.type === "result" && obj.result) {
17
+ return typeof obj.result === "string" ? obj.result : JSON.stringify(obj.result);
18
+ }
19
+ // Claude Code stream-json final message
20
+ if (obj.result && typeof obj.result === "string") {
21
+ return obj.result;
22
+ }
23
+ } catch { /* skip unparseable lines */ }
24
+ }
25
+ // Fallback: accumulate all assistant text deltas
26
+ const parts = [];
27
+ for (const line of lines) {
28
+ try {
29
+ const obj = JSON.parse(line);
30
+ if (obj.type === "assistant" && obj.message?.content) {
31
+ for (const block of obj.message.content) {
32
+ if (block.type === "text" && block.text) parts.push(block.text);
33
+ }
34
+ }
35
+ } catch { /* skip */ }
36
+ }
37
+ return parts.join("") || raw;
38
+ }
39
+
40
+ /**
41
+ * Create a wrapping onOutput that parses stream-json lines and forwards
42
+ * meaningful content (assistant text, tool usage) to the original callback.
43
+ */
44
+ function createStreamJsonFilter(onOutput) {
45
+ if (!onOutput) return null;
46
+ return ({ stream, line }) => {
47
+ try {
48
+ const obj = JSON.parse(line);
49
+ // Forward assistant text messages
50
+ if (obj.type === "assistant" && obj.message?.content) {
51
+ for (const block of obj.message.content) {
52
+ if (block.type === "text" && block.text) {
53
+ onOutput({ stream, line: block.text.slice(0, 200) });
54
+ } else if (block.type === "tool_use") {
55
+ onOutput({ stream, line: `[tool: ${block.name}]` });
56
+ }
57
+ }
58
+ return;
59
+ }
60
+ // Forward result
61
+ if (obj.type === "result") {
62
+ const summary = typeof obj.result === "string"
63
+ ? obj.result.slice(0, 200)
64
+ : "result received";
65
+ onOutput({ stream, line: `[result] ${summary}` });
66
+ return;
67
+ }
68
+ } catch { /* not JSON, forward raw */ }
69
+ onOutput({ stream, line });
70
+ };
71
+ }
72
+
5
73
  export class ClaudeAgent extends BaseAgent {
6
74
  async runTask(task) {
7
75
  const role = task.role || "coder";
8
76
  const args = ["-p", task.prompt];
9
77
  const model = this.getRoleModel(role);
10
78
  if (model) args.push("--model", model);
11
- const res = await runCommand(resolveBin("claude"), args, { onOutput: task.onOutput });
79
+
80
+ // Use stream-json when onOutput is provided to get real-time feedback
81
+ if (task.onOutput) {
82
+ args.push("--output-format", "stream-json");
83
+ const streamFilter = createStreamJsonFilter(task.onOutput);
84
+ const res = await runCommand(resolveBin("claude"), args, { onOutput: streamFilter });
85
+ const output = extractTextFromStreamJson(res.stdout);
86
+ return { ok: res.exitCode === 0, output, error: res.stderr, exitCode: res.exitCode };
87
+ }
88
+
89
+ const res = await runCommand(resolveBin("claude"), args);
12
90
  return { ok: res.exitCode === 0, output: res.stdout, error: res.stderr, exitCode: res.exitCode };
13
91
  }
14
92
 
@@ -6,6 +6,10 @@
6
6
  export const PROGRESS_STAGES = [
7
7
  "session:start",
8
8
  "iteration:start",
9
+ "triage:start",
10
+ "triage:end",
11
+ "researcher:start",
12
+ "researcher:end",
9
13
  "planner:start",
10
14
  "planner:end",
11
15
  "coder:start",
@@ -22,7 +26,9 @@ export const PROGRESS_STAGES = [
22
26
  "question",
23
27
  "session:end",
24
28
  "dry-run:summary",
25
- "pipeline:tracker"
29
+ "pipeline:tracker",
30
+ "agent:heartbeat",
31
+ "agent:stall"
26
32
  ];
27
33
 
28
34
  const PIPELINE_ORDER = [
@@ -89,11 +95,19 @@ export function sendTrackerLog(server, stageName, status, summary) {
89
95
  }
90
96
  }
91
97
 
98
+ function resolveLogLevel(event) {
99
+ if (event.type === "agent:output") return "debug";
100
+ if (event.type === "agent:heartbeat") return "debug";
101
+ if (event.type === "agent:stall") return "warning";
102
+ if (event.status === "fail") return "error";
103
+ return "info";
104
+ }
105
+
92
106
  export function buildProgressHandler(server) {
93
107
  return (event) => {
94
108
  try {
95
109
  server.sendLoggingMessage({
96
- level: event.type === "agent:output" ? "debug" : event.status === "fail" ? "error" : "info",
110
+ level: resolveLogLevel(event),
97
111
  logger: "karajan",
98
112
  data: event
99
113
  });
@@ -8,6 +8,7 @@ import fs from "node:fs/promises";
8
8
  import { runKjCommand } from "./run-kj.js";
9
9
  import { normalizePlanArgs } from "./tool-arg-normalizers.js";
10
10
  import { buildProgressHandler, buildProgressNotifier, buildPipelineTracker, sendTrackerLog } from "./progress.js";
11
+ import { createStallDetector } from "../utils/stall-detector.js";
11
12
  import { runFlow, resumeFlow } from "../orchestrator.js";
12
13
  import { loadConfig, applyRunOverrides, validateConfig, resolveRole } from "../config.js";
13
14
  import { createLogger } from "../utils/logger.js";
@@ -19,6 +20,24 @@ import { buildReviewerPrompt } from "../prompts/reviewer.js";
19
20
  import { parseMaybeJsonString } from "../review/parser.js";
20
21
  import { computeBaseRef, generateDiff } from "../review/diff-generator.js";
21
22
  import { resolveReviewProfile } from "../review/profiles.js";
23
+ import { createRunLog, readRunLog } from "../utils/run-log.js";
24
+
25
+ /**
26
+ * Resolve the user's project directory via MCP roots.
27
+ * Falls back to process.cwd() if roots are not available.
28
+ */
29
+ async function resolveProjectDir(server) {
30
+ try {
31
+ const { roots } = await server.listRoots();
32
+ if (roots?.length > 0) {
33
+ const uri = roots[0].uri;
34
+ // MCP roots use file:// URIs
35
+ if (uri.startsWith("file://")) return new URL(uri).pathname;
36
+ return uri;
37
+ }
38
+ } catch { /* client may not support roots */ }
39
+ return process.cwd();
40
+ }
22
41
 
23
42
  export function asObject(value) {
24
43
  if (value && typeof value === "object") return value;
@@ -147,8 +166,13 @@ export async function handleRunDirect(a, server, extra) {
147
166
  if (config.pipeline?.security?.enabled) requiredProviders.push(resolveRole(config, "security").provider);
148
167
  await assertAgentsAvailable(requiredProviders);
149
168
 
169
+ const projectDir = await resolveProjectDir(server);
170
+ const runLog = createRunLog(projectDir);
171
+ runLog.logText(`[kj_run] started — task="${a.task.slice(0, 80)}..."`);
172
+
150
173
  const emitter = new EventEmitter();
151
174
  emitter.on("progress", buildProgressHandler(server));
175
+ emitter.on("progress", (event) => runLog.logEvent(event));
152
176
  const progressNotifier = buildProgressNotifier(extra);
153
177
  if (progressNotifier) emitter.on("progress", progressNotifier);
154
178
  buildPipelineTracker(config, emitter);
@@ -156,8 +180,13 @@ export async function handleRunDirect(a, server, extra) {
156
180
  const askQuestion = buildAskQuestion(server);
157
181
  const pgTaskId = a.pgTask || null;
158
182
  const pgProject = a.pgProject || config.planning_game?.project_id || null;
159
- const result = await runFlow({ task: a.task, config, logger, flags: a, emitter, askQuestion, pgTaskId, pgProject });
160
- return { ok: !result.paused && (result.approved !== false), ...result };
183
+ try {
184
+ const result = await runFlow({ task: a.task, config, logger, flags: a, emitter, askQuestion, pgTaskId, pgProject });
185
+ runLog.logText(`[kj_run] finished — ok=${!result.paused && (result.approved !== false)}`);
186
+ return { ok: !result.paused && (result.approved !== false), ...result };
187
+ } finally {
188
+ runLog.close();
189
+ }
161
190
  }
162
191
 
163
192
  export async function handleResumeDirect(a, server, extra) {
@@ -182,6 +211,20 @@ export async function handleResumeDirect(a, server, extra) {
182
211
  return { ok: true, ...result };
183
212
  }
184
213
 
214
+ function buildDirectEmitter(server, runLog) {
215
+ const emitter = new EventEmitter();
216
+ emitter.on("progress", (event) => {
217
+ try {
218
+ const level = event.type === "agent:stall" ? "warning"
219
+ : event.type === "agent:heartbeat" ? "info"
220
+ : "debug";
221
+ server.sendLoggingMessage({ level, logger: "karajan", data: event });
222
+ } catch { /* best-effort */ }
223
+ if (runLog) runLog.logEvent(event);
224
+ });
225
+ return emitter;
226
+ }
227
+
185
228
  export async function handlePlanDirect(a, server, extra) {
186
229
  const options = normalizePlanArgs(a);
187
230
  const config = await buildConfig(options, "plan");
@@ -190,10 +233,31 @@ export async function handlePlanDirect(a, server, extra) {
190
233
  const plannerRole = resolveRole(config, "planner");
191
234
  await assertAgentsAvailable([plannerRole.provider]);
192
235
 
236
+ const projectDir = await resolveProjectDir(server);
237
+ const runLog = createRunLog(projectDir);
238
+ runLog.logText(`[kj_plan] started — provider=${plannerRole.provider}`);
239
+ const emitter = buildDirectEmitter(server, runLog);
240
+ const eventBase = { sessionId: null, iteration: 0, startedAt: Date.now() };
241
+ const onOutput = ({ stream, line }) => {
242
+ emitter.emit("progress", { type: "agent:output", stage: "planner", message: line, detail: { stream, agent: plannerRole.provider } });
243
+ };
244
+ const stallDetector = createStallDetector({
245
+ onOutput, emitter, eventBase, stage: "planner", provider: plannerRole.provider
246
+ });
247
+
193
248
  const planner = createAgent(plannerRole.provider, config, logger);
194
249
  const prompt = buildPlannerPrompt({ task: a.task, context: a.context });
195
250
  sendTrackerLog(server, "planner", "running", plannerRole.provider);
196
- const result = await planner.runTask({ prompt, role: "planner" });
251
+ runLog.logText(`[planner] agent launched, waiting for response...`);
252
+ let result;
253
+ try {
254
+ result = await planner.runTask({ prompt, role: "planner", onOutput: stallDetector.onOutput });
255
+ } finally {
256
+ stallDetector.stop();
257
+ const stats = stallDetector.stats();
258
+ runLog.logText(`[planner] finished — lines=${stats.lineCount}, bytes=${stats.bytesReceived}, elapsed=${Math.round(stats.elapsedMs / 1000)}s`);
259
+ runLog.close();
260
+ }
197
261
 
198
262
  if (!result.ok) {
199
263
  sendTrackerLog(server, "planner", "failed");
@@ -212,6 +276,18 @@ export async function handleCodeDirect(a, server, extra) {
212
276
  const coderRole = resolveRole(config, "coder");
213
277
  await assertAgentsAvailable([coderRole.provider]);
214
278
 
279
+ const projectDir = await resolveProjectDir(server);
280
+ const runLog = createRunLog(projectDir);
281
+ runLog.logText(`[kj_code] started — provider=${coderRole.provider}`);
282
+ const emitter = buildDirectEmitter(server, runLog);
283
+ const eventBase = { sessionId: null, iteration: 0, startedAt: Date.now() };
284
+ const onOutput = ({ stream, line }) => {
285
+ emitter.emit("progress", { type: "agent:output", stage: "coder", message: line, detail: { stream, agent: coderRole.provider } });
286
+ };
287
+ const stallDetector = createStallDetector({
288
+ onOutput, emitter, eventBase, stage: "coder", provider: coderRole.provider
289
+ });
290
+
215
291
  const coder = createAgent(coderRole.provider, config, logger);
216
292
  let coderRules = null;
217
293
  if (config.coder_rules) {
@@ -221,7 +297,16 @@ export async function handleCodeDirect(a, server, extra) {
221
297
  }
222
298
  const prompt = buildCoderPrompt({ task: a.task, coderRules, methodology: config.development?.methodology || "tdd" });
223
299
  sendTrackerLog(server, "coder", "running", coderRole.provider);
224
- const result = await coder.runTask({ prompt, role: "coder" });
300
+ runLog.logText(`[coder] agent launched, waiting for response...`);
301
+ let result;
302
+ try {
303
+ result = await coder.runTask({ prompt, role: "coder", onOutput: stallDetector.onOutput });
304
+ } finally {
305
+ stallDetector.stop();
306
+ const stats = stallDetector.stats();
307
+ runLog.logText(`[coder] finished — lines=${stats.lineCount}, bytes=${stats.bytesReceived}, elapsed=${Math.round(stats.elapsedMs / 1000)}s`);
308
+ runLog.close();
309
+ }
225
310
 
226
311
  if (!result.ok) {
227
312
  sendTrackerLog(server, "coder", "failed");
@@ -239,6 +324,18 @@ export async function handleReviewDirect(a, server, extra) {
239
324
  const reviewerRole = resolveRole(config, "reviewer");
240
325
  await assertAgentsAvailable([reviewerRole.provider, config.reviewer_options?.fallback_reviewer]);
241
326
 
327
+ const projectDir = await resolveProjectDir(server);
328
+ const runLog = createRunLog(projectDir);
329
+ runLog.logText(`[kj_review] started — provider=${reviewerRole.provider}`);
330
+ const emitter = buildDirectEmitter(server, runLog);
331
+ const eventBase = { sessionId: null, iteration: 0, startedAt: Date.now() };
332
+ const onOutput = ({ stream, line }) => {
333
+ emitter.emit("progress", { type: "agent:output", stage: "reviewer", message: line, detail: { stream, agent: reviewerRole.provider } });
334
+ };
335
+ const stallDetector = createStallDetector({
336
+ onOutput, emitter, eventBase, stage: "reviewer", provider: reviewerRole.provider
337
+ });
338
+
242
339
  const reviewer = createAgent(reviewerRole.provider, config, logger);
243
340
  const resolvedBase = await computeBaseRef({ baseBranch: config.base_branch, baseRef: a.baseRef });
244
341
  const diff = await generateDiff({ baseRef: resolvedBase });
@@ -246,7 +343,16 @@ export async function handleReviewDirect(a, server, extra) {
246
343
 
247
344
  const prompt = buildReviewerPrompt({ task: a.task, diff, reviewRules: rules, mode: config.review_mode });
248
345
  sendTrackerLog(server, "reviewer", "running", reviewerRole.provider);
249
- const result = await reviewer.reviewTask({ prompt, role: "reviewer" });
346
+ runLog.logText(`[reviewer] agent launched, waiting for response...`);
347
+ let result;
348
+ try {
349
+ result = await reviewer.reviewTask({ prompt, role: "reviewer", onOutput: stallDetector.onOutput });
350
+ } finally {
351
+ stallDetector.stop();
352
+ const stats = stallDetector.stats();
353
+ runLog.logText(`[reviewer] finished — lines=${stats.lineCount}, bytes=${stats.bytesReceived}, elapsed=${Math.round(stats.elapsedMs / 1000)}s`);
354
+ runLog.close();
355
+ }
250
356
 
251
357
  if (!result.ok) {
252
358
  sendTrackerLog(server, "reviewer", "failed");
@@ -261,6 +367,12 @@ export async function handleReviewDirect(a, server, extra) {
261
367
  export async function handleToolCall(name, args, server, extra) {
262
368
  const a = asObject(args);
263
369
 
370
+ if (name === "kj_status") {
371
+ const maxLines = a.lines || 50;
372
+ const projectDir = await resolveProjectDir(server);
373
+ return readRunLog(maxLines, projectDir);
374
+ }
375
+
264
376
  if (name === "kj_init") {
265
377
  return runKjCommand({ command: "init", options: a });
266
378
  }
package/src/mcp/tools.js CHANGED
@@ -165,6 +165,16 @@ export const tools = [
165
165
  }
166
166
  }
167
167
  },
168
+ {
169
+ name: "kj_status",
170
+ description: "Show real-time log of the current or last Karajan run. Use this to monitor progress while kj_run/kj_plan/kj_code is executing. Reads from .kj/run.log in the project directory.",
171
+ inputSchema: {
172
+ type: "object",
173
+ properties: {
174
+ lines: { type: "number", description: "Number of log lines to show (default 50)" }
175
+ }
176
+ }
177
+ },
168
178
  {
169
179
  name: "kj_plan",
170
180
  description: "Generate implementation plan for a task",
@@ -11,6 +11,7 @@ import { runReviewerWithFallback } from "./reviewer-fallback.js";
11
11
  import { runCoderWithFallback } from "./agent-fallback.js";
12
12
  import { invokeSolomon } from "./solomon-escalation.js";
13
13
  import { detectRateLimit } from "../utils/rate-limit-detector.js";
14
+ import { createStallDetector } from "../utils/stall-detector.js";
14
15
 
15
16
  export async function runCoderStage({ coderRoleInstance, coderRole, config, logger, emitter, eventBase, session, plannedTask, trackBudget, iteration }) {
16
17
  logger.setContext({ iteration, stage: "coder" });
@@ -28,13 +29,21 @@ export async function runCoderStage({ coderRoleInstance, coderRole, config, logg
28
29
  detail: { stream, agent: coderRole.provider }
29
30
  }));
30
31
  };
31
- const coderStart = Date.now();
32
- const coderExecResult = await coderRoleInstance.execute({
33
- task: plannedTask,
34
- reviewerFeedback: session.last_reviewer_feedback,
35
- sonarSummary: session.last_sonar_summary,
36
- onOutput: coderOnOutput
32
+ const coderStall = createStallDetector({
33
+ onOutput: coderOnOutput, emitter, eventBase, stage: "coder", provider: coderRole.provider
37
34
  });
35
+ const coderStart = Date.now();
36
+ let coderExecResult;
37
+ try {
38
+ coderExecResult = await coderRoleInstance.execute({
39
+ task: plannedTask,
40
+ reviewerFeedback: session.last_reviewer_feedback,
41
+ sonarSummary: session.last_sonar_summary,
42
+ onOutput: coderStall.onOutput
43
+ });
44
+ } finally {
45
+ coderStall.stop();
46
+ }
38
47
  trackBudget({ role: "coder", provider: coderRole.provider, model: coderRole.model, result: coderExecResult.result, duration_ms: Date.now() - coderStart });
39
48
 
40
49
  if (!coderExecResult.ok) {
@@ -130,10 +139,25 @@ export async function runRefactorerStage({ refactorerRole, config, logger, emitt
130
139
  detail: { refactorer: refactorerRole.provider }
131
140
  })
132
141
  );
142
+ const refactorerOnOutput = ({ stream, line }) => {
143
+ emitProgress(emitter, makeEvent("agent:output", { ...eventBase, stage: "refactorer" }, {
144
+ message: line,
145
+ detail: { stream, agent: refactorerRole.provider }
146
+ }));
147
+ };
148
+ const refactorerStall = createStallDetector({
149
+ onOutput: refactorerOnOutput, emitter, eventBase, stage: "refactorer", provider: refactorerRole.provider
150
+ });
151
+
133
152
  const refRole = new RefactorerRole({ config, logger, emitter, createAgentFn: createAgent });
134
153
  await refRole.init();
135
154
  const refactorerStart = Date.now();
136
- const refResult = await refRole.execute(plannedTask);
155
+ let refResult;
156
+ try {
157
+ refResult = await refRole.execute({ task: plannedTask, onOutput: refactorerStall.onOutput });
158
+ } finally {
159
+ refactorerStall.stop();
160
+ }
137
161
  trackBudget({ role: "refactorer", provider: refactorerRole.provider, model: refactorerRole.model, result: refResult.result, duration_ms: Date.now() - refactorerStart });
138
162
  if (!refResult.ok) {
139
163
  const details = refResult.result?.error || refResult.summary || "unknown error";
@@ -392,19 +416,27 @@ export async function runReviewerStage({ reviewerRole, config, logger, emitter,
392
416
  detail: { stream, agent: reviewerRole.provider }
393
417
  }));
394
418
  };
395
- const reviewerStart = Date.now();
396
- const reviewerExec = await runReviewerWithFallback({
397
- reviewerName: reviewerRole.provider,
398
- config,
399
- logger,
400
- emitter,
401
- reviewInput: { task, diff, reviewRules, onOutput: reviewerOnOutput },
402
- session,
403
- iteration,
404
- onAttemptResult: ({ reviewer, result }) => {
405
- trackBudget({ role: "reviewer", provider: reviewer, model: reviewerRole.model, result, duration_ms: Date.now() - reviewerStart });
406
- }
419
+ const reviewerStall = createStallDetector({
420
+ onOutput: reviewerOnOutput, emitter, eventBase, stage: "reviewer", provider: reviewerRole.provider
407
421
  });
422
+ const reviewerStart = Date.now();
423
+ let reviewerExec;
424
+ try {
425
+ reviewerExec = await runReviewerWithFallback({
426
+ reviewerName: reviewerRole.provider,
427
+ config,
428
+ logger,
429
+ emitter,
430
+ reviewInput: { task, diff, reviewRules, onOutput: reviewerStall.onOutput },
431
+ session,
432
+ iteration,
433
+ onAttemptResult: ({ reviewer, result }) => {
434
+ trackBudget({ role: "reviewer", provider: reviewer, model: reviewerRole.model, result, duration_ms: Date.now() - reviewerStart });
435
+ }
436
+ });
437
+ } finally {
438
+ reviewerStall.stop();
439
+ }
408
440
 
409
441
  if (!reviewerExec.execResult || !reviewerExec.execResult.ok) {
410
442
  const lastAttempt = reviewerExec.attempts.at(-1);
@@ -6,6 +6,7 @@ import { addCheckpoint, markSessionStatus } from "../session-store.js";
6
6
  import { emitProgress, makeEvent } from "../utils/events.js";
7
7
  import { parsePlannerOutput } from "../prompts/planner.js";
8
8
  import { selectModelsForRoles } from "../utils/model-selector.js";
9
+ import { createStallDetector } from "../utils/stall-detector.js";
9
10
 
10
11
  export async function runTriageStage({ config, logger, emitter, eventBase, session, coderRole, trackBudget }) {
11
12
  logger.setContext({ iteration: 0, stage: "triage" });
@@ -16,10 +17,26 @@ export async function runTriageStage({ config, logger, emitter, eventBase, sessi
16
17
  })
17
18
  );
18
19
 
20
+ const triageProvider = config?.roles?.triage?.provider || coderRole.provider;
21
+ const triageOnOutput = ({ stream, line }) => {
22
+ emitProgress(emitter, makeEvent("agent:output", { ...eventBase, stage: "triage" }, {
23
+ message: line,
24
+ detail: { stream, agent: triageProvider }
25
+ }));
26
+ };
27
+ const triageStall = createStallDetector({
28
+ onOutput: triageOnOutput, emitter, eventBase, stage: "triage", provider: triageProvider
29
+ });
30
+
19
31
  const triage = new TriageRole({ config, logger, emitter });
20
32
  await triage.init({ task: session.task, sessionId: session.id, iteration: 0 });
21
33
  const triageStart = Date.now();
22
- const triageOutput = await triage.run({ task: session.task });
34
+ let triageOutput;
35
+ try {
36
+ triageOutput = await triage.run({ task: session.task, onOutput: triageStall.onOutput });
37
+ } finally {
38
+ triageStall.stop();
39
+ }
23
40
  trackBudget({
24
41
  role: "triage",
25
42
  provider: config?.roles?.triage?.provider || coderRole.provider,
@@ -115,10 +132,26 @@ export async function runResearcherStage({ config, logger, emitter, eventBase, s
115
132
  })
116
133
  );
117
134
 
135
+ const researcherProvider = config?.roles?.researcher?.provider || coderRole.provider;
136
+ const researcherOnOutput = ({ stream, line }) => {
137
+ emitProgress(emitter, makeEvent("agent:output", { ...eventBase, stage: "researcher" }, {
138
+ message: line,
139
+ detail: { stream, agent: researcherProvider }
140
+ }));
141
+ };
142
+ const researcherStall = createStallDetector({
143
+ onOutput: researcherOnOutput, emitter, eventBase, stage: "researcher", provider: researcherProvider
144
+ });
145
+
118
146
  const researcher = new ResearcherRole({ config, logger, emitter });
119
147
  await researcher.init({ task: session.task });
120
148
  const researchStart = Date.now();
121
- const researchOutput = await researcher.run({ task: session.task });
149
+ let researchOutput;
150
+ try {
151
+ researchOutput = await researcher.run({ task: session.task, onOutput: researcherStall.onOutput });
152
+ } finally {
153
+ researcherStall.stop();
154
+ }
122
155
  trackBudget({
123
156
  role: "researcher",
124
157
  provider: config?.roles?.researcher?.provider || coderRole.provider,
@@ -160,11 +193,26 @@ export async function runPlannerStage({ config, logger, emitter, eventBase, sess
160
193
  })
161
194
  );
162
195
 
196
+ const plannerOnOutput = ({ stream, line }) => {
197
+ emitProgress(emitter, makeEvent("agent:output", { ...eventBase, stage: "planner" }, {
198
+ message: line,
199
+ detail: { stream, agent: plannerRole.provider }
200
+ }));
201
+ };
202
+ const plannerStall = createStallDetector({
203
+ onOutput: plannerOnOutput, emitter, eventBase, stage: "planner", provider: plannerRole.provider
204
+ });
205
+
163
206
  const planRole = new PlannerRole({ config, logger, emitter, createAgentFn: createAgent });
164
207
  planRole.context = { task, research: researchContext, triageDecomposition };
165
208
  await planRole.init();
166
209
  const plannerStart = Date.now();
167
- const planResult = await planRole.execute(task);
210
+ let planResult;
211
+ try {
212
+ planResult = await planRole.execute({ task, onOutput: plannerStall.onOutput });
213
+ } finally {
214
+ plannerStall.stop();
215
+ }
168
216
  trackBudget({ role: "planner", provider: plannerRole.provider, model: plannerRole.model, result: planResult.result, duration_ms: Date.now() - plannerStart });
169
217
  await addCheckpoint(session, {
170
218
  stage: "planner",
@@ -65,15 +65,20 @@ export class PlannerRole extends BaseRole {
65
65
  }
66
66
 
67
67
  async execute(input) {
68
- const task = input || this.context?.task || "";
68
+ const { task, onOutput } = typeof input === "string"
69
+ ? { task: input, onOutput: null }
70
+ : { task: input?.task || input || "", onOutput: input?.onOutput || null };
71
+ const taskStr = task || this.context?.task || "";
69
72
  const research = this.context?.research || null;
70
73
  const triageDecomposition = this.context?.triageDecomposition || null;
71
74
  const provider = resolveProvider(this.config);
72
75
 
73
76
  const agent = this._createAgent(provider, this.config, this.logger);
74
- const prompt = buildPrompt({ task, instructions: this.instructions, research, triageDecomposition });
77
+ const prompt = buildPrompt({ task: taskStr, instructions: this.instructions, research, triageDecomposition });
75
78
 
76
- const result = await agent.runTask({ prompt, role: "planner" });
79
+ const runArgs = { prompt, role: "planner" };
80
+ if (onOutput) runArgs.onOutput = onOutput;
81
+ const result = await agent.runTask(runArgs);
77
82
 
78
83
  if (!result.ok) {
79
84
  return {
@@ -36,12 +36,15 @@ export class RefactorerRole extends BaseRole {
36
36
  const task = typeof input === "string"
37
37
  ? input
38
38
  : input?.task || this.context?.task || "";
39
+ const onOutput = typeof input === "string" ? null : input?.onOutput || null;
39
40
 
40
41
  const provider = resolveProvider(this.config);
41
42
  const agent = this._createAgent(provider, this.config, this.logger);
42
43
 
43
44
  const prompt = buildPrompt({ task, instructions: this.instructions });
44
- const result = await agent.runTask({ prompt, role: "refactorer" });
45
+ const runArgs = { prompt, role: "refactorer" };
46
+ if (onOutput) runArgs.onOutput = onOutput;
47
+ const result = await agent.runTask(runArgs);
45
48
 
46
49
  if (!result.ok) {
47
50
  return {
@@ -64,12 +64,15 @@ export class ResearcherRole extends BaseRole {
64
64
  const task = typeof input === "string"
65
65
  ? input
66
66
  : input?.task || this.context?.task || "";
67
+ const onOutput = typeof input === "string" ? null : input?.onOutput || null;
67
68
 
68
69
  const provider = resolveProvider(this.config);
69
70
  const agent = this._createAgent(provider, this.config, this.logger);
70
71
 
71
72
  const prompt = buildPrompt({ task, instructions: this.instructions });
72
- const result = await agent.runTask({ prompt, role: "researcher" });
73
+ const runArgs = { prompt, role: "researcher" };
74
+ if (onOutput) runArgs.onOutput = onOutput;
75
+ const result = await agent.runTask(runArgs);
73
76
 
74
77
  if (!result.ok) {
75
78
  return {
@@ -67,12 +67,15 @@ export class TriageRole extends BaseRole {
67
67
  const task = typeof input === "string"
68
68
  ? input
69
69
  : input?.task || this.context?.task || "";
70
+ const onOutput = typeof input === "string" ? null : input?.onOutput || null;
70
71
 
71
72
  const provider = resolveProvider(this.config);
72
73
  const agent = this._createAgent(provider, this.config, this.logger);
73
74
 
74
75
  const prompt = buildPrompt({ task, instructions: this.instructions });
75
- const result = await agent.runTask({ prompt, role: "triage" });
76
+ const runArgs = { prompt, role: "triage" };
77
+ if (onOutput) runArgs.onOutput = onOutput;
78
+ const result = await agent.runTask(runArgs);
76
79
 
77
80
  if (!result.ok) {
78
81
  return {
@@ -0,0 +1,126 @@
1
+ /**
2
+ * File-based run logger.
3
+ *
4
+ * Writes progress events to a known file so that external tools
5
+ * (tail -f, kj_status, another Claude process) can monitor what
6
+ * Karajan is doing in real time.
7
+ *
8
+ * Log location: <projectDir>/.kj/run.log (overwritten each run)
9
+ */
10
+
11
+ import fs from "node:fs";
12
+ import path from "node:path";
13
+
14
+ const LOG_FILENAME = "run.log";
15
+
16
+ function resolveLogDir(baseDir) {
17
+ return path.join(baseDir || process.cwd(), ".kj");
18
+ }
19
+
20
+ function resolveLogPath(baseDir) {
21
+ return path.join(resolveLogDir(baseDir), LOG_FILENAME);
22
+ }
23
+
24
+ function ensureDir(dir) {
25
+ try {
26
+ fs.mkdirSync(dir, { recursive: true });
27
+ } catch { /* already exists */ }
28
+ }
29
+
30
+ function formatLine(event) {
31
+ const ts = new Date().toISOString().slice(11, 23);
32
+ const stage = event.stage || event.detail?.stage || "";
33
+ const type = event.type || "info";
34
+ const msg = event.message || "";
35
+ const extra = [];
36
+
37
+ if (event.detail?.provider) extra.push(`agent=${event.detail.provider}`);
38
+ if (event.detail?.lineCount !== undefined) extra.push(`lines=${event.detail.lineCount}`);
39
+ if (event.detail?.elapsedMs !== undefined) extra.push(`elapsed=${Math.round(event.detail.elapsedMs / 1000)}s`);
40
+ if (event.detail?.silenceMs !== undefined) extra.push(`silence=${Math.round(event.detail.silenceMs / 1000)}s`);
41
+ if (event.detail?.severity) extra.push(`severity=${event.detail.severity}`);
42
+ if (event.detail?.stream) extra.push(`stream=${event.detail.stream}`);
43
+
44
+ const extraStr = extra.length ? ` (${extra.join(", ")})` : "";
45
+ return `${ts} [${type}] ${stage ? `[${stage}] ` : ""}${msg}${extraStr}`;
46
+ }
47
+
48
+ export function createRunLog(projectDir) {
49
+ const logPath = resolveLogPath(projectDir);
50
+ const logDir = resolveLogDir(projectDir);
51
+ ensureDir(logDir);
52
+
53
+ // Truncate/create the log file
54
+ fs.writeFileSync(logPath, `--- Karajan run started at ${new Date().toISOString()} ---\n`);
55
+
56
+ let fd = null;
57
+ try {
58
+ fd = fs.openSync(logPath, "a");
59
+ } catch {
60
+ // If we can't open for append, use writeFile fallback
61
+ }
62
+
63
+ function write(line) {
64
+ try {
65
+ if (fd !== null) {
66
+ fs.writeSync(fd, line + "\n");
67
+ } else {
68
+ fs.appendFileSync(logPath, line + "\n");
69
+ }
70
+ } catch { /* best-effort */ }
71
+ }
72
+
73
+ function logEvent(event) {
74
+ write(formatLine(event));
75
+ }
76
+
77
+ function logText(text) {
78
+ const ts = new Date().toISOString().slice(11, 23);
79
+ write(`${ts} ${text}`);
80
+ }
81
+
82
+ function close() {
83
+ try {
84
+ if (fd !== null) {
85
+ fs.closeSync(fd);
86
+ fd = null;
87
+ }
88
+ } catch { /* best-effort */ }
89
+ }
90
+
91
+ return {
92
+ logEvent,
93
+ logText,
94
+ close,
95
+ get path() { return logPath; }
96
+ };
97
+ }
98
+
99
+ /**
100
+ * Read the current run log contents.
101
+ * Returns the last N lines (default 50).
102
+ */
103
+ export function readRunLog(maxLines = 50, projectDir) {
104
+ const logPath = resolveLogPath(projectDir);
105
+ try {
106
+ const content = fs.readFileSync(logPath, "utf8");
107
+ const lines = content.split("\n").filter(Boolean);
108
+ const total = lines.length;
109
+ const shown = lines.slice(-maxLines);
110
+ return {
111
+ ok: true,
112
+ path: logPath,
113
+ totalLines: total,
114
+ lines: shown,
115
+ summary: shown.join("\n")
116
+ };
117
+ } catch (err) {
118
+ return {
119
+ ok: false,
120
+ path: logPath,
121
+ error: err.code === "ENOENT"
122
+ ? "No active run log found. Start a run with kj_run first."
123
+ : `Failed to read log: ${err.message}`
124
+ };
125
+ }
126
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Stall detector for agent execution.
3
+ *
4
+ * Wraps an onOutput callback to track activity and emit heartbeat / stall
5
+ * warnings when an agent stops producing output for too long.
6
+ *
7
+ * Usage:
8
+ * const detector = createStallDetector({ onOutput, emitter, eventBase, stage, provider, stallTimeoutMs });
9
+ * // pass detector.onOutput to the agent
10
+ * // when done: detector.stop();
11
+ */
12
+
13
+ import { emitProgress, makeEvent } from "./events.js";
14
+
15
+ const DEFAULT_HEARTBEAT_INTERVAL_MS = 30_000; // heartbeat every 30s
16
+ const DEFAULT_STALL_TIMEOUT_MS = 120_000; // warn after 2min silence
17
+ const DEFAULT_CRITICAL_TIMEOUT_MS = 300_000; // critical after 5min silence
18
+
19
+ export function createStallDetector({
20
+ onOutput,
21
+ emitter,
22
+ eventBase,
23
+ stage,
24
+ provider,
25
+ heartbeatIntervalMs = DEFAULT_HEARTBEAT_INTERVAL_MS,
26
+ stallTimeoutMs = DEFAULT_STALL_TIMEOUT_MS,
27
+ criticalTimeoutMs = DEFAULT_CRITICAL_TIMEOUT_MS
28
+ }) {
29
+ let lastActivityAt = Date.now();
30
+ let lineCount = 0;
31
+ let bytesReceived = 0;
32
+ let stallWarned = false;
33
+ let criticalWarned = false;
34
+ let heartbeatTimer = null;
35
+ const startedAt = Date.now();
36
+
37
+ function emitHeartbeat() {
38
+ const now = Date.now();
39
+ const silenceMs = now - lastActivityAt;
40
+ const elapsedMs = now - startedAt;
41
+
42
+ if (silenceMs >= criticalTimeoutMs && !criticalWarned) {
43
+ criticalWarned = true;
44
+ emitProgress(emitter, makeEvent("agent:stall", { ...eventBase, stage }, {
45
+ status: "critical",
46
+ message: `Agent ${provider} unresponsive for ${Math.round(silenceMs / 1000)}s — may be hung`,
47
+ detail: {
48
+ provider,
49
+ silenceMs,
50
+ elapsedMs,
51
+ lineCount,
52
+ bytesReceived,
53
+ severity: "critical"
54
+ }
55
+ }));
56
+ } else if (silenceMs >= stallTimeoutMs && !stallWarned) {
57
+ stallWarned = true;
58
+ emitProgress(emitter, makeEvent("agent:stall", { ...eventBase, stage }, {
59
+ status: "warning",
60
+ message: `Agent ${provider} silent for ${Math.round(silenceMs / 1000)}s — still waiting`,
61
+ detail: {
62
+ provider,
63
+ silenceMs,
64
+ elapsedMs,
65
+ lineCount,
66
+ bytesReceived,
67
+ severity: "warning"
68
+ }
69
+ }));
70
+ } else if (silenceMs < stallTimeoutMs) {
71
+ // Reset warning flags when activity resumes
72
+ stallWarned = false;
73
+ criticalWarned = false;
74
+
75
+ emitProgress(emitter, makeEvent("agent:heartbeat", { ...eventBase, stage }, {
76
+ message: `Agent ${provider} active — ${lineCount} lines, ${Math.round(elapsedMs / 1000)}s elapsed`,
77
+ detail: {
78
+ provider,
79
+ elapsedMs,
80
+ lineCount,
81
+ bytesReceived
82
+ }
83
+ }));
84
+ }
85
+ }
86
+
87
+ // Start periodic heartbeat
88
+ heartbeatTimer = setInterval(emitHeartbeat, heartbeatIntervalMs);
89
+
90
+ function wrappedOnOutput(data) {
91
+ lastActivityAt = Date.now();
92
+ lineCount++;
93
+ bytesReceived += data.line?.length || 0;
94
+
95
+ // Reset stall flags on new activity
96
+ stallWarned = false;
97
+ criticalWarned = false;
98
+
99
+ // Forward to the original callback
100
+ if (onOutput) {
101
+ onOutput(data);
102
+ }
103
+ }
104
+
105
+ function stop() {
106
+ if (heartbeatTimer) {
107
+ clearInterval(heartbeatTimer);
108
+ heartbeatTimer = null;
109
+ }
110
+ }
111
+
112
+ function stats() {
113
+ return {
114
+ lineCount,
115
+ bytesReceived,
116
+ elapsedMs: Date.now() - startedAt,
117
+ lastActivityMs: Date.now() - lastActivityAt
118
+ };
119
+ }
120
+
121
+ return {
122
+ onOutput: wrappedOnOutput,
123
+ stop,
124
+ stats
125
+ };
126
+ }