a2acalling 0.6.51 → 0.6.53

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.
@@ -1,5 +1,4 @@
1
1
  use std::sync::atomic::{AtomicBool, AtomicU16, Ordering};
2
- use std::sync::Arc;
3
2
  use std::time::Duration;
4
3
  use tauri::{Emitter, Manager};
5
4
 
@@ -21,8 +20,8 @@ pub fn set_connected(port: u16) {
21
20
 
22
21
  /// Start background health check loop — emits "server-status" events
23
22
  pub fn start_health_monitor(app: tauri::AppHandle) {
24
- let handle = Arc::new(app);
25
- tokio::spawn(async move {
23
+ tauri::async_runtime::spawn(async move {
24
+ let handle = app;
26
25
  loop {
27
26
  tokio::time::sleep(Duration::from_secs(3)).await;
28
27
 
@@ -75,7 +75,7 @@ fn process_dashboard_event(app: &tauri::AppHandle, raw: &str) {
75
75
 
76
76
  /// Connect to server-driven dashboard SSE and map events to native notifications.
77
77
  pub fn start_event_stream_listener(app: tauri::AppHandle) {
78
- tokio::spawn(async move {
78
+ tauri::async_runtime::spawn(async move {
79
79
  // Wait for initial discovery attempt.
80
80
  tokio::time::sleep(Duration::from_secs(2)).await;
81
81
 
@@ -44,7 +44,10 @@ pub fn start_server() -> StartResult {
44
44
  }
45
45
  };
46
46
 
47
- let port = crate::discovery::read_config_port().unwrap_or(3001);
47
+ let port = crate::discovery::read_config_ports()
48
+ .first()
49
+ .copied()
50
+ .unwrap_or(3001);
48
51
  let port_str = port.to_string();
49
52
 
50
53
  let result = Command::new(&binary)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "a2acalling",
3
- "version": "0.6.51",
3
+ "version": "0.6.53",
4
4
  "description": "Agent-to-agent calling for OpenClaw - A2A agent communication",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -1056,6 +1056,7 @@ function renderTierEditor(tierId) {
1056
1056
  document.getElementById('tier-name').value = tier.name || tier.id;
1057
1057
  document.getElementById('tier-description').value = tier.description || '';
1058
1058
  document.getElementById('tier-disclosure').value = tier.disclosure || 'minimal';
1059
+ document.getElementById('tier-tools').value = toLines(tier.allowed_tools || []);
1059
1060
  document.getElementById('tier-topics').value = toLines(tier.topics || []);
1060
1061
  document.getElementById('tier-goals').value = toLines(tier.goals || []);
1061
1062
  }
@@ -1072,6 +1073,7 @@ function bindSettingsActions() {
1072
1073
  name: document.getElementById('tier-name').value,
1073
1074
  description: document.getElementById('tier-description').value,
1074
1075
  disclosure: document.getElementById('tier-disclosure').value,
1076
+ allowed_tools: fromLines(document.getElementById('tier-tools').value),
1075
1077
  topics: fromLines(document.getElementById('tier-topics').value),
1076
1078
  goals: fromLines(document.getElementById('tier-goals').value)
1077
1079
  };
@@ -132,6 +132,7 @@
132
132
  <label>Name <input id="tier-name" type="text"></label>
133
133
  <label>Description <input id="tier-description" type="text"></label>
134
134
  <label>Disclosure <input id="tier-disclosure" type="text" placeholder="minimal"></label>
135
+ <label>Allowed Tools (one per line)<textarea id="tier-tools" rows="5" placeholder="Read&#10;Grep&#10;Glob"></textarea></label>
135
136
  <label>Topics (one per line)<textarea id="tier-topics" rows="6"></textarea></label>
136
137
  <label>Goals (one per line)<textarea id="tier-goals" rows="6"></textarea></label>
137
138
  <div class="row">
@@ -4,7 +4,14 @@
4
4
  * Spawns `claude` CLI processes for real LLM-powered A2A conversations
5
5
  * as an alternative to OpenClaw for A2A conversations.
6
6
  *
7
- * Uses `claude -p` (print mode) with `--resume` for multi-turn context continuity.
7
+ * Design decision (A2A-29):
8
+ * We intentionally run Claude turns in stateless one-shot mode instead of `--resume`.
9
+ * In production we observed intermittent hangs during nested Claude startup/restore.
10
+ * Stateless calls cost more tokens but are operationally safer under load.
11
+ *
12
+ * Permissioning is still enforced:
13
+ * `--allowedTools` is derived per request from token capabilities + allowed topics
14
+ * and can be further constrained by per-tier `allowed_tools` policy from onboarding.
8
15
  */
9
16
 
10
17
  const { execSync, spawn } = require('child_process');
@@ -14,6 +21,43 @@ const { HARD_FALLBACK_TURN_TIMEOUT_MS } = require('./turn-timeout');
14
21
  const logger = createLogger({ component: 'a2a.claude-subagent' });
15
22
 
16
23
  const A2A_RESPONSE_REGEX = /<a2a_response>\s*([\s\S]*?)\s*<\/a2a_response>/i;
24
+ const DEFAULT_CLAUDE_MODEL = 'claude-sonnet-4-5-20250929';
25
+ const CLAUDE_TOOL_UNIVERSE = ['Bash', 'Bash(readonly)', 'Read', 'Grep', 'Glob', 'WebSearch', 'WebFetch'];
26
+ const LEGACY_DEFAULT_TOOLS = ['Bash(readonly)', 'Read', 'Grep', 'Glob', 'WebSearch', 'WebFetch'];
27
+
28
+ const TOOL_NAME_MAP = {
29
+ bash: 'Bash',
30
+ 'bash(readonly)': 'Bash(readonly)',
31
+ 'bash-readonly': 'Bash(readonly)',
32
+ read: 'Read',
33
+ grep: 'Grep',
34
+ glob: 'Glob',
35
+ websearch: 'WebSearch',
36
+ webfetch: 'WebFetch'
37
+ };
38
+
39
+ function normalizeToolName(value) {
40
+ const key = String(value || '').trim().toLowerCase();
41
+ return TOOL_NAME_MAP[key] || null;
42
+ }
43
+
44
+ function sanitizeAllowedToolList(values) {
45
+ if (!Array.isArray(values)) return [];
46
+ const out = [];
47
+ const seen = new Set();
48
+ for (const value of values) {
49
+ const canonical = normalizeToolName(value);
50
+ if (!canonical || seen.has(canonical)) continue;
51
+ seen.add(canonical);
52
+ out.push(canonical);
53
+ }
54
+ return out;
55
+ }
56
+
57
+ function formatToolListForPrompt(tools) {
58
+ if (!Array.isArray(tools) || tools.length === 0) return ' (none)';
59
+ return tools.map(tool => ` - ${tool}`).join('\n');
60
+ }
17
61
 
18
62
  /**
19
63
  * Check if `claude` CLI is available in PATH.
@@ -40,6 +84,7 @@ function isClaudeAvailable() {
40
84
  * @param {string} config.tierObjectives - formatted objectives string
41
85
  * @param {string} config.doNotDiscuss - formatted do_not_discuss string
42
86
  * @param {string} config.neverDisclose - formatted never_disclose string
87
+ * @param {string[]} [config.allowedTools] - Effective tool allowlist for this call
43
88
  * @param {string} config.personalityNotes
44
89
  * @param {string} config.roleContext
45
90
  * @returns {string}
@@ -55,10 +100,17 @@ function buildSubagentSystemPrompt(config) {
55
100
  tierObjectives = ' (none specified)',
56
101
  doNotDiscuss = ' (none specified)',
57
102
  neverDisclose = ' (none specified)',
103
+ allowedTools = [],
58
104
  personalityNotes = '',
59
105
  roleContext = ''
60
106
  } = config;
61
107
 
108
+ const effectiveAllowedTools = sanitizeAllowedToolList(allowedTools);
109
+ const allowedForPrompt = effectiveAllowedTools.length > 0
110
+ ? effectiveAllowedTools
111
+ : [...LEGACY_DEFAULT_TOOLS];
112
+ const blockedTools = CLAUDE_TOOL_UNIVERSE.filter(tool => !allowedForPrompt.includes(tool));
113
+
62
114
  return `You are ${agentName}, the personal AI agent for ${ownerName}.
63
115
  You are on a live A2A (agent-to-agent) call with ${otherAgentName}, who represents ${otherOwnerName}. ${roleContext}
64
116
 
@@ -99,6 +151,19 @@ ${doNotDiscuss}
99
151
  NEVER disclose:
100
152
  ${neverDisclose}
101
153
 
154
+ == TOOL PERMISSIONS ==
155
+
156
+ Allowed tools this call:
157
+ ${formatToolListForPrompt(allowedForPrompt)}
158
+
159
+ Blocked tools this call:
160
+ ${formatToolListForPrompt(blockedTools)}
161
+
162
+ Tool policy:
163
+ - Never invoke blocked tools.
164
+ - If you need a blocked tool, continue the conversation without it and add a "question_for_owner" flag requesting permission.
165
+ - Include exact tool name and reason in the flag content.
166
+
102
167
  == BEHAVIORAL MANDATE ==
103
168
 
104
169
  You operate in three concurrent modes:
@@ -219,13 +284,11 @@ function parseSubagentResponse(resultText) {
219
284
  */
220
285
  function spawnClaude(args, timeoutMs = HARD_FALLBACK_TURN_TIMEOUT_MS) {
221
286
  return new Promise((resolve, reject) => {
287
+ const spawnEnv = { ...process.env, FORCE_COLOR: '0' };
288
+ delete spawnEnv.CLAUDECODE;
222
289
  const proc = spawn('claude', args, {
223
- stdio: ['pipe', 'pipe', 'pipe'],
224
- env: {
225
- ...process.env,
226
- FORCE_COLOR: '0',
227
- CLAUDECODE: '' // Unset to allow nested invocation
228
- }
290
+ stdio: ['ignore', 'pipe', 'pipe'],
291
+ env: spawnEnv
229
292
  });
230
293
 
231
294
  let stdout = '';
@@ -289,11 +352,158 @@ function extractResultFromJson(stdout) {
289
352
  }
290
353
  }
291
354
 
355
+ function normalizePermissionList(values) {
356
+ if (!Array.isArray(values)) return [];
357
+ return values
358
+ .map(v => String(v || '').trim().toLowerCase())
359
+ .filter(Boolean);
360
+ }
361
+
362
+ function hasPermissionMatch(values, key) {
363
+ if (!key) return false;
364
+ return values.some(value => value === key || value.startsWith(`${key}.`));
365
+ }
366
+
367
+ function deriveClaudeToolsFromPermissionSignals({ capabilities = [], allowedTopics = [] } = {}) {
368
+ const normalizedCaps = normalizePermissionList(capabilities);
369
+ const normalizedTopics = normalizePermissionList(allowedTopics);
370
+ const hasPermissionContext = normalizedCaps.length > 0 || normalizedTopics.length > 0;
371
+
372
+ if (!hasPermissionContext) {
373
+ return [...LEGACY_DEFAULT_TOOLS];
374
+ }
375
+
376
+ const hasContextRead = hasPermissionMatch(normalizedCaps, 'context-read')
377
+ || normalizedTopics.includes('chat');
378
+ const hasSearch = hasPermissionMatch(normalizedCaps, 'search')
379
+ || normalizedTopics.includes('search');
380
+ const hasToolsRead = hasPermissionMatch(normalizedCaps, 'tools')
381
+ || hasPermissionMatch(normalizedCaps, 'tools-read')
382
+ || normalizedTopics.includes('tools');
383
+ const hasToolsWrite = hasPermissionMatch(normalizedCaps, 'tools-write')
384
+ || hasPermissionMatch(normalizedCaps, 'tools.write')
385
+ || normalizedTopics.includes('tools-write')
386
+ || normalizedTopics.includes('tools.write');
387
+
388
+ const tools = [];
389
+
390
+ // Keep read-only introspection available for context-aware tiers.
391
+ if (hasContextRead || hasSearch || hasToolsRead || hasToolsWrite) {
392
+ tools.push('Read', 'Grep', 'Glob');
393
+ }
394
+
395
+ // Web tools are explicitly tied to search-style permissions.
396
+ if (hasSearch) {
397
+ tools.push('WebSearch', 'WebFetch');
398
+ }
399
+
400
+ // Shell access is gated behind tool permissions, with explicit writable opt-in.
401
+ if (hasToolsWrite) {
402
+ tools.unshift('Bash');
403
+ } else if (hasToolsRead) {
404
+ tools.unshift('Bash(readonly)');
405
+ }
406
+
407
+ // Fail closed to read-only file inspection if metadata is custom/unknown.
408
+ if (tools.length === 0) {
409
+ return ['Read', 'Grep', 'Glob'];
410
+ }
411
+
412
+ return tools;
413
+ }
414
+
415
+ /**
416
+ * Resolve Claude tool allowlist from token-derived permissions and explicit per-tier tool policy.
417
+ */
418
+ function resolveClaudeAllowedTools({ capabilities = [], allowedTopics = [], allowedTools = [] } = {}) {
419
+ const derivedTools = deriveClaudeToolsFromPermissionSignals({ capabilities, allowedTopics });
420
+ const explicitTools = sanitizeAllowedToolList(allowedTools);
421
+
422
+ if (explicitTools.length > 0) {
423
+ const hasPermissionSignals = normalizePermissionList(capabilities).length > 0
424
+ || normalizePermissionList(allowedTopics).length > 0;
425
+
426
+ if (hasPermissionSignals) {
427
+ // Permission signals define the ceiling; explicit tier tools can narrow it.
428
+ const narrowed = explicitTools.filter(tool => derivedTools.includes(tool));
429
+ return narrowed.length > 0 ? narrowed : derivedTools;
430
+ }
431
+
432
+ return explicitTools;
433
+ }
434
+
435
+ return derivedTools;
436
+ }
437
+
438
+ function buildClaudeToolArg(allowedTools) {
439
+ return Array.isArray(allowedTools) ? allowedTools.join(' ').trim() : '';
440
+ }
441
+
442
+ function parseSummaryPayload(resultText) {
443
+ const text = String(resultText || '').trim();
444
+ if (!text) return null;
445
+
446
+ // Backwards-compatible: older prompts wrapped JSON in <a2a_response>.
447
+ const tagged = text.match(A2A_RESPONSE_REGEX);
448
+ if (tagged && tagged[1]) {
449
+ try {
450
+ return JSON.parse(tagged[1].trim());
451
+ } catch (err) {
452
+ logger.warn('Failed to parse tagged summary JSON', {
453
+ event: 'subagent_summary_tag_parse_failed',
454
+ error: err
455
+ });
456
+ }
457
+ }
458
+
459
+ // Preferred path for unified summary prompt: direct JSON object.
460
+ try {
461
+ const parsed = JSON.parse(text);
462
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
463
+ return parsed;
464
+ }
465
+ } catch (err) {
466
+ logger.debug('Summary result is not direct JSON; falling back to plain text summary', {
467
+ event: 'subagent_summary_raw_fallback',
468
+ data: { output_length: text.length }
469
+ });
470
+ }
471
+
472
+ return null;
473
+ }
474
+
475
+ function summarizeFromPayload(payload, fallbackText) {
476
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
477
+ return null;
478
+ }
479
+
480
+ // Native A2A summary payload shape.
481
+ if (typeof payload.summary === 'string' || typeof payload.ownerSummary === 'string') {
482
+ return {
483
+ summary: payload.summary || payload.message || fallbackText || '',
484
+ ownerSummary: payload.ownerSummary || payload.summary || payload.message || fallbackText || '',
485
+ actionItems: Array.isArray(payload.actionItems) ? payload.actionItems : [],
486
+ flags: Array.isArray(payload.flags) ? payload.flags : []
487
+ };
488
+ }
489
+
490
+ // Unified summary schema shape (headline/assessment/nextSteps).
491
+ if (typeof payload.headline === 'string') {
492
+ return {
493
+ summary: payload.headline,
494
+ ownerSummary: typeof payload.assessment === 'string' ? payload.assessment : payload.headline,
495
+ actionItems: Array.isArray(payload.nextSteps) ? payload.nextSteps : [],
496
+ flags: []
497
+ };
498
+ }
499
+
500
+ return null;
501
+ }
502
+
292
503
  /**
293
504
  * Run a single turn of the Claude subagent.
294
505
  *
295
506
  * @param {Object} options
296
- * @param {string} options.sessionId - Conversation session ID (used for --resume on turn 2+)
297
507
  * @param {string} options.systemPrompt - System prompt (used on turn 1 only)
298
508
  * @param {string} options.turnMessage - The inbound message from the remote agent
299
509
  * @param {number} options.turn - Current turn number (1-based)
@@ -303,12 +513,15 @@ function extractResultFromJson(stdout) {
303
513
  * @param {Array} options.activeThreads - Active conversation threads
304
514
  * @param {Array} options.candidateCollaborations - Candidate collaboration ideas
305
515
  * @param {boolean} options.closeSignal - Whether close has been signaled
516
+ * @param {Array<string>} [options.capabilities] - Token capabilities (permission source of truth)
517
+ * @param {Array<string>} [options.allowedTopics] - Token allowed topics (permission source of truth)
518
+ * @param {Array<string>} [options.allowedTools] - Token allowed tools (onboarding tier policy)
519
+ * @param {function} [options.spawnFn] - Injectable process runner for tests
306
520
  * @param {number} [options.timeoutMs=300000] - Timeout in milliseconds
307
- * @returns {Promise<{ message: string, statePatch: object|null, flags: array, sessionId: string }>}
521
+ * @returns {Promise<{ message: string, statePatch: object|null, flags: array }>}
308
522
  */
309
523
  async function runClaudeTurn(options) {
310
524
  const {
311
- sessionId,
312
525
  systemPrompt,
313
526
  turnMessage,
314
527
  turn = 1,
@@ -318,6 +531,10 @@ async function runClaudeTurn(options) {
318
531
  activeThreads = [],
319
532
  candidateCollaborations = [],
320
533
  closeSignal = false,
534
+ capabilities = [],
535
+ allowedTopics = [],
536
+ allowedTools = [],
537
+ spawnFn = spawnClaude,
321
538
  timeoutMs = HARD_FALLBACK_TURN_TIMEOUT_MS
322
539
  } = options;
323
540
 
@@ -333,29 +550,21 @@ async function runClaudeTurn(options) {
333
550
  });
334
551
 
335
552
  const startAt = Date.now();
336
- const allowedTools = 'Bash(readonly) Read Grep Glob WebSearch WebFetch';
337
-
338
- let args;
339
- if (turn === 1 || !sessionId) {
340
- // First turn: create new session
341
- args = [
342
- '-p',
343
- '--output-format', 'json',
344
- '--system-prompt', systemPrompt,
345
- '--allowedTools', allowedTools,
346
- '--model', 'claude-sonnet-4-5-20250929',
347
- turnPrompt
348
- ];
349
- } else {
350
- // Subsequent turns: resume existing session
351
- args = [
352
- '-p',
353
- '--output-format', 'json',
354
- '--resume', sessionId,
355
- '--allowedTools', allowedTools,
356
- turnPrompt
357
- ];
553
+ const effectiveAllowedTools = resolveClaudeAllowedTools({ capabilities, allowedTopics, allowedTools });
554
+ const allowedToolsArg = buildClaudeToolArg(effectiveAllowedTools);
555
+ const args = [
556
+ '-p',
557
+ '--output-format', 'json',
558
+ '--system-prompt', systemPrompt,
559
+ '--model', DEFAULT_CLAUDE_MODEL
560
+ ];
561
+
562
+ // We always provide --allowedTools explicitly so token permissioning stays
563
+ // enforced in Claude mode even after moving to stateless turns.
564
+ if (allowedToolsArg) {
565
+ args.push('--allowedTools', allowedToolsArg);
358
566
  }
567
+ args.push('--', turnPrompt);
359
568
 
360
569
  logger.debug('Spawning Claude subagent turn', {
361
570
  event: 'subagent_turn_start',
@@ -363,13 +572,14 @@ async function runClaudeTurn(options) {
363
572
  turn,
364
573
  max_turns: maxTurns,
365
574
  phase,
366
- is_resume: turn > 1 && Boolean(sessionId),
575
+ is_stateless: true,
576
+ allowed_tools: effectiveAllowedTools,
367
577
  timeout_ms: timeoutMs
368
578
  }
369
579
  });
370
580
 
371
- const { stdout } = await spawnClaude(args, timeoutMs);
372
- const { result, sessionId: newSessionId } = extractResultFromJson(stdout);
581
+ const { stdout } = await spawnFn(args, timeoutMs);
582
+ const { result } = extractResultFromJson(stdout);
373
583
  const parsed = parseSubagentResponse(result);
374
584
 
375
585
  logger.debug('Claude subagent turn completed', {
@@ -379,91 +589,90 @@ async function runClaudeTurn(options) {
379
589
  duration_ms: Date.now() - startAt,
380
590
  message_length: parsed.message.length,
381
591
  has_state_patch: Boolean(parsed.statePatch),
382
- flag_count: parsed.flags.length,
383
- session_id: newSessionId || sessionId
592
+ flag_count: parsed.flags.length
384
593
  }
385
594
  });
386
595
 
387
596
  return {
388
597
  message: parsed.message,
389
598
  statePatch: parsed.statePatch,
390
- flags: parsed.flags,
391
- sessionId: newSessionId || sessionId
599
+ flags: parsed.flags
392
600
  };
393
601
  }
394
602
 
395
603
  /**
396
- * Run a summary turn using the Claude subagent session.
604
+ * Run a summary turn in stateless Claude mode.
397
605
  *
398
- * @param {string} sessionId - Session ID to resume
399
- * @param {string} reason - Why the conversation is ending
400
- * @param {number} [timeoutMs=300000] - Timeout in milliseconds
606
+ * @param {Object} options
607
+ * @param {string} options.prompt - Unified summary prompt
608
+ * @param {string} [options.reason] - Why the conversation is ending
609
+ * @param {Array<string>} [options.capabilities] - Token capabilities for summary turn tooling
610
+ * @param {Array<string>} [options.allowedTopics] - Token allowed topics for summary turn tooling
611
+ * @param {Array<string>} [options.allowedTools] - Token allowed tools for summary turn tooling
612
+ * @param {function} [options.spawnFn] - Injectable process runner for tests
613
+ * @param {number} [options.timeoutMs=300000] - Timeout in milliseconds
401
614
  * @returns {Promise<{ summary: string, ownerSummary: string, actionItems: array, flags: array }>}
402
615
  */
403
- async function runClaudeSummary(sessionId, reason, timeoutMs = HARD_FALLBACK_TURN_TIMEOUT_MS) {
404
- if (!sessionId) {
405
- throw new Error('Cannot summarize without a session ID');
406
- }
616
+ async function runClaudeSummary(options = {}) {
617
+ const {
618
+ prompt,
619
+ reason,
620
+ capabilities = [],
621
+ allowedTopics = [],
622
+ allowedTools = [],
623
+ spawnFn = spawnClaude,
624
+ timeoutMs = HARD_FALLBACK_TURN_TIMEOUT_MS
625
+ } = options;
407
626
 
408
- const summaryPrompt = `The conversation is ending. Reason: ${reason || 'max turns reached'}.
627
+ const summaryPrompt = String(prompt || '').trim();
628
+ if (!summaryPrompt) {
629
+ throw new Error('Cannot summarize without a prompt');
630
+ }
409
631
 
410
- Provide a structured summary. Respond with ONLY a JSON block:
411
-
412
- <a2a_response>
413
- {
414
- "message": "Brief 1-2 sentence summary of the conversation.",
415
- "statePatch": {"phase": "close", "closeSignal": true},
416
- "flags": [],
417
- "summary": "Detailed summary for the conversation record.",
418
- "ownerSummary": "Summary written for the owner highlighting key findings and opportunities.",
419
- "actionItems": ["Specific follow-up item 1", "Specific follow-up item 2"]
420
- }
421
- </a2a_response>`;
632
+ const effectiveAllowedTools = resolveClaudeAllowedTools({ capabilities, allowedTopics, allowedTools });
633
+ const allowedToolsArg = buildClaudeToolArg(effectiveAllowedTools);
422
634
 
423
635
  const args = [
424
636
  '-p',
425
637
  '--output-format', 'json',
426
- '--resume', sessionId,
427
- summaryPrompt
638
+ '--model', DEFAULT_CLAUDE_MODEL
428
639
  ];
429
640
 
641
+ if (allowedToolsArg) {
642
+ args.push('--allowedTools', allowedToolsArg);
643
+ }
644
+ args.push(
645
+ '--append-system-prompt',
646
+ `Conversation summary mode. Reason: ${reason || 'conversation ended'}. Return only structured summary JSON.`,
647
+ '--',
648
+ summaryPrompt
649
+ );
650
+
430
651
  const startAt = Date.now();
431
652
 
432
653
  logger.debug('Spawning Claude summary', {
433
654
  event: 'subagent_summary_start',
434
- data: { session_id: sessionId, reason }
655
+ data: {
656
+ reason: reason || 'conversation ended',
657
+ allowed_tools: effectiveAllowedTools
658
+ }
435
659
  });
436
660
 
437
- const { stdout } = await spawnClaude(args, timeoutMs);
661
+ const { stdout } = await spawnFn(args, timeoutMs);
438
662
  const { result } = extractResultFromJson(stdout);
439
-
440
- // Try to extract structured summary from <a2a_response>
441
- const match = result.match(A2A_RESPONSE_REGEX);
442
- if (match) {
443
- try {
444
- const parsed = JSON.parse(match[1].trim());
445
- logger.debug('Claude summary completed', {
446
- event: 'subagent_summary_complete',
447
- data: {
448
- session_id: sessionId,
449
- duration_ms: Date.now() - startAt,
450
- has_summary: Boolean(parsed.summary),
451
- action_item_count: Array.isArray(parsed.actionItems) ? parsed.actionItems.length : 0
452
- }
453
- });
454
-
455
- return {
456
- summary: parsed.summary || parsed.message || result.replace(A2A_RESPONSE_REGEX, '').trim(),
457
- ownerSummary: parsed.ownerSummary || parsed.summary || parsed.message || '',
458
- actionItems: Array.isArray(parsed.actionItems) ? parsed.actionItems : [],
459
- flags: Array.isArray(parsed.flags) ? parsed.flags : []
460
- };
461
- } catch (err) {
462
- logger.warn('Failed to parse summary JSON', {
463
- event: 'subagent_summary_parse_failed',
464
- error: err
465
- });
466
- }
663
+ const summaryPayload = parseSummaryPayload(result);
664
+ const parsedSummary = summarizeFromPayload(summaryPayload, result.trim());
665
+
666
+ if (parsedSummary) {
667
+ logger.debug('Claude summary completed', {
668
+ event: 'subagent_summary_complete',
669
+ data: {
670
+ duration_ms: Date.now() - startAt,
671
+ has_summary: Boolean(parsedSummary.summary),
672
+ action_item_count: parsedSummary.actionItems.length
673
+ }
674
+ });
675
+ return parsedSummary;
467
676
  }
468
677
 
469
678
  // Fallback: use raw text as summary
@@ -480,6 +689,7 @@ module.exports = {
480
689
  isClaudeAvailable,
481
690
  buildSubagentSystemPrompt,
482
691
  buildTurnPrompt,
692
+ resolveClaudeAllowedTools,
483
693
  runClaudeTurn,
484
694
  runClaudeSummary,
485
695
  parseSubagentResponse
package/src/lib/config.js CHANGED
@@ -125,6 +125,13 @@ function validateTierPatch(tierName, tierConfig) {
125
125
  });
126
126
  }
127
127
 
128
+ if (tierConfig.allowed_tools !== undefined) {
129
+ out.allowed_tools = validateStringArray(tierConfig.allowed_tools, `${tierName}.allowed_tools`, {
130
+ maxItems: 30,
131
+ itemMaxLength: 80
132
+ });
133
+ }
134
+
128
135
  if (tierConfig.topics !== undefined) {
129
136
  out.topics = validateStringArray(tierConfig.topics, `${tierName}.topics`, {
130
137
  maxItems: 200,
@@ -181,6 +188,7 @@ const DEFAULT_CONFIG = {
181
188
  name: 'Public',
182
189
  description: 'Basic networking - safe for anyone',
183
190
  capabilities: ['context-read'],
191
+ allowed_tools: ['Read', 'Grep', 'Glob'],
184
192
  topics: ['chat'],
185
193
  goals: [],
186
194
  disclosure: 'minimal',
@@ -190,6 +198,7 @@ const DEFAULT_CONFIG = {
190
198
  name: 'Friends',
191
199
  description: 'Most capabilities, no sensitive financial data',
192
200
  capabilities: ['context-read', 'calendar.read', 'email.read', 'search'],
201
+ allowed_tools: ['Bash(readonly)', 'Read', 'Grep', 'Glob', 'WebSearch', 'WebFetch'],
193
202
  topics: ['chat', 'search', 'openclaw', 'a2a'],
194
203
  goals: [],
195
204
  disclosure: 'public',
@@ -199,6 +208,7 @@ const DEFAULT_CONFIG = {
199
208
  name: 'Family',
200
209
  description: 'Full access - only for your inner circle',
201
210
  capabilities: ['context-read', 'calendar', 'email', 'search', 'tools', 'memory'],
211
+ allowed_tools: ['Bash', 'Read', 'Grep', 'Glob', 'WebSearch', 'WebFetch'],
202
212
  topics: ['chat', 'search', 'openclaw', 'a2a', 'tools', 'memory'],
203
213
  goals: [],
204
214
  disclosure: 'public',
@@ -208,6 +218,7 @@ const DEFAULT_CONFIG = {
208
218
  name: 'Custom',
209
219
  description: 'User-defined permissions',
210
220
  capabilities: ['context-read'],
221
+ allowed_tools: ['Read', 'Grep', 'Glob'],
211
222
  topics: [],
212
223
  goals: [],
213
224
  disclosure: 'minimal',