@stilero/bankan 1.0.13 → 1.0.17

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.
@@ -3,7 +3,7 @@ import { rm } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
4
  import { execFileSync } from 'node:child_process';
5
5
  import { simpleGit } from 'simple-git';
6
- import config, { loadSettings, getWorkspacesDir } from './config.js';
6
+ import { loadSettings, getWorkspacesDir } from './config.js';
7
7
  import store from './store.js';
8
8
  import agentManager from './agents.js';
9
9
  import bus from './events.js';
@@ -23,8 +23,13 @@ let signalTimer = null;
23
23
 
24
24
  function stripAnsi(text) {
25
25
  if (typeof text !== 'string') return text;
26
- return text.replace(
27
- /[\x1b\x9b][\[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><~]|\x1b\].*?(?:\x07|\x1b\\)|\r/g,
26
+ // Replace cursor forward codes (\x1b[nC) with a space to preserve word boundaries.
27
+ // eslint-disable-next-line no-control-regex
28
+ let result = text.replace(/\x1b\[\d*C/g, ' ');
29
+ // Strip remaining ANSI control sequences.
30
+ return result.replace(
31
+ // eslint-disable-next-line no-control-regex
32
+ /[\x1b\x9b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><~]|\x1b\].*?(?:\x07|\x1b\\)|\r/g,
28
33
  ''
29
34
  );
30
35
  }
@@ -33,31 +38,34 @@ function escapePrompt(text) {
33
38
  return text.replace(/'/g, "'\\''");
34
39
  }
35
40
 
36
- function buildCodexExecCommand(prompt, { captureLastMessage = false, sandbox = 'read-only' } = {}) {
41
+ function buildCodexExecCommand(prompt, { captureLastMessage = false, sandbox = 'read-only', model = '' } = {}) {
37
42
  const escapedPrompt = escapePrompt(prompt);
43
+ const modelFlag = model ? `-m ${model} ` : '';
38
44
  if (!captureLastMessage) {
39
- return `codex exec --sandbox ${sandbox} '${escapedPrompt}'`;
45
+ return `codex exec ${modelFlag}--sandbox ${sandbox} '${escapedPrompt}'`;
40
46
  }
41
47
 
42
- return `tmpfile=$(mktemp); codex exec --sandbox ${sandbox} -o "$tmpfile" '${escapedPrompt}'; status=$?; printf '\\n=== CODEX_LAST_MESSAGE_FILE:%s ===\\n' "$tmpfile"; exit $status`;
48
+ return `tmpfile=$(mktemp); codex exec ${modelFlag}--sandbox ${sandbox} -o "$tmpfile" '${escapedPrompt}'; status=$?; printf '\\n=== CODEX_LAST_MESSAGE_FILE:%s ===\\n' "$tmpfile"; exit $status`;
43
49
  }
44
50
 
45
- function buildAgentCommand(cliTool, prompt, mode = 'interactive') {
51
+ export function buildAgentCommand(cliTool, prompt, mode = 'interactive', model = '') {
46
52
  if (cliTool === 'codex') {
47
53
  if (mode === 'plan' || mode === 'review') {
48
- return buildCodexExecCommand(prompt, { captureLastMessage: true, sandbox: 'read-only' });
54
+ return buildCodexExecCommand(prompt, { captureLastMessage: true, sandbox: 'read-only', model });
49
55
  }
50
56
  if (mode === 'interactive') {
51
- return buildCodexExecCommand(prompt, { captureLastMessage: true, sandbox: 'danger-full-access' });
57
+ return buildCodexExecCommand(prompt, { captureLastMessage: true, sandbox: 'danger-full-access', model });
52
58
  }
53
- return buildCodexExecCommand(prompt, { captureLastMessage: false, sandbox: 'read-only' });
59
+ return buildCodexExecCommand(prompt, { captureLastMessage: false, sandbox: 'read-only', model });
54
60
  }
55
61
 
56
- if (mode === 'print' || mode === 'plan' || mode === 'review') {
57
- return `claude --print '${escapePrompt(prompt)}'`;
62
+ const modelFlag = model ? `--model ${model} ` : '';
63
+
64
+ if (mode === 'print') {
65
+ return `claude ${modelFlag}--print '${escapePrompt(prompt)}'`;
58
66
  }
59
67
 
60
- return `claude --dangerously-skip-permissions '${escapePrompt(prompt)}'`;
68
+ return `claude ${modelFlag}--dangerously-skip-permissions '${escapePrompt(prompt)}'`;
61
69
  }
62
70
 
63
71
  function getLastStructuredBlock(text, startMarker, endMarker) {
@@ -69,6 +77,98 @@ function getLastStructuredBlock(text, startMarker, endMarker) {
69
77
  return text.slice(startIdx, endIdx + endMarker.length);
70
78
  }
71
79
 
80
+ function getAllStructuredBlocks(text, startMarker, endMarker) {
81
+ if (typeof text !== 'string' || !text) return [];
82
+ const blocks = [];
83
+ let searchFrom = 0;
84
+ while (true) {
85
+ const startIdx = text.indexOf(startMarker, searchFrom);
86
+ if (startIdx === -1) break;
87
+ const endIdx = text.indexOf(endMarker, startIdx + startMarker.length);
88
+ if (endIdx === -1) break;
89
+ blocks.push(text.slice(startIdx, endIdx + endMarker.length));
90
+ searchFrom = endIdx + endMarker.length;
91
+ }
92
+ return blocks;
93
+ }
94
+
95
+ // Terminal UI noise patterns left behind after ANSI stripping.
96
+ // Matches entire lines that are purely artifacts.
97
+ const TERMINAL_ARTIFACT_LINE_RE = /^(?:.*(?:⏵⏵bypass|bypasspermission|shift\+tab\s*to\s*cycle)|.*Opus\s*4\.\d.*(?:│|context)|.*Claude(?:Code|Max)|.*▐▛|.*▝▜|.*[░▓█]{3,}|[─━═]{10,}|^\s*[❯›]\s*$|.*\.data\/workspaces\/T-)/i;
98
+
99
+ // Prompt echo lines: CLI echoes the full prompt and org messages into the terminal.
100
+ // These lines are noise when captured as part of the plan/review text.
101
+ const PROMPT_ECHO_LINE_RE = /^(?:❯\s+\S|.*\bOrganization:|\s*(?:Repository|Workspace):\s|.*(?:TASK\s+ID|PRIORITY):\s|.*Plan\s+Mode\s+Instructions|.*Core\s+constraints:|.*Output\s+ONLY\s+in\s+this\s+exact\s+format|.*Do\s+not\s+edit\s+files.*change\s+system\s+state|.*Treat\s+this\s+stage\s+as\s+planning\s+only)/i;
102
+
103
+ // Inline artifacts that can appear at the end of or within content lines.
104
+ // These are stripped from each line individually.
105
+ const TRAILING_ARTIFACT_RE = /\s*[❯›]\s*[─━═]{4,}.*$/;
106
+ const INLINE_ARTIFACT_RE = /[─━═]{10,}/g;
107
+
108
+ export function cleanTerminalArtifacts(text) {
109
+ if (typeof text !== 'string') return text;
110
+
111
+ // If the text contains a second === PLAN START === or === REVIEW START ===,
112
+ // truncate at that point — everything after is echoed prompt/template noise.
113
+ // Also strip noise lines between the real plan content and the truncation point.
114
+ let truncated = text;
115
+ for (const marker of ['=== PLAN START ===', '=== REVIEW START ===']) {
116
+ const firstIdx = truncated.indexOf(marker);
117
+ if (firstIdx !== -1) {
118
+ const secondIdx = truncated.indexOf(marker, firstIdx + marker.length);
119
+ if (secondIdx !== -1) {
120
+ truncated = truncated.slice(0, secondIdx).trimEnd();
121
+ // Find where real plan content ends by locating the last structured
122
+ // section header and its items, then strip everything after.
123
+ const contentLines = truncated.split('\n');
124
+ const planHeaderRe = /^(?:SUMMARY:|BRANCH:|FILES_TO_MODIFY:|STEPS:|TESTS_NEEDED:|RISKS:|VERDICT:|CRITICAL_ISSUES:|MINOR_ISSUES:|CHANGED_FILES:)/;
125
+ let lastFieldLine = contentLines.length - 1;
126
+ let sectionIdx = -1;
127
+ for (let i = 0; i < contentLines.length; i++) {
128
+ if (planHeaderRe.test(contentLines[i].trim())) sectionIdx = i;
129
+ }
130
+ if (sectionIdx !== -1) {
131
+ lastFieldLine = sectionIdx;
132
+ for (let i = sectionIdx + 1; i < contentLines.length; i++) {
133
+ const trimmed = contentLines[i].trim();
134
+ if (!trimmed) break;
135
+ if (trimmed.startsWith('- ') || /^\d+\.\s/.test(trimmed)) {
136
+ lastFieldLine = i;
137
+ } else {
138
+ break;
139
+ }
140
+ }
141
+ }
142
+ truncated = contentLines.slice(0, lastFieldLine + 1).join('\n');
143
+ // Re-add the end marker if it was lost
144
+ const endMarker = marker.replace('START', 'END');
145
+ if (!truncated.includes(endMarker)) {
146
+ truncated += '\n' + endMarker;
147
+ }
148
+ }
149
+ }
150
+ }
151
+
152
+ const lines = truncated.split('\n');
153
+ const cleaned = lines
154
+ .map(line => {
155
+ // Trim trailing box-drawing and prompt artifacts from content lines
156
+ let result = line.replace(TRAILING_ARTIFACT_RE, '');
157
+ result = result.replace(INLINE_ARTIFACT_RE, '');
158
+ // Strip trailing prompt characters (❯/›) from content lines
159
+ result = result.replace(/\s*[❯›]\s*$/, '');
160
+ return result.trimEnd();
161
+ })
162
+ .filter(line => {
163
+ if (!line) return true; // keep blank lines
164
+ if (TERMINAL_ARTIFACT_LINE_RE.test(line)) return false;
165
+ if (PROMPT_ECHO_LINE_RE.test(line)) return false;
166
+ return true;
167
+ });
168
+ // Collapse runs of 3+ blank lines down to a single blank line
169
+ return cleaned.join('\n').replace(/\n{3,}/g, '\n\n');
170
+ }
171
+
72
172
  function getCodexLastMessagePath(buffer) {
73
173
  if (typeof buffer !== 'string' || !buffer) return null;
74
174
  const matches = [...buffer.matchAll(/=== CODEX_LAST_MESSAGE_FILE:(.+?) ===/g)];
@@ -91,9 +191,93 @@ function readCapturedCodexMessage(buffer, { remove = true } = {}) {
91
191
  }
92
192
  }
93
193
 
94
- function hasCodexStructuredOutput(buffer, endMarker) {
95
- const captured = readCapturedCodexMessage(buffer, { remove: false });
96
- return Boolean(captured && captured.includes(endMarker));
194
+ function extractStructuredStageText(agent, {
195
+ startMarker,
196
+ endMarker,
197
+ kind,
198
+ removeCaptured = false,
199
+ readCapturedCodexMessage: readCaptured = readCapturedCodexMessage,
200
+ } = {}) {
201
+ if (!agent) return null;
202
+ const bufStr = agent.getBufferString(100);
203
+ if (agent.cli === 'codex') {
204
+ const captured = readCaptured(bufStr, { remove: removeCaptured });
205
+ if (captured) {
206
+ return getLastStructuredBlock(captured, startMarker, endMarker);
207
+ }
208
+ }
209
+
210
+ const structured = agent.getStructuredBlock?.(kind) || null;
211
+ if (structured) return structured;
212
+
213
+ // Fallback: scan terminal buffer directly (handles edge cases
214
+ // where structured capture missed the block)
215
+ const cleanBuf = stripAnsi(agent.getBufferString(100));
216
+ return getLastStructuredBlock(cleanBuf, startMarker, endMarker);
217
+ }
218
+
219
+ export function extractPlannerPlanText(agent, options = {}) {
220
+ const startMarker = '=== PLAN START ===';
221
+ const endMarker = '=== PLAN END ===';
222
+ const result = extractStructuredStageText(agent, {
223
+ startMarker,
224
+ endMarker,
225
+ kind: 'plan',
226
+ ...options,
227
+ });
228
+
229
+ // If the extracted block is a placeholder (echoed prompt template),
230
+ // search all captured blocks first, then fall back to the full buffer.
231
+ // The CLI can re-render the prompt template after the real plan, causing
232
+ // getLastStructuredBlock to return the template instead of the real plan.
233
+ if (result && isPlanPlaceholder(result)) {
234
+ // Check all blocks captured by the agent's streaming parser
235
+ const capturedBlocks = agent.getAllCapturedBlocks?.('plan') || [];
236
+ for (let i = capturedBlocks.length - 1; i >= 0; i--) {
237
+ if (!isPlanPlaceholder(capturedBlocks[i])) return capturedBlocks[i];
238
+ }
239
+
240
+ // Final fallback: scan the full terminal buffer
241
+ const cleanBuf = stripAnsi(agent.getBufferString(500));
242
+ const blocks = getAllStructuredBlocks(cleanBuf, startMarker, endMarker);
243
+ for (let i = blocks.length - 1; i >= 0; i--) {
244
+ if (!isPlanPlaceholder(blocks[i])) return blocks[i];
245
+ }
246
+ }
247
+
248
+ return result;
249
+ }
250
+
251
+ export function extractReviewerReviewText(agent, options = {}) {
252
+ const startMarker = '=== REVIEW START ===';
253
+ const endMarker = '=== REVIEW END ===';
254
+ const result = extractStructuredStageText(agent, {
255
+ startMarker,
256
+ endMarker,
257
+ kind: 'review',
258
+ ...options,
259
+ });
260
+
261
+ // Same fallback as extractPlannerPlanText: if the captured block is a
262
+ // placeholder (echoed prompt template), search all captured blocks and
263
+ // the full buffer for the real review output.
264
+ const reviewResult = result ? parseReviewResult(result) : null;
265
+ if (result && isReviewResultPlaceholder(result, reviewResult)) {
266
+ const capturedBlocks = agent.getAllCapturedBlocks?.('review') || [];
267
+ for (let i = capturedBlocks.length - 1; i >= 0; i--) {
268
+ const parsed = parseReviewResult(capturedBlocks[i]);
269
+ if (!isReviewResultPlaceholder(capturedBlocks[i], parsed)) return capturedBlocks[i];
270
+ }
271
+
272
+ const cleanBuf = stripAnsi(agent.getBufferString(500));
273
+ const blocks = getAllStructuredBlocks(cleanBuf, startMarker, endMarker);
274
+ for (let i = blocks.length - 1; i >= 0; i--) {
275
+ const parsed = parseReviewResult(blocks[i]);
276
+ if (!isReviewResultPlaceholder(blocks[i], parsed)) return blocks[i];
277
+ }
278
+ }
279
+
280
+ return result;
97
281
  }
98
282
 
99
283
  function getImplementationCompletionState(agent, taskId) {
@@ -192,6 +376,18 @@ function generateBranchName(task) {
192
376
  return `feature/${task.id.toLowerCase()}-${slugifyTitle(task.title)}`;
193
377
  }
194
378
 
379
+ // Strip garbage from branch names caused by ANSI cursor positioning collapse
380
+ // (e.g. "feature/t-a811ca-reporting FILES_TO_MODIFY:" → "feature/t-a811ca-reporting").
381
+ // Stops at the first character that's invalid in a git branch name.
382
+ export function sanitizeBranchName(raw) {
383
+ if (typeof raw !== 'string') return raw;
384
+ // Trim leading/trailing whitespace, then keep only valid branch chars.
385
+ // Git branch names allow: alphanumeric, -, _, /, .
386
+ // Stop at first space or other invalid char (catches appended field headers).
387
+ const match = raw.trim().match(/^[a-zA-Z0-9/_.-]+/);
388
+ return match ? match[0].replace(/\.+$/, '') : raw.trim();
389
+ }
390
+
195
391
  function buildSyntheticPlan(task) {
196
392
  return `=== PLAN START ===
197
393
  SUMMARY: Planning skipped because planner max is set to 0. Implement the requested task directly.
@@ -340,7 +536,8 @@ ${task.plan}
340
536
  Instructions:
341
537
  - You are already on branch ${task.branch} in ${repoDir}
342
538
  ${promptBody}
343
- - When fully complete, output this exact string on its own line:
539
+ - Before signaling completion, ensure ALL changes are committed to git on branch ${task.branch}
540
+ - When fully complete and all changes are committed, output this exact string on its own line:
344
541
  === IMPLEMENTATION COMPLETE ${task.id} ===
345
542
  - If you encounter a blocker you cannot resolve, output:
346
543
  === BLOCKED: {reason} ===
@@ -536,7 +733,7 @@ async function startPlanning(task) {
536
733
  planner.taskLabel = `Planning: ${task.title}`;
537
734
 
538
735
  const prompt = buildPlannerPrompt({ ...task, workspacePath });
539
- const cmd = buildAgentCommand(planner.cli, prompt, 'plan');
736
+ const cmd = buildAgentCommand(planner.cli, prompt, 'plan', planner.model);
540
737
  const plannerCwd = workspacePath;
541
738
  const ok = planner.spawn(plannerCwd, cmd);
542
739
  if (!ok) {
@@ -555,18 +752,18 @@ async function startPlanning(task) {
555
752
  function onPlanComplete(agentId, taskId) {
556
753
  const planner = agentManager.get(agentId);
557
754
  if (!planner) return;
558
- const bufStr = planner.getBufferString(100);
559
- const captured = planner.cli === 'codex' ? readCapturedCodexMessage(bufStr) : null;
560
- const sourceText = captured || stripAnsi(bufStr);
755
+ const rawPlanText = extractPlannerPlanText(planner, { removeCaptured: true });
756
+
757
+ if (!rawPlanText) return;
758
+ if (isPlanPlaceholder(rawPlanText)) return;
561
759
 
562
- // Extract plan text
563
- const planText = getLastStructuredBlock(sourceText, '=== PLAN START ===', '=== PLAN END ===');
564
- if (!planText) return;
565
- if (isPlanPlaceholder(planText)) return;
760
+ const planText = cleanTerminalArtifacts(rawPlanText);
566
761
 
567
762
  // Parse branch name
568
763
  const branchMatch = planText.match(/BRANCH:\s*(.+)/);
569
- const branch = branchMatch ? branchMatch[1].trim() : generateBranchName(store.getTask(taskId) || { id: taskId, title: 'auto' });
764
+ const branch = branchMatch
765
+ ? sanitizeBranchName(branchMatch[1])
766
+ : generateBranchName(store.getTask(taskId) || { id: taskId, title: 'auto' });
570
767
 
571
768
  // Save plan
572
769
  store.savePlan(taskId, planText);
@@ -581,7 +778,7 @@ function onPlanComplete(agentId, taskId) {
581
778
  assignedTo: null,
582
779
  });
583
780
 
584
- retireAgentSession(planner, { taskId, outcome: 'completed', transcript: sourceText });
781
+ retireAgentSession(planner, { taskId, outcome: 'completed', transcript: planText });
585
782
  bus.emit('plan:ready', { taskId, plan: planText });
586
783
  }
587
784
 
@@ -639,7 +836,7 @@ async function startImplementation(task) {
639
836
 
640
837
  const cliTool = agent.cli;
641
838
  const prompt = buildImplementorPrompt(task, workspacePath);
642
- const cmd = buildAgentCommand(cliTool, prompt, 'interactive');
839
+ const cmd = buildAgentCommand(cliTool, prompt, 'interactive', agent.model);
643
840
 
644
841
  const ok = agent.spawn(workspacePath, cmd);
645
842
  if (!ok) {
@@ -668,7 +865,7 @@ async function onImplementationComplete(agentId) {
668
865
  const git = simpleGit(task.workspacePath);
669
866
  await git.push('origin', task.branch);
670
867
  } catch (err) {
671
- console.error(`Git push failed:`, err.message);
868
+ console.error('Git push failed:', err.message);
672
869
  store.updateTask(taskId, {
673
870
  status: 'blocked',
674
871
  blockedReason: `Branch push failed: ${err.message}`,
@@ -715,7 +912,7 @@ SUMMARY: Review skipped because reviewer max is set to 0.
715
912
  reviewer.status = 'active';
716
913
 
717
914
  const prompt = buildReviewerPrompt(task);
718
- const cmd = buildAgentCommand(reviewer.cli, prompt, 'review');
915
+ const cmd = buildAgentCommand(reviewer.cli, prompt, 'review', reviewer.model);
719
916
  const ok = reviewer.spawn(task.workspacePath, cmd);
720
917
  if (!ok) {
721
918
  store.updateTask(task.id, {
@@ -732,12 +929,9 @@ SUMMARY: Review skipped because reviewer max is set to 0.
732
929
  async function onReviewComplete(agentId, taskId) {
733
930
  const reviewer = agentManager.get(agentId);
734
931
  if (!reviewer) return;
735
- const bufStr = reviewer.getBufferString(100);
736
-
737
- const captured = reviewer.cli === 'codex' ? readCapturedCodexMessage(bufStr) : null;
738
- const sourceText = captured || stripAnsi(bufStr);
739
- const reviewText = getLastStructuredBlock(sourceText, '=== REVIEW START ===', '=== REVIEW END ===');
740
- if (!reviewText) return;
932
+ const rawReviewText = extractReviewerReviewText(reviewer, { removeCaptured: true });
933
+ if (!rawReviewText) return;
934
+ const reviewText = cleanTerminalArtifacts(rawReviewText);
741
935
  const reviewResult = parseReviewResult(reviewText);
742
936
  if (isReviewResultPlaceholder(reviewText, reviewResult)) return;
743
937
  const shouldPass = reviewShouldPass(reviewResult);
@@ -746,7 +940,7 @@ async function onReviewComplete(agentId, taskId) {
746
940
  retireAgentSession(reviewer, {
747
941
  taskId,
748
942
  outcome: shouldPass ? 'completed' : 'failed_review',
749
- transcript: sourceText,
943
+ transcript: reviewText,
750
944
  });
751
945
 
752
946
  if (shouldPass) {
@@ -821,7 +1015,7 @@ async function createPR(taskId) {
821
1015
  await cleanupWorkspace(store.getTask(taskId));
822
1016
  store.updateTask(taskId, { status: 'done', assignedTo: null });
823
1017
  } catch (err) {
824
- console.error(`PR creation error:`, err.message);
1018
+ console.error('PR creation error:', err.message);
825
1019
  store.updateTask(taskId, {
826
1020
  status: 'blocked',
827
1021
  blockedReason: summarizeProcessError('PR finalization failed', err),
@@ -928,19 +1122,23 @@ function checkSignals() {
928
1122
  // Check planners
929
1123
  for (const agent of agentManager.getAgentsByRole('plan')) {
930
1124
  if (agent.status === 'active' && agent.currentTask) {
1125
+ // Skip checks during initial startup to avoid matching the completion
1126
+ // marker in the echoed prompt text (interactive mode echoes the prompt)
1127
+ const elapsed = agent.startedAt ? Date.now() - agent.startedAt : 0;
1128
+ if (elapsed < 20000) continue;
1129
+
931
1130
  const buf = agent.getBufferString(50);
932
1131
  const cleanBuf = stripAnsi(buf);
933
- const planReady = agent.cli === 'codex'
934
- ? hasCodexStructuredOutput(buf, '=== PLAN END ===')
935
- : cleanBuf.includes('=== PLAN END ===');
936
- if (planReady) {
1132
+ const planText = extractPlannerPlanText(agent);
1133
+ const hasConcretePlan = Boolean(planText && !isPlanPlaceholder(planText));
1134
+ if (hasConcretePlan) {
937
1135
  onPlanComplete(agent.id, agent.currentTask);
938
1136
  } else if (!checkTrustPrompt(agent, buf)) {
939
1137
  // Live plan streaming
940
1138
  if (!cleanBuf.includes('=== PLAN END ===') && cleanBuf.includes('=== PLAN START ===')) {
941
1139
  const partial = cleanBuf.slice(cleanBuf.indexOf('=== PLAN START ==='));
942
1140
  if (!isPlanPlaceholder(partial)) {
943
- bus.emit('plan:partial', { taskId: agent.currentTask, plan: partial });
1141
+ bus.emit('plan:partial', { taskId: agent.currentTask, plan: cleanTerminalArtifacts(partial) });
944
1142
  }
945
1143
  }
946
1144
  if (agent.startedAt && Date.now() - agent.startedAt > PLANNER_TIMEOUT) {
@@ -953,6 +1151,11 @@ function checkSignals() {
953
1151
  // Check implementors
954
1152
  for (const agent of agentManager.getAgentsByRole('imp')) {
955
1153
  if (agent.status === 'active' && agent.currentTask) {
1154
+ // Skip checks during initial startup to avoid matching the completion
1155
+ // marker in the echoed prompt text (interactive mode echoes the prompt)
1156
+ const elapsed = agent.startedAt ? Date.now() - agent.startedAt : 0;
1157
+ if (elapsed < 20000) continue;
1158
+
956
1159
  const buf = agent.getBufferString(50);
957
1160
  const implementationState = getImplementationCompletionState(agent, agent.currentTask);
958
1161
  if (implementationState.complete) {
@@ -982,11 +1185,18 @@ function checkSignals() {
982
1185
  // Check reviewers
983
1186
  for (const agent of agentManager.getAgentsByRole('rev')) {
984
1187
  if (agent.status === 'active' && agent.currentTask) {
1188
+ // Skip checks during initial startup to avoid matching the completion
1189
+ // marker in the echoed prompt text (interactive mode echoes the prompt)
1190
+ const elapsed = agent.startedAt ? Date.now() - agent.startedAt : 0;
1191
+ if (elapsed < 20000) continue;
1192
+
985
1193
  const buf = agent.getBufferString(50);
986
- const reviewReady = agent.cli === 'codex'
987
- ? hasCodexStructuredOutput(buf, '=== REVIEW END ===')
988
- : stripAnsi(buf).includes('=== REVIEW END ===');
989
- if (reviewReady) {
1194
+ const reviewText = extractReviewerReviewText(agent);
1195
+ const reviewResult = reviewText ? parseReviewResult(reviewText) : null;
1196
+ const hasConcreteReview = Boolean(
1197
+ reviewText && reviewResult && !isReviewResultPlaceholder(reviewText, reviewResult)
1198
+ );
1199
+ if (hasConcreteReview) {
990
1200
  onReviewComplete(agent.id, agent.currentTask);
991
1201
  } else if (!checkTrustPrompt(agent, buf)) {
992
1202
  if (agent.startedAt && Date.now() - agent.startedAt > REVIEWER_TIMEOUT) {
@@ -1068,25 +1278,18 @@ function pollLoop() {
1068
1278
  const taskId = agent.currentTask;
1069
1279
  const task = store.getTask(taskId);
1070
1280
  if (task && !['blocked', 'done', 'aborted', 'backlog', 'paused', 'workspace_setup'].includes(task.status)) {
1071
- const buf = agent.getBufferString(100);
1072
1281
  const isPlanner = agent.id.startsWith('plan-');
1073
1282
  const isImplementor = agent.id.startsWith('imp-');
1074
1283
  const isReviewer = agent.id.startsWith('rev-');
1075
1284
 
1076
- const cleanBuf = stripAnsi(buf);
1077
1285
  if (isPlanner) {
1078
- const planReady = agent.cli === 'codex'
1079
- ? hasCodexStructuredOutput(buf, '=== PLAN END ===')
1080
- : cleanBuf.includes('=== PLAN END ===');
1081
- const planText = planReady
1082
- ? getLastStructuredBlock(cleanBuf, '=== PLAN START ===', '=== PLAN END ===')
1083
- : null;
1286
+ const planText = extractPlannerPlanText(agent);
1084
1287
  if (planText && !isPlanPlaceholder(planText)) {
1085
1288
  onPlanComplete(agent.id, taskId);
1086
1289
  } else {
1087
1290
  store.updateTask(taskId, {
1088
1291
  status: 'blocked',
1089
- blockedReason: planReady
1292
+ blockedReason: planText
1090
1293
  ? 'Planner exited after returning placeholder output'
1091
1294
  : 'Agent process exited unexpectedly',
1092
1295
  assignedTo: null,
@@ -1110,29 +1313,16 @@ function pollLoop() {
1110
1313
  });
1111
1314
  }
1112
1315
  } else if (isReviewer) {
1113
- const reviewReady = agent.cli === 'codex'
1114
- ? hasCodexStructuredOutput(buf, '=== REVIEW END ===')
1115
- : cleanBuf.includes('=== REVIEW END ===');
1116
- if (reviewReady) {
1117
- const reviewer = agentManager.get(agent.id);
1118
- const bufStr = reviewer?.getBufferString(100) || '';
1119
- const captured = reviewer?.cli === 'codex' ? readCapturedCodexMessage(bufStr, { remove: false }) : null;
1120
- const sourceText = captured || bufStr;
1121
- const reviewText = getLastStructuredBlock(sourceText, '=== REVIEW START ===', '=== REVIEW END ===');
1122
- const reviewResult = reviewText ? parseReviewResult(reviewText) : null;
1123
- if (reviewText && reviewResult && !isReviewResultPlaceholder(reviewText, reviewResult)) {
1124
- onReviewComplete(agent.id, taskId);
1125
- } else {
1126
- store.updateTask(taskId, {
1127
- status: 'blocked',
1128
- blockedReason: 'Reviewer exited after returning placeholder output',
1129
- assignedTo: null,
1130
- });
1131
- }
1316
+ const reviewText = extractReviewerReviewText(agent);
1317
+ const reviewResult = reviewText ? parseReviewResult(reviewText) : null;
1318
+ if (reviewText && reviewResult && !isReviewResultPlaceholder(reviewText, reviewResult)) {
1319
+ onReviewComplete(agent.id, taskId);
1132
1320
  } else {
1133
1321
  store.updateTask(taskId, {
1134
1322
  status: 'blocked',
1135
- blockedReason: 'Agent process exited unexpectedly',
1323
+ blockedReason: reviewText
1324
+ ? 'Reviewer exited after returning placeholder output'
1325
+ : 'Agent process exited unexpectedly',
1136
1326
  assignedTo: null,
1137
1327
  });
1138
1328
  }
@@ -1176,17 +1366,12 @@ bus.on('agent:unexpected-exit', ({ agentId, taskId }) => {
1176
1366
  const isReviewer = agentId.startsWith('rev-');
1177
1367
 
1178
1368
  if (isPlanner) {
1179
- const planReady = agent.cli === 'codex'
1180
- ? hasCodexStructuredOutput(buf, '=== PLAN END ===')
1181
- : cleanBuf.includes('=== PLAN END ===');
1182
- if (planReady) {
1183
- const captured = agent.cli === 'codex' ? readCapturedCodexMessage(buf, { remove: false }) : null;
1184
- const sourceText = captured || cleanBuf;
1185
- const planText = getLastStructuredBlock(sourceText, '=== PLAN START ===', '=== PLAN END ===');
1186
- if (planText && !isPlanPlaceholder(planText)) {
1187
- onPlanComplete(agentId, taskId);
1188
- return;
1189
- }
1369
+ const planText = extractPlannerPlanText(agent);
1370
+ if (planText && !isPlanPlaceholder(planText)) {
1371
+ onPlanComplete(agentId, taskId);
1372
+ return;
1373
+ }
1374
+ if (planText) {
1190
1375
  authBlockedReason = 'Planner exited after returning placeholder output';
1191
1376
  }
1192
1377
  } else if (isImplementor) {
@@ -1199,18 +1384,13 @@ bus.on('agent:unexpected-exit', ({ agentId, taskId }) => {
1199
1384
  authBlockedReason = implementationState.blockedReason;
1200
1385
  }
1201
1386
  } else if (isReviewer) {
1202
- const reviewReady = agent.cli === 'codex'
1203
- ? hasCodexStructuredOutput(buf, '=== REVIEW END ===')
1204
- : cleanBuf.includes('=== REVIEW END ===');
1205
- if (reviewReady) {
1206
- const captured = agent.cli === 'codex' ? readCapturedCodexMessage(buf, { remove: false }) : null;
1207
- const sourceText = captured || cleanBuf;
1208
- const reviewText = getLastStructuredBlock(sourceText, '=== REVIEW START ===', '=== REVIEW END ===');
1209
- const reviewResult = reviewText ? parseReviewResult(reviewText) : null;
1210
- if (reviewText && reviewResult && !isReviewResultPlaceholder(reviewText, reviewResult)) {
1211
- onReviewComplete(agentId, taskId);
1212
- return;
1213
- }
1387
+ const reviewText = extractReviewerReviewText(agent);
1388
+ const reviewResult = reviewText ? parseReviewResult(reviewText) : null;
1389
+ if (reviewText && reviewResult && !isReviewResultPlaceholder(reviewText, reviewResult)) {
1390
+ onReviewComplete(agentId, taskId);
1391
+ return;
1392
+ }
1393
+ if (reviewText) {
1214
1394
  authBlockedReason = 'Reviewer exited after returning placeholder output';
1215
1395
  }
1216
1396
  }