a2acalling 0.6.42 → 0.6.44

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,485 @@
1
+ /**
2
+ * Claude Subagent — Lifecycle management for Claude CLI subagent sessions.
3
+ *
4
+ * Spawns `claude` CLI processes for real LLM-powered A2A conversations
5
+ * as an alternative to OpenClaw for A2A conversations.
6
+ *
7
+ * Uses `claude -p` (print mode) with `--resume` for multi-turn context continuity.
8
+ */
9
+
10
+ const { execSync, spawn } = require('child_process');
11
+ const { createLogger } = require('./logger');
12
+
13
+ const logger = createLogger({ component: 'a2a.claude-subagent' });
14
+
15
+ const A2A_RESPONSE_REGEX = /<a2a_response>\s*([\s\S]*?)\s*<\/a2a_response>/i;
16
+
17
+ /**
18
+ * Check if `claude` CLI is available in PATH.
19
+ */
20
+ function isClaudeAvailable() {
21
+ try {
22
+ execSync('command -v claude', { stdio: 'ignore' });
23
+ return true;
24
+ } catch {
25
+ return false;
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Build the system prompt for the Claude subagent.
31
+ *
32
+ * @param {Object} config
33
+ * @param {string} config.agentName
34
+ * @param {string} config.ownerName
35
+ * @param {string} config.otherAgentName
36
+ * @param {string} config.otherOwnerName
37
+ * @param {string} config.accessTier
38
+ * @param {string} config.tierTopics - formatted topics string
39
+ * @param {string} config.tierObjectives - formatted objectives string
40
+ * @param {string} config.doNotDiscuss - formatted do_not_discuss string
41
+ * @param {string} config.neverDisclose - formatted never_disclose string
42
+ * @param {string} config.personalityNotes
43
+ * @param {string} config.roleContext
44
+ * @returns {string}
45
+ */
46
+ function buildSubagentSystemPrompt(config) {
47
+ const {
48
+ agentName = 'Agent',
49
+ ownerName = 'the owner',
50
+ otherAgentName = 'Remote Agent',
51
+ otherOwnerName = 'their owner',
52
+ accessTier = 'public',
53
+ tierTopics = ' (none specified)',
54
+ tierObjectives = ' (none specified)',
55
+ doNotDiscuss = ' (none specified)',
56
+ neverDisclose = ' (none specified)',
57
+ personalityNotes = '',
58
+ roleContext = ''
59
+ } = config;
60
+
61
+ return `You are ${agentName}, the personal AI agent for ${ownerName}.
62
+ You are on a live A2A (agent-to-agent) call with ${otherAgentName}, who represents ${otherOwnerName}. ${roleContext}
63
+
64
+ == OUTPUT FORMAT ==
65
+
66
+ After your conversational reply, you MUST append exactly one structured response block:
67
+
68
+ <a2a_response>
69
+ {"message":"Your conversational reply here","statePatch":{"phase":"explore","overlapScore":0.3,"activeThreads":["thread1"],"candidateCollaborations":["idea1"],"closeSignal":false,"confidence":0.4},"flags":[]}
70
+ </a2a_response>
71
+
72
+ Rules for the response block:
73
+ - "message" (required): Your full conversational reply text. This is what the other agent sees.
74
+ - "statePatch" (optional): Collaboration state update with any of: phase, overlapScore (0-1), activeThreads (max 4), candidateCollaborations (max 4), closeSignal (boolean), confidence (0-1).
75
+ - "flags" (optional): Array of flag objects like {"type":"question_for_owner","content":"..."} or {"type":"opportunity_flagged","content":"..."}.
76
+ - Must be valid JSON (double quotes only).
77
+ - The message in the JSON block should match your visible conversational text.
78
+
79
+ Flag types:
80
+ - "question_for_owner": Something you want to ask ${ownerName} about before committing
81
+ - "opportunity_flagged": A concrete collaboration opportunity worth the owner's attention
82
+ - "boundary_touched": The other agent probed near a do_not_discuss or never_disclose topic
83
+ - "unverifiable_claim": The other agent made a claim you cannot verify
84
+
85
+ == DISCLOSURE CONTEXT ==
86
+
87
+ Access level: ${accessTier}
88
+
89
+ ${ownerName}'s topics of interest:
90
+ ${tierTopics}
91
+
92
+ Objectives:
93
+ ${tierObjectives}
94
+
95
+ DO NOT DISCUSS (redirect naturally):
96
+ ${doNotDiscuss}
97
+
98
+ NEVER disclose:
99
+ ${neverDisclose}
100
+
101
+ == BEHAVIORAL MANDATE ==
102
+
103
+ You operate in three concurrent modes:
104
+
105
+ 1. EXPLORING: Map the other agent's owner — capabilities, resources, blind spots, ambitions.
106
+ Ask probing questions. Don't accept surface-level answers. Dig into specifics.
107
+
108
+ 2. ADVERSARIALLY QUALIFYING: Pressure-test claims. Push back respectfully.
109
+ "You say X, but that sounds like Y. What's actually different?"
110
+ "That's a crowded space. What makes their angle defensible?"
111
+ The best collaborations come from people who can handle scrutiny.
112
+
113
+ 3. COLLABORATING: Look for concrete overlap and actionable next steps.
114
+ Complementary capabilities, shared challenges, non-obvious intersections.
115
+ Propose specific ideas, not vague "let's stay in touch."
116
+
117
+ == PHASE AWARENESS ==
118
+
119
+ Each turn you receive state including turn number, maxTurns, and current phase.
120
+ Adapt your behavior to the phase:
121
+
122
+ - handshake (turns 1-2): Establish context, introduce key topics, set one meaningful direction.
123
+ - exploring (turns 2-6): Map goals, capabilities, constraints. Stay here while new info surfaces.
124
+ - deepening (turns 5-10): Work through specific collaboration threads in detail.
125
+ - converging (turns 8+): Convert insights into concrete next steps. Set closeSignal when done.
126
+
127
+ These are guidelines, not hard locks. Stay in any phase as long as it's productive.
128
+
129
+ == PERSONALITY ==
130
+
131
+ ${personalityNotes || "Direct, curious, slightly irreverent. You have opinions and share them. You're not a concierge — you're a sparring partner who represents someone."}
132
+
133
+ When unsure about your owner's position, say so: "I don't have ${ownerName}'s take on that — but here's what I think based on their work..."`;
134
+ }
135
+
136
+ /**
137
+ * Build the turn prompt containing state and the inbound message.
138
+ */
139
+ function buildTurnPrompt(options) {
140
+ const {
141
+ turnMessage,
142
+ turn,
143
+ maxTurns,
144
+ phase = 'handshake',
145
+ overlapScore = 0.15,
146
+ activeThreads = [],
147
+ candidateCollaborations = [],
148
+ closeSignal = false
149
+ } = options;
150
+
151
+ return `== TURN STATE ==
152
+ Turn: ${turn}/${maxTurns}
153
+ Phase: ${phase}
154
+ Overlap score: ${overlapScore}
155
+ Active threads: ${activeThreads.length > 0 ? activeThreads.join(', ') : '(none)'}
156
+ Candidate collaborations: ${candidateCollaborations.length > 0 ? candidateCollaborations.join(', ') : '(none)'}
157
+ Close signal: ${closeSignal}
158
+
159
+ == INBOUND MESSAGE ==
160
+
161
+ ${turnMessage}
162
+
163
+ Respond naturally, then append your <a2a_response> block.`;
164
+ }
165
+
166
+ /**
167
+ * Parse the structured response from Claude's output.
168
+ *
169
+ * @param {string} resultText - Raw text output from claude CLI
170
+ * @returns {{ message: string, statePatch: object|null, flags: array }}
171
+ */
172
+ function parseSubagentResponse(resultText) {
173
+ if (!resultText || typeof resultText !== 'string') {
174
+ return { message: '', statePatch: null, flags: [] };
175
+ }
176
+
177
+ const match = resultText.match(A2A_RESPONSE_REGEX);
178
+ if (!match) {
179
+ // Graceful degradation: treat entire result as the message
180
+ return { message: resultText.trim(), statePatch: null, flags: [] };
181
+ }
182
+
183
+ const jsonStr = (match[1] || '').trim();
184
+ if (!jsonStr) {
185
+ const cleanText = resultText.replace(A2A_RESPONSE_REGEX, '').trim();
186
+ return { message: cleanText || '', statePatch: null, flags: [] };
187
+ }
188
+
189
+ try {
190
+ const parsed = JSON.parse(jsonStr);
191
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
192
+ throw new Error('a2a_response must be a JSON object');
193
+ }
194
+
195
+ return {
196
+ message: typeof parsed.message === 'string' ? parsed.message : resultText.replace(A2A_RESPONSE_REGEX, '').trim(),
197
+ statePatch: parsed.statePatch && typeof parsed.statePatch === 'object' ? parsed.statePatch : null,
198
+ flags: Array.isArray(parsed.flags) ? parsed.flags : []
199
+ };
200
+ } catch (err) {
201
+ logger.warn('Failed to parse <a2a_response> JSON', {
202
+ event: 'subagent_response_parse_failed',
203
+ error: err,
204
+ data: { json_length: jsonStr.length }
205
+ });
206
+ // Fall back to using text outside the tags
207
+ const cleanText = resultText.replace(A2A_RESPONSE_REGEX, '').trim();
208
+ return { message: cleanText || resultText.trim(), statePatch: null, flags: [] };
209
+ }
210
+ }
211
+
212
+ /**
213
+ * Spawn `claude` CLI and collect output as a promise.
214
+ *
215
+ * @param {string[]} args - CLI arguments
216
+ * @param {number} timeoutMs - Timeout in milliseconds
217
+ * @returns {Promise<{ stdout: string, stderr: string }>}
218
+ */
219
+ function spawnClaude(args, timeoutMs = 180000) {
220
+ return new Promise((resolve, reject) => {
221
+ const proc = spawn('claude', args, {
222
+ stdio: ['pipe', 'pipe', 'pipe'],
223
+ env: {
224
+ ...process.env,
225
+ FORCE_COLOR: '0',
226
+ CLAUDECODE: '' // Unset to allow nested invocation
227
+ }
228
+ });
229
+
230
+ let stdout = '';
231
+ let stderr = '';
232
+ let killed = false;
233
+
234
+ const timer = setTimeout(() => {
235
+ killed = true;
236
+ proc.kill('SIGTERM');
237
+ // Give it 5s to clean up, then force kill
238
+ setTimeout(() => {
239
+ try { proc.kill('SIGKILL'); } catch (e) { /* already dead */ }
240
+ }, 5000);
241
+ }, timeoutMs);
242
+
243
+ proc.stdout.on('data', (data) => { stdout += data.toString(); });
244
+ proc.stderr.on('data', (data) => { stderr += data.toString(); });
245
+
246
+ proc.on('close', (code) => {
247
+ clearTimeout(timer);
248
+ if (killed) {
249
+ reject(new Error(`Claude CLI timed out after ${timeoutMs}ms`));
250
+ return;
251
+ }
252
+ if (code !== 0 && !stdout.trim()) {
253
+ reject(new Error(`Claude CLI exited with code ${code}: ${stderr.slice(0, 500)}`));
254
+ return;
255
+ }
256
+ resolve({ stdout, stderr });
257
+ });
258
+
259
+ proc.on('error', (err) => {
260
+ clearTimeout(timer);
261
+ reject(err);
262
+ });
263
+ });
264
+ }
265
+
266
+ /**
267
+ * Extract the result text from Claude's JSON output.
268
+ * Claude with --output-format json returns { type, subtype, cost_usd, duration_ms, duration_api_ms,
269
+ * is_error, num_turns, result, session_id, ... }
270
+ */
271
+ function extractResultFromJson(stdout) {
272
+ const trimmed = stdout.trim();
273
+ if (!trimmed) return { result: '', sessionId: null };
274
+
275
+ try {
276
+ const parsed = JSON.parse(trimmed);
277
+ return {
278
+ result: typeof parsed.result === 'string' ? parsed.result : '',
279
+ sessionId: parsed.session_id || null
280
+ };
281
+ } catch (err) {
282
+ // If JSON parsing fails, treat entire output as result text
283
+ logger.debug('Claude output not valid JSON, using raw text', {
284
+ event: 'subagent_json_parse_fallback',
285
+ data: { output_length: trimmed.length }
286
+ });
287
+ return { result: trimmed, sessionId: null };
288
+ }
289
+ }
290
+
291
+ /**
292
+ * Run a single turn of the Claude subagent.
293
+ *
294
+ * @param {Object} options
295
+ * @param {string} options.sessionId - Conversation session ID (used for --resume on turn 2+)
296
+ * @param {string} options.systemPrompt - System prompt (used on turn 1 only)
297
+ * @param {string} options.turnMessage - The inbound message from the remote agent
298
+ * @param {number} options.turn - Current turn number (1-based)
299
+ * @param {number} options.maxTurns - Maximum turns allowed
300
+ * @param {string} options.phase - Current conversation phase
301
+ * @param {number} options.overlapScore - Current overlap score
302
+ * @param {Array} options.activeThreads - Active conversation threads
303
+ * @param {Array} options.candidateCollaborations - Candidate collaboration ideas
304
+ * @param {boolean} options.closeSignal - Whether close has been signaled
305
+ * @param {number} [options.timeoutMs=180000] - Timeout in milliseconds
306
+ * @returns {Promise<{ message: string, statePatch: object|null, flags: array, sessionId: string }>}
307
+ */
308
+ async function runClaudeTurn(options) {
309
+ const {
310
+ sessionId,
311
+ systemPrompt,
312
+ turnMessage,
313
+ turn = 1,
314
+ maxTurns = 30,
315
+ phase = 'handshake',
316
+ overlapScore = 0.15,
317
+ activeThreads = [],
318
+ candidateCollaborations = [],
319
+ closeSignal = false,
320
+ timeoutMs = 180000
321
+ } = options;
322
+
323
+ const turnPrompt = buildTurnPrompt({
324
+ turnMessage,
325
+ turn,
326
+ maxTurns,
327
+ phase,
328
+ overlapScore,
329
+ activeThreads,
330
+ candidateCollaborations,
331
+ closeSignal
332
+ });
333
+
334
+ const startAt = Date.now();
335
+ const allowedTools = 'Bash(readonly) Read Grep Glob WebSearch WebFetch';
336
+
337
+ let args;
338
+ if (turn === 1 || !sessionId) {
339
+ // First turn: create new session
340
+ args = [
341
+ '-p',
342
+ '--output-format', 'json',
343
+ '--system-prompt', systemPrompt,
344
+ '--allowedTools', allowedTools,
345
+ '--model', 'claude-sonnet-4-5-20250929',
346
+ turnPrompt
347
+ ];
348
+ } else {
349
+ // Subsequent turns: resume existing session
350
+ args = [
351
+ '-p',
352
+ '--output-format', 'json',
353
+ '--resume', sessionId,
354
+ '--allowedTools', allowedTools,
355
+ turnPrompt
356
+ ];
357
+ }
358
+
359
+ logger.debug('Spawning Claude subagent turn', {
360
+ event: 'subagent_turn_start',
361
+ data: {
362
+ turn,
363
+ max_turns: maxTurns,
364
+ phase,
365
+ is_resume: turn > 1 && Boolean(sessionId),
366
+ timeout_ms: timeoutMs
367
+ }
368
+ });
369
+
370
+ const { stdout } = await spawnClaude(args, timeoutMs);
371
+ const { result, sessionId: newSessionId } = extractResultFromJson(stdout);
372
+ const parsed = parseSubagentResponse(result);
373
+
374
+ logger.debug('Claude subagent turn completed', {
375
+ event: 'subagent_turn_complete',
376
+ data: {
377
+ turn,
378
+ duration_ms: Date.now() - startAt,
379
+ message_length: parsed.message.length,
380
+ has_state_patch: Boolean(parsed.statePatch),
381
+ flag_count: parsed.flags.length,
382
+ session_id: newSessionId || sessionId
383
+ }
384
+ });
385
+
386
+ return {
387
+ message: parsed.message,
388
+ statePatch: parsed.statePatch,
389
+ flags: parsed.flags,
390
+ sessionId: newSessionId || sessionId
391
+ };
392
+ }
393
+
394
+ /**
395
+ * Run a summary turn using the Claude subagent session.
396
+ *
397
+ * @param {string} sessionId - Session ID to resume
398
+ * @param {string} reason - Why the conversation is ending
399
+ * @param {number} [timeoutMs=120000] - Timeout in milliseconds
400
+ * @returns {Promise<{ summary: string, ownerSummary: string, actionItems: array, flags: array }>}
401
+ */
402
+ async function runClaudeSummary(sessionId, reason, timeoutMs = 120000) {
403
+ if (!sessionId) {
404
+ throw new Error('Cannot summarize without a session ID');
405
+ }
406
+
407
+ const summaryPrompt = `The conversation is ending. Reason: ${reason || 'max turns reached'}.
408
+
409
+ Provide a structured summary. Respond with ONLY a JSON block:
410
+
411
+ <a2a_response>
412
+ {
413
+ "message": "Brief 1-2 sentence summary of the conversation.",
414
+ "statePatch": {"phase": "close", "closeSignal": true},
415
+ "flags": [],
416
+ "summary": "Detailed summary for the conversation record.",
417
+ "ownerSummary": "Summary written for the owner highlighting key findings and opportunities.",
418
+ "actionItems": ["Specific follow-up item 1", "Specific follow-up item 2"]
419
+ }
420
+ </a2a_response>`;
421
+
422
+ const args = [
423
+ '-p',
424
+ '--output-format', 'json',
425
+ '--resume', sessionId,
426
+ summaryPrompt
427
+ ];
428
+
429
+ const startAt = Date.now();
430
+
431
+ logger.debug('Spawning Claude summary', {
432
+ event: 'subagent_summary_start',
433
+ data: { session_id: sessionId, reason }
434
+ });
435
+
436
+ const { stdout } = await spawnClaude(args, timeoutMs);
437
+ const { result } = extractResultFromJson(stdout);
438
+
439
+ // Try to extract structured summary from <a2a_response>
440
+ const match = result.match(A2A_RESPONSE_REGEX);
441
+ if (match) {
442
+ try {
443
+ const parsed = JSON.parse(match[1].trim());
444
+ logger.debug('Claude summary completed', {
445
+ event: 'subagent_summary_complete',
446
+ data: {
447
+ session_id: sessionId,
448
+ duration_ms: Date.now() - startAt,
449
+ has_summary: Boolean(parsed.summary),
450
+ action_item_count: Array.isArray(parsed.actionItems) ? parsed.actionItems.length : 0
451
+ }
452
+ });
453
+
454
+ return {
455
+ summary: parsed.summary || parsed.message || result.replace(A2A_RESPONSE_REGEX, '').trim(),
456
+ ownerSummary: parsed.ownerSummary || parsed.summary || parsed.message || '',
457
+ actionItems: Array.isArray(parsed.actionItems) ? parsed.actionItems : [],
458
+ flags: Array.isArray(parsed.flags) ? parsed.flags : []
459
+ };
460
+ } catch (err) {
461
+ logger.warn('Failed to parse summary JSON', {
462
+ event: 'subagent_summary_parse_failed',
463
+ error: err
464
+ });
465
+ }
466
+ }
467
+
468
+ // Fallback: use raw text as summary
469
+ const summaryText = result.replace(A2A_RESPONSE_REGEX, '').trim() || result.trim();
470
+ return {
471
+ summary: summaryText,
472
+ ownerSummary: summaryText,
473
+ actionItems: [],
474
+ flags: []
475
+ };
476
+ }
477
+
478
+ module.exports = {
479
+ isClaudeAvailable,
480
+ buildSubagentSystemPrompt,
481
+ buildTurnPrompt,
482
+ runClaudeTurn,
483
+ runClaudeSummary,
484
+ parseSubagentResponse
485
+ };
@@ -33,6 +33,11 @@ const TERMINATION_PATTERNS = [
33
33
  /\[DISCONNECT/i,
34
34
  /\[END.?CALL\]/i,
35
35
  /\[CLOSING\]/i,
36
+ /\bEND.?CALL\b/i,
37
+ /\bcall\s+closed\b/i,
38
+ /\bwrapping\s+up\b/i,
39
+ /\[No\s+further\b/i,
40
+ /\bno\s+further\b/i,
36
41
  /\bREFUSING\s+(TO\s+)?(CONTINU|RESPOND|ENGAG)/i,
37
42
  /\bcall\s+complet(ed|e)\b/i,
38
43
  /\bconversation\s+(is\s+)?(over|ended|closed|complet)/i,
@@ -43,12 +48,15 @@ const TERMINATION_PATTERNS = [
43
48
 
44
49
  function detectRemoteTermination(text) {
45
50
  if (!text || typeof text !== 'string') return false;
51
+ // Very short responses (single dot, empty-ish) indicate dead conversation
52
+ const trimmed = text.trim();
53
+ if (trimmed.length <= 1) return true;
46
54
  return TERMINATION_PATTERNS.some(pattern => pattern.test(text));
47
55
  }
48
56
 
49
57
  /**
50
58
  * Infer collaboration state progression when the runtime doesn't emit
51
- * <collab_state> tags (generic/fallback mode). Advances phase based on
59
+ * <collab_state> tags (e.g. OpenClaw without adaptive mode). Advances phase based on
52
60
  * turn count and estimates overlap from remote text analysis.
53
61
  */
54
62
  function inferStateProgression(collabState, remoteText, turn) {
@@ -82,6 +90,12 @@ function inferStateProgression(collabState, remoteText, turn) {
82
90
  // Confidence increases over turns
83
91
  patch.confidence = Math.min(0.9, 0.25 + turn * 0.08);
84
92
 
93
+ // In converging phase, signal close — conversation has run its natural course
94
+ const effectivePhase = patch.phase || collabState.phase;
95
+ if (effectivePhase === 'converging') {
96
+ patch.closeSignal = true;
97
+ }
98
+
85
99
  return patch;
86
100
  }
87
101
 
@@ -114,8 +128,11 @@ class ConversationDriver {
114
128
  this.tier = options.tier || 'public';
115
129
  this.summarizer = options.summarizer || null;
116
130
  this.ownerContext = options.ownerContext || {};
131
+ this.claudeMode = options.runtime?.mode === 'claude';
132
+ this.claudeTimeoutMs = options.claudeTimeoutMs || 180000;
117
133
 
118
- this.client = new A2AClient({ caller: this.caller, timeout: 65000 });
134
+ const clientTimeout = this.claudeMode ? 200000 : 65000;
135
+ this.client = new A2AClient({ caller: this.caller, timeout: clientTimeout });
119
136
  }
120
137
 
121
138
  /**
@@ -207,6 +224,7 @@ Be concise but specific. No filler.`;
207
224
  conversationId = `conv_${Date.now()}_local`;
208
225
 
209
226
  let nextMessage = openingMessage;
227
+ const overlapHistory = [];
210
228
 
211
229
  for (let turn = 0; turn < this.maxTurns; turn++) {
212
230
  // 1. Send message to remote
@@ -336,18 +354,30 @@ Be concise but specific. No filler.`;
336
354
  // 5. Call runtime.runTurn() to generate next message
337
355
  const sessionId = `a2a-${conversationId}`;
338
356
  let rawResponse;
357
+ const contextPayload = {
358
+ conversationId,
359
+ tier: this.tier,
360
+ ownerName: this.agentContext.owner,
361
+ agentName: this.agentContext.name,
362
+ roleContext: 'You initiated this call.'
363
+ };
364
+ if (this.claudeMode) {
365
+ contextPayload.turnCount = turn + 1;
366
+ contextPayload.maxTurns = this.maxTurns;
367
+ contextPayload.phase = collabState.phase;
368
+ contextPayload.overlapScore = collabState.overlapScore;
369
+ contextPayload.activeThreads = collabState.activeThreads;
370
+ contextPayload.candidateCollaborations = collabState.candidateCollaborations;
371
+ contextPayload.closeSignal = collabState.closeSignal;
372
+ }
339
373
  try {
340
374
  rawResponse = await this.runtime.runTurn({
341
375
  sessionId,
342
376
  prompt,
343
377
  message: remoteText,
344
378
  caller: this.caller,
345
- timeoutMs: 65000,
346
- context: {
347
- conversationId,
348
- tier: this.tier,
349
- ownerName: this.agentContext.owner
350
- }
379
+ timeoutMs: this.claudeMode ? this.claudeTimeoutMs : 65000,
380
+ context: contextPayload
351
381
  });
352
382
  } catch (err) {
353
383
  logger.error('Runtime turn failed', {
@@ -359,32 +389,73 @@ Be concise but specific. No filler.`;
359
389
  }
360
390
 
361
391
  // 6. Extract collab state from response
362
- const parsed = extractCollaborationState(rawResponse);
363
- nextMessage = parsed.cleanText || rawResponse;
364
-
365
- if (parsed.hasState && parsed.statePatch) {
366
- if (parsed.statePatch.phase) collabState.phase = parsed.statePatch.phase;
367
- if (parsed.statePatch.overlapScore != null) {
368
- collabState.overlapScore = Math.max(0, Math.min(1, parsed.statePatch.overlapScore));
392
+ // In claude mode, use the side channel (getLastTurnMeta) for state/flags
393
+ const turnMeta = this.claudeMode ? this.runtime.getLastTurnMeta?.(sessionId) : null;
394
+
395
+ if (turnMeta?.statePatch) {
396
+ // Claude subagent returned structured state — apply it directly
397
+ nextMessage = rawResponse;
398
+ const sp = turnMeta.statePatch;
399
+ if (sp.phase) collabState.phase = sp.phase;
400
+ if (sp.overlapScore != null) {
401
+ collabState.overlapScore = Math.max(0, Math.min(1, sp.overlapScore));
369
402
  }
370
- if (Array.isArray(parsed.statePatch.activeThreads)) {
371
- collabState.activeThreads = parsed.statePatch.activeThreads.slice(0, 4);
403
+ if (Array.isArray(sp.activeThreads)) {
404
+ collabState.activeThreads = sp.activeThreads.slice(0, 4);
372
405
  }
373
- if (Array.isArray(parsed.statePatch.candidateCollaborations)) {
374
- collabState.candidateCollaborations = parsed.statePatch.candidateCollaborations.slice(0, 4);
406
+ if (Array.isArray(sp.candidateCollaborations)) {
407
+ collabState.candidateCollaborations = sp.candidateCollaborations.slice(0, 4);
375
408
  }
376
- if (parsed.statePatch.closeSignal != null) {
377
- collabState.closeSignal = Boolean(parsed.statePatch.closeSignal);
409
+ if (sp.closeSignal != null) {
410
+ collabState.closeSignal = Boolean(sp.closeSignal);
378
411
  }
379
- if (parsed.statePatch.confidence != null) {
380
- collabState.confidence = Math.max(0, Math.min(1, parsed.statePatch.confidence));
412
+ if (sp.confidence != null) {
413
+ collabState.confidence = Math.max(0, Math.min(1, sp.confidence));
381
414
  }
382
415
  } else {
383
- // No <collab_state> in response (generic/fallback runtime) — infer progression
384
- const inferred = inferStateProgression(collabState, remoteText, turn + 1);
385
- if (inferred.phase) collabState.phase = inferred.phase;
386
- if (inferred.overlapScore != null) collabState.overlapScore = inferred.overlapScore;
387
- if (inferred.confidence != null) collabState.confidence = inferred.confidence;
416
+ // Non-claude path: extract from <collab_state> tags in response text
417
+ const parsed = extractCollaborationState(rawResponse);
418
+ nextMessage = parsed.cleanText || rawResponse;
419
+
420
+ if (parsed.hasState && parsed.statePatch) {
421
+ if (parsed.statePatch.phase) collabState.phase = parsed.statePatch.phase;
422
+ if (parsed.statePatch.overlapScore != null) {
423
+ collabState.overlapScore = Math.max(0, Math.min(1, parsed.statePatch.overlapScore));
424
+ }
425
+ if (Array.isArray(parsed.statePatch.activeThreads)) {
426
+ collabState.activeThreads = parsed.statePatch.activeThreads.slice(0, 4);
427
+ }
428
+ if (Array.isArray(parsed.statePatch.candidateCollaborations)) {
429
+ collabState.candidateCollaborations = parsed.statePatch.candidateCollaborations.slice(0, 4);
430
+ }
431
+ if (parsed.statePatch.closeSignal != null) {
432
+ collabState.closeSignal = Boolean(parsed.statePatch.closeSignal);
433
+ }
434
+ if (parsed.statePatch.confidence != null) {
435
+ collabState.confidence = Math.max(0, Math.min(1, parsed.statePatch.confidence));
436
+ }
437
+ } else {
438
+ // No <collab_state> in response (generic/fallback runtime) — infer progression
439
+ const inferred = inferStateProgression(collabState, remoteText, turn + 1);
440
+ if (inferred.phase) collabState.phase = inferred.phase;
441
+ if (inferred.overlapScore != null) collabState.overlapScore = inferred.overlapScore;
442
+ if (inferred.confidence != null) collabState.confidence = inferred.confidence;
443
+ if (inferred.closeSignal != null) collabState.closeSignal = inferred.closeSignal;
444
+ }
445
+ }
446
+
447
+ // 6b. Overlap flatline detection — if overlap hasn't changed significantly
448
+ // for 3+ consecutive turns while in converging phase, the conversation is dead
449
+ overlapHistory.push(collabState.overlapScore);
450
+ if (collabState.phase === 'converging' && overlapHistory.length >= 3) {
451
+ const recent = overlapHistory.slice(-3);
452
+ const maxDelta = Math.max(
453
+ Math.abs(recent[1] - recent[0]),
454
+ Math.abs(recent[2] - recent[1])
455
+ );
456
+ if (maxDelta < 0.02) {
457
+ collabState.closeSignal = true;
458
+ }
388
459
  }
389
460
 
390
461
  // 7. Persist collab state to DB
@@ -396,6 +467,16 @@ Be concise but specific. No filler.`;
396
467
  }
397
468
  }
398
469
 
470
+ // 7b. Store flags from claude subagent responses
471
+ if (turnMeta?.flags?.length > 0 && this.convStore && dbConversationStarted) {
472
+ this.convStore.addMessage(conversationId, {
473
+ direction: 'outbound',
474
+ role: 'system',
475
+ content: `[flags] ${turnMeta.flags.map(f => f.content || f.type).join('; ')}`,
476
+ metadata: JSON.stringify({ flags: turnMeta.flags, turn: turn + 1 })
477
+ });
478
+ }
479
+
399
480
  // onTurn callback for progress output
400
481
  if (this.onTurn) {
401
482
  try {