a2acalling 0.6.43 → 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.
package/README.md CHANGED
@@ -410,11 +410,7 @@ app.listen(3001);
410
410
  | `A2A_PORT` | Server port (default: 3001) |
411
411
  | `A2A_CONFIG_DIR` | Config directory (default: `~/.config/openclaw`) |
412
412
  | `A2A_WORKSPACE` | Workspace root for context files like `USER.md` (default: current directory) |
413
- | `A2A_RUNTIME` | Runtime mode: `auto` (default), `openclaw`, or `generic` |
414
- | `A2A_RUNTIME_FAILOVER` | Fallback to generic runtime if OpenClaw runtime errors (default: `true`) |
415
- | `A2A_AGENT_COMMAND` | Generic runtime command for inbound turn handling (reads JSON from stdin) |
416
- | `A2A_SUMMARY_COMMAND` | Generic runtime command for call summaries (reads JSON from stdin) |
417
- | `A2A_NOTIFY_COMMAND` | Generic runtime command for owner notifications (reads JSON from stdin) |
413
+ | `A2A_RUNTIME` | Runtime mode: `auto` (default), `openclaw`, or `claude` |
418
414
  | `A2A_AGENT_NAME` | Override local agent display name |
419
415
  | `A2A_OWNER_NAME` | Override owner display name |
420
416
  | `A2A_COLLAB_MODE` | Conversation style: `adaptive` (default) or `deep_dive` |
package/bin/cli.js CHANGED
@@ -518,8 +518,7 @@ async function handleDisclosureSubmit(args, commandLabel = 'onboard') {
518
518
  return tierData.topics.map(t => String(t && t.topic || '').trim()).filter(Boolean);
519
519
  }
520
520
 
521
- // Get tiers data (support both new 'tiers' key and legacy 'topics' key)
522
- const tiersData = manifest.tiers || manifest.topics || {};
521
+ const tiersData = manifest.tiers || {};
523
522
 
524
523
  try {
525
524
  config.setTier('public', {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "a2acalling",
3
- "version": "0.6.43",
3
+ "version": "0.6.44",
4
4
  "description": "Agent-to-agent calling for OpenClaw - A2A agent communication",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -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
+ };
@@ -56,7 +56,7 @@ function detectRemoteTermination(text) {
56
56
 
57
57
  /**
58
58
  * Infer collaboration state progression when the runtime doesn't emit
59
- * <collab_state> tags (generic/fallback mode). Advances phase based on
59
+ * <collab_state> tags (e.g. OpenClaw without adaptive mode). Advances phase based on
60
60
  * turn count and estimates overlap from remote text analysis.
61
61
  */
62
62
  function inferStateProgression(collabState, remoteText, turn) {
@@ -128,8 +128,11 @@ class ConversationDriver {
128
128
  this.tier = options.tier || 'public';
129
129
  this.summarizer = options.summarizer || null;
130
130
  this.ownerContext = options.ownerContext || {};
131
+ this.claudeMode = options.runtime?.mode === 'claude';
132
+ this.claudeTimeoutMs = options.claudeTimeoutMs || 180000;
131
133
 
132
- 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 });
133
136
  }
134
137
 
135
138
  /**
@@ -351,18 +354,30 @@ Be concise but specific. No filler.`;
351
354
  // 5. Call runtime.runTurn() to generate next message
352
355
  const sessionId = `a2a-${conversationId}`;
353
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
+ }
354
373
  try {
355
374
  rawResponse = await this.runtime.runTurn({
356
375
  sessionId,
357
376
  prompt,
358
377
  message: remoteText,
359
378
  caller: this.caller,
360
- timeoutMs: 65000,
361
- context: {
362
- conversationId,
363
- tier: this.tier,
364
- ownerName: this.agentContext.owner
365
- }
379
+ timeoutMs: this.claudeMode ? this.claudeTimeoutMs : 65000,
380
+ context: contextPayload
366
381
  });
367
382
  } catch (err) {
368
383
  logger.error('Runtime turn failed', {
@@ -374,33 +389,59 @@ Be concise but specific. No filler.`;
374
389
  }
375
390
 
376
391
  // 6. Extract collab state from response
377
- const parsed = extractCollaborationState(rawResponse);
378
- nextMessage = parsed.cleanText || rawResponse;
379
-
380
- if (parsed.hasState && parsed.statePatch) {
381
- if (parsed.statePatch.phase) collabState.phase = parsed.statePatch.phase;
382
- if (parsed.statePatch.overlapScore != null) {
383
- 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));
384
402
  }
385
- if (Array.isArray(parsed.statePatch.activeThreads)) {
386
- collabState.activeThreads = parsed.statePatch.activeThreads.slice(0, 4);
403
+ if (Array.isArray(sp.activeThreads)) {
404
+ collabState.activeThreads = sp.activeThreads.slice(0, 4);
387
405
  }
388
- if (Array.isArray(parsed.statePatch.candidateCollaborations)) {
389
- collabState.candidateCollaborations = parsed.statePatch.candidateCollaborations.slice(0, 4);
406
+ if (Array.isArray(sp.candidateCollaborations)) {
407
+ collabState.candidateCollaborations = sp.candidateCollaborations.slice(0, 4);
390
408
  }
391
- if (parsed.statePatch.closeSignal != null) {
392
- collabState.closeSignal = Boolean(parsed.statePatch.closeSignal);
409
+ if (sp.closeSignal != null) {
410
+ collabState.closeSignal = Boolean(sp.closeSignal);
393
411
  }
394
- if (parsed.statePatch.confidence != null) {
395
- 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));
396
414
  }
397
415
  } else {
398
- // No <collab_state> in response (generic/fallback runtime) — infer progression
399
- const inferred = inferStateProgression(collabState, remoteText, turn + 1);
400
- if (inferred.phase) collabState.phase = inferred.phase;
401
- if (inferred.overlapScore != null) collabState.overlapScore = inferred.overlapScore;
402
- if (inferred.confidence != null) collabState.confidence = inferred.confidence;
403
- if (inferred.closeSignal != null) collabState.closeSignal = inferred.closeSignal;
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
+ }
404
445
  }
405
446
 
406
447
  // 6b. Overlap flatline detection — if overlap hasn't changed significantly
@@ -426,6 +467,16 @@ Be concise but specific. No filler.`;
426
467
  }
427
468
  }
428
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
+
429
480
  // onTurn callback for progress output
430
481
  if (this.onTurn) {
431
482
  try {