a2acalling 0.6.43 → 0.6.45
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 +27 -5
- package/bin/cli.js +48 -2
- package/docs/plans/2026-02-16-a2a-callbook-macos-app.md +1660 -0
- package/docs/plans/2026-02-16-e2e-test-prompt-sequence.md +1812 -0
- package/native/macos/index.html +172 -0
- package/native/macos/package.json +8 -0
- package/native/macos/src-tauri/Cargo.toml +23 -0
- package/native/macos/src-tauri/build.rs +3 -0
- package/native/macos/src-tauri/capabilities/default.json +16 -0
- package/native/macos/src-tauri/icons/128x128.png +0 -0
- package/native/macos/src-tauri/icons/128x128@2x.png +0 -0
- package/native/macos/src-tauri/icons/32x32.png +0 -0
- package/native/macos/src-tauri/icons/icon.icns +0 -0
- package/native/macos/src-tauri/icons/tray-connected.png +0 -0
- package/native/macos/src-tauri/icons/tray-disconnected.png +0 -0
- package/native/macos/src-tauri/src/discovery.rs +86 -0
- package/native/macos/src-tauri/src/health.rs +64 -0
- package/native/macos/src-tauri/src/lib.rs +185 -0
- package/native/macos/src-tauri/src/main.rs +6 -0
- package/native/macos/src-tauri/src/notifications.rs +101 -0
- package/native/macos/src-tauri/src/server.rs +67 -0
- package/native/macos/src-tauri/tauri.conf.json +48 -0
- package/package.json +1 -1
- package/scripts/postinstall.js +49 -0
- package/src/lib/claude-subagent.js +485 -0
- package/src/lib/conversation-driver.js +80 -29
- package/src/lib/disclosure.js +5 -6
- package/src/lib/runtime-adapter.js +221 -437
- package/src/routes/dashboard.js +5 -5
- package/src/server.js +5 -10
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
use serde::Serialize;
|
|
2
|
+
use std::process::Command;
|
|
3
|
+
|
|
4
|
+
#[derive(Debug, Serialize)]
|
|
5
|
+
pub struct StartResult {
|
|
6
|
+
pub success: bool,
|
|
7
|
+
pub message: String,
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/// Find the `a2a` CLI binary
|
|
11
|
+
fn find_a2a_binary() -> Option<String> {
|
|
12
|
+
// Check common locations
|
|
13
|
+
let candidates = [
|
|
14
|
+
"a2a", // In PATH
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
for candidate in &candidates {
|
|
18
|
+
let result = Command::new("which")
|
|
19
|
+
.arg(candidate)
|
|
20
|
+
.output();
|
|
21
|
+
|
|
22
|
+
if let Ok(output) = result {
|
|
23
|
+
if output.status.success() {
|
|
24
|
+
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
|
25
|
+
if !path.is_empty() {
|
|
26
|
+
return Some(path);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
None
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/// Start the a2a server as a detached process
|
|
36
|
+
pub fn start_server() -> StartResult {
|
|
37
|
+
let binary = match find_a2a_binary() {
|
|
38
|
+
Some(b) => b,
|
|
39
|
+
None => {
|
|
40
|
+
return StartResult {
|
|
41
|
+
success: false,
|
|
42
|
+
message: "Could not find 'a2a' CLI. Is a2acalling installed? Run: npm install -g a2acalling".to_string(),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
let port = crate::discovery::read_config_port().unwrap_or(3001);
|
|
48
|
+
let port_str = port.to_string();
|
|
49
|
+
|
|
50
|
+
let result = Command::new(&binary)
|
|
51
|
+
.args(["server", "--port", &port_str])
|
|
52
|
+
.stdout(std::process::Stdio::null())
|
|
53
|
+
.stderr(std::process::Stdio::null())
|
|
54
|
+
.stdin(std::process::Stdio::null())
|
|
55
|
+
.spawn();
|
|
56
|
+
|
|
57
|
+
match result {
|
|
58
|
+
Ok(_child) => StartResult {
|
|
59
|
+
success: true,
|
|
60
|
+
message: format!("Server starting on port {}...", port),
|
|
61
|
+
},
|
|
62
|
+
Err(err) => StartResult {
|
|
63
|
+
success: false,
|
|
64
|
+
message: format!("Failed to start server: {}", err),
|
|
65
|
+
},
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-config-schema/schema.json",
|
|
3
|
+
"productName": "A2A Callbook",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"identifier": "com.openclaw.a2a-callbook",
|
|
6
|
+
"build": {
|
|
7
|
+
"frontendDist": "../index.html"
|
|
8
|
+
},
|
|
9
|
+
"app": {
|
|
10
|
+
"windows": [
|
|
11
|
+
{
|
|
12
|
+
"title": "A2A Callbook",
|
|
13
|
+
"width": 1024,
|
|
14
|
+
"height": 720,
|
|
15
|
+
"minWidth": 480,
|
|
16
|
+
"minHeight": 600,
|
|
17
|
+
"resizable": true
|
|
18
|
+
}
|
|
19
|
+
],
|
|
20
|
+
"security": {
|
|
21
|
+
"dangerousRemoteUrlAccess": [
|
|
22
|
+
{ "url": "http://127.0.0.1:**" },
|
|
23
|
+
{ "url": "http://localhost:**" }
|
|
24
|
+
]
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
"bundle": {
|
|
28
|
+
"active": true,
|
|
29
|
+
"targets": ["dmg", "app"],
|
|
30
|
+
"icon": [
|
|
31
|
+
"icons/32x32.png",
|
|
32
|
+
"icons/128x128.png",
|
|
33
|
+
"icons/128x128@2x.png",
|
|
34
|
+
"icons/icon.icns"
|
|
35
|
+
],
|
|
36
|
+
"macOS": {
|
|
37
|
+
"minimumSystemVersion": "12.0",
|
|
38
|
+
"frameworks": []
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
"plugins": {
|
|
42
|
+
"deep-link": {
|
|
43
|
+
"desktop": {
|
|
44
|
+
"schemes": ["a2a"]
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
package/package.json
CHANGED
package/scripts/postinstall.js
CHANGED
|
@@ -43,7 +43,56 @@ const result = spawnSync(process.execPath, [cliPath, 'quickstart'], {
|
|
|
43
43
|
|
|
44
44
|
if (result.error) {
|
|
45
45
|
// Don't fail the install — the agent will get onboarding when it runs `a2a`.
|
|
46
|
+
installMacOSApp();
|
|
46
47
|
process.exit(0);
|
|
47
48
|
}
|
|
48
49
|
|
|
50
|
+
installMacOSApp();
|
|
49
51
|
process.exit(result.status || 0);
|
|
52
|
+
|
|
53
|
+
// Download and install the native macOS app from GitHub Releases
|
|
54
|
+
function installMacOSApp() {
|
|
55
|
+
const os = require('os');
|
|
56
|
+
const fs = require('fs');
|
|
57
|
+
|
|
58
|
+
if (os.platform() !== 'darwin') return;
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
const version = require('../package.json').version;
|
|
62
|
+
const appDir = path.join(os.homedir(), 'Applications');
|
|
63
|
+
const appPath = path.join(appDir, 'A2A Callbook.app');
|
|
64
|
+
|
|
65
|
+
// Skip if already installed at same version
|
|
66
|
+
const plistPath = path.join(appPath, 'Contents', 'Info.plist');
|
|
67
|
+
if (fs.existsSync(plistPath)) {
|
|
68
|
+
try {
|
|
69
|
+
const plist = fs.readFileSync(plistPath, 'utf8');
|
|
70
|
+
if (plist.includes(version)) {
|
|
71
|
+
return; // Same version already installed
|
|
72
|
+
}
|
|
73
|
+
} catch (_) {}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const tarUrl = `https://github.com/onthegonow/a2a_calling/releases/download/v${version}/A2A-Callbook-${version}.app.tar.gz`;
|
|
77
|
+
const tmpFile = path.join(os.tmpdir(), `a2a-callbook-${version}.tar.gz`);
|
|
78
|
+
|
|
79
|
+
// Download
|
|
80
|
+
const { execFileSync } = require('child_process');
|
|
81
|
+
execFileSync('curl', ['-sL', '-o', tmpFile, tarUrl], { timeout: 30000 });
|
|
82
|
+
|
|
83
|
+
if (!fs.existsSync(tmpFile) || fs.statSync(tmpFile).size < 1000) {
|
|
84
|
+
return; // Download failed or too small — skip silently
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Ensure ~/Applications exists
|
|
88
|
+
fs.mkdirSync(appDir, { recursive: true });
|
|
89
|
+
|
|
90
|
+
// Extract
|
|
91
|
+
execFileSync('tar', ['-xzf', tmpFile, '-C', appDir], { timeout: 15000 });
|
|
92
|
+
|
|
93
|
+
// Cleanup
|
|
94
|
+
try { fs.unlinkSync(tmpFile); } catch (_) {}
|
|
95
|
+
} catch (_) {
|
|
96
|
+
// Silently fail — native app is optional
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -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
|
+
};
|