capacitor-mobile-claw 1.0.0
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/LICENSE +21 -0
- package/README.md +231 -0
- package/dist/esm/definitions.d.ts +226 -0
- package/dist/esm/definitions.d.ts.map +1 -0
- package/dist/esm/definitions.js +16 -0
- package/dist/esm/definitions.js.map +1 -0
- package/dist/esm/engine.d.ts +93 -0
- package/dist/esm/engine.d.ts.map +1 -0
- package/dist/esm/engine.js +332 -0
- package/dist/esm/engine.js.map +1 -0
- package/dist/esm/index.d.ts +24 -0
- package/dist/esm/index.d.ts.map +1 -0
- package/dist/esm/index.js +25 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/mcp/mcp-server-manager.d.ts +33 -0
- package/dist/esm/mcp/mcp-server-manager.d.ts.map +1 -0
- package/dist/esm/mcp/mcp-server-manager.js +99 -0
- package/dist/esm/mcp/mcp-server-manager.js.map +1 -0
- package/dist/esm/mcp/server/device-mcp-server.d.ts +43 -0
- package/dist/esm/mcp/server/device-mcp-server.d.ts.map +1 -0
- package/dist/esm/mcp/server/device-mcp-server.js +52 -0
- package/dist/esm/mcp/server/device-mcp-server.js.map +1 -0
- package/dist/esm/mcp/tools/app-state.tools.d.ts +3 -0
- package/dist/esm/mcp/tools/app-state.tools.d.ts.map +1 -0
- package/dist/esm/mcp/tools/app-state.tools.js +22 -0
- package/dist/esm/mcp/tools/app-state.tools.js.map +1 -0
- package/dist/esm/mcp/tools/barcode.tools.d.ts +3 -0
- package/dist/esm/mcp/tools/barcode.tools.d.ts.map +1 -0
- package/dist/esm/mcp/tools/barcode.tools.js +29 -0
- package/dist/esm/mcp/tools/barcode.tools.js.map +1 -0
- package/dist/esm/mcp/tools/biometric.tools.d.ts +3 -0
- package/dist/esm/mcp/tools/biometric.tools.d.ts.map +1 -0
- package/dist/esm/mcp/tools/biometric.tools.js +33 -0
- package/dist/esm/mcp/tools/biometric.tools.js.map +1 -0
- package/dist/esm/mcp/tools/bluetooth.tools.d.ts +3 -0
- package/dist/esm/mcp/tools/bluetooth.tools.d.ts.map +1 -0
- package/dist/esm/mcp/tools/bluetooth.tools.js +95 -0
- package/dist/esm/mcp/tools/bluetooth.tools.js.map +1 -0
- package/dist/esm/mcp/tools/camera.tools.d.ts +3 -0
- package/dist/esm/mcp/tools/camera.tools.d.ts.map +1 -0
- package/dist/esm/mcp/tools/camera.tools.js +36 -0
- package/dist/esm/mcp/tools/camera.tools.js.map +1 -0
- package/dist/esm/mcp/tools/categories.d.ts +22 -0
- package/dist/esm/mcp/tools/categories.d.ts.map +1 -0
- package/dist/esm/mcp/tools/categories.js +242 -0
- package/dist/esm/mcp/tools/categories.js.map +1 -0
- package/dist/esm/mcp/tools/clipboard.tools.d.ts +3 -0
- package/dist/esm/mcp/tools/clipboard.tools.d.ts.map +1 -0
- package/dist/esm/mcp/tools/clipboard.tools.js +22 -0
- package/dist/esm/mcp/tools/clipboard.tools.js.map +1 -0
- package/dist/esm/mcp/tools/contacts.tools.d.ts +3 -0
- package/dist/esm/mcp/tools/contacts.tools.d.ts.map +1 -0
- package/dist/esm/mcp/tools/contacts.tools.js +58 -0
- package/dist/esm/mcp/tools/contacts.tools.js.map +1 -0
- package/dist/esm/mcp/tools/device-info.tools.d.ts +3 -0
- package/dist/esm/mcp/tools/device-info.tools.d.ts.map +1 -0
- package/dist/esm/mcp/tools/device-info.tools.js +39 -0
- package/dist/esm/mcp/tools/device-info.tools.js.map +1 -0
- package/dist/esm/mcp/tools/discovery.tools.d.ts +3 -0
- package/dist/esm/mcp/tools/discovery.tools.d.ts.map +1 -0
- package/dist/esm/mcp/tools/discovery.tools.js +41 -0
- package/dist/esm/mcp/tools/discovery.tools.js.map +1 -0
- package/dist/esm/mcp/tools/geolocation.tools.d.ts +3 -0
- package/dist/esm/mcp/tools/geolocation.tools.d.ts.map +1 -0
- package/dist/esm/mcp/tools/geolocation.tools.js +25 -0
- package/dist/esm/mcp/tools/geolocation.tools.js.map +1 -0
- package/dist/esm/mcp/tools/haptics.tools.d.ts +3 -0
- package/dist/esm/mcp/tools/haptics.tools.d.ts.map +1 -0
- package/dist/esm/mcp/tools/haptics.tools.js +41 -0
- package/dist/esm/mcp/tools/haptics.tools.js.map +1 -0
- package/dist/esm/mcp/tools/health.tools.d.ts +3 -0
- package/dist/esm/mcp/tools/health.tools.d.ts.map +1 -0
- package/dist/esm/mcp/tools/health.tools.js +38 -0
- package/dist/esm/mcp/tools/health.tools.js.map +1 -0
- package/dist/esm/mcp/tools/http.tools.d.ts +3 -0
- package/dist/esm/mcp/tools/http.tools.d.ts.map +1 -0
- package/dist/esm/mcp/tools/http.tools.js +24 -0
- package/dist/esm/mcp/tools/http.tools.js.map +1 -0
- package/dist/esm/mcp/tools/index.d.ts +10 -0
- package/dist/esm/mcp/tools/index.d.ts.map +1 -0
- package/dist/esm/mcp/tools/index.js +70 -0
- package/dist/esm/mcp/tools/index.js.map +1 -0
- package/dist/esm/mcp/tools/keep-awake.tools.d.ts +3 -0
- package/dist/esm/mcp/tools/keep-awake.tools.d.ts.map +1 -0
- package/dist/esm/mcp/tools/keep-awake.tools.js +23 -0
- package/dist/esm/mcp/tools/keep-awake.tools.js.map +1 -0
- package/dist/esm/mcp/tools/motion-sensors.tools.d.ts +3 -0
- package/dist/esm/mcp/tools/motion-sensors.tools.d.ts.map +1 -0
- package/dist/esm/mcp/tools/motion-sensors.tools.js +60 -0
- package/dist/esm/mcp/tools/motion-sensors.tools.js.map +1 -0
- package/dist/esm/mcp/tools/network-status.tools.d.ts +3 -0
- package/dist/esm/mcp/tools/network-status.tools.d.ts.map +1 -0
- package/dist/esm/mcp/tools/network-status.tools.js +16 -0
- package/dist/esm/mcp/tools/network-status.tools.js.map +1 -0
- package/dist/esm/mcp/tools/nfc.tools.d.ts +3 -0
- package/dist/esm/mcp/tools/nfc.tools.d.ts.map +1 -0
- package/dist/esm/mcp/tools/nfc.tools.js +52 -0
- package/dist/esm/mcp/tools/nfc.tools.js.map +1 -0
- package/dist/esm/mcp/tools/notifications.tools.d.ts +3 -0
- package/dist/esm/mcp/tools/notifications.tools.d.ts.map +1 -0
- package/dist/esm/mcp/tools/notifications.tools.js +30 -0
- package/dist/esm/mcp/tools/notifications.tools.js.map +1 -0
- package/dist/esm/mcp/tools/push-notifications.tools.d.ts +3 -0
- package/dist/esm/mcp/tools/push-notifications.tools.d.ts.map +1 -0
- package/dist/esm/mcp/tools/push-notifications.tools.js +43 -0
- package/dist/esm/mcp/tools/push-notifications.tools.js.map +1 -0
- package/dist/esm/mcp/tools/secure-storage.tools.d.ts +3 -0
- package/dist/esm/mcp/tools/secure-storage.tools.d.ts.map +1 -0
- package/dist/esm/mcp/tools/secure-storage.tools.js +31 -0
- package/dist/esm/mcp/tools/secure-storage.tools.js.map +1 -0
- package/dist/esm/mcp/tools/sftp.tools.d.ts +3 -0
- package/dist/esm/mcp/tools/sftp.tools.d.ts.map +1 -0
- package/dist/esm/mcp/tools/sftp.tools.js +41 -0
- package/dist/esm/mcp/tools/sftp.tools.js.map +1 -0
- package/dist/esm/mcp/tools/share.tools.d.ts +3 -0
- package/dist/esm/mcp/tools/share.tools.d.ts.map +1 -0
- package/dist/esm/mcp/tools/share.tools.js +18 -0
- package/dist/esm/mcp/tools/share.tools.js.map +1 -0
- package/dist/esm/mcp/tools/speech.tools.d.ts +3 -0
- package/dist/esm/mcp/tools/speech.tools.d.ts.map +1 -0
- package/dist/esm/mcp/tools/speech.tools.js +38 -0
- package/dist/esm/mcp/tools/speech.tools.js.map +1 -0
- package/dist/esm/mcp/tools/ssh.tools.d.ts +3 -0
- package/dist/esm/mcp/tools/ssh.tools.d.ts.map +1 -0
- package/dist/esm/mcp/tools/ssh.tools.js +41 -0
- package/dist/esm/mcp/tools/ssh.tools.js.map +1 -0
- package/dist/esm/mcp/tools/tcp.tools.d.ts +3 -0
- package/dist/esm/mcp/tools/tcp.tools.d.ts.map +1 -0
- package/dist/esm/mcp/tools/tcp.tools.js +47 -0
- package/dist/esm/mcp/tools/tcp.tools.js.map +1 -0
- package/dist/esm/mcp/tools/tts.tools.d.ts +3 -0
- package/dist/esm/mcp/tools/tts.tools.d.ts.map +1 -0
- package/dist/esm/mcp/tools/tts.tools.js +29 -0
- package/dist/esm/mcp/tools/tts.tools.js.map +1 -0
- package/dist/esm/mcp/tools/types.d.ts +18 -0
- package/dist/esm/mcp/tools/types.d.ts.map +1 -0
- package/dist/esm/mcp/tools/types.js +2 -0
- package/dist/esm/mcp/tools/types.js.map +1 -0
- package/dist/esm/mcp/tools/udp.tools.d.ts +3 -0
- package/dist/esm/mcp/tools/udp.tools.d.ts.map +1 -0
- package/dist/esm/mcp/tools/udp.tools.js +22 -0
- package/dist/esm/mcp/tools/udp.tools.js.map +1 -0
- package/dist/esm/mcp/tools/wol.tools.d.ts +3 -0
- package/dist/esm/mcp/tools/wol.tools.d.ts.map +1 -0
- package/dist/esm/mcp/tools/wol.tools.js +16 -0
- package/dist/esm/mcp/tools/wol.tools.js.map +1 -0
- package/dist/esm/mcp/transport/bridge-server-transport.d.ts +27 -0
- package/dist/esm/mcp/transport/bridge-server-transport.d.ts.map +1 -0
- package/dist/esm/mcp/transport/bridge-server-transport.js +72 -0
- package/dist/esm/mcp/transport/bridge-server-transport.js.map +1 -0
- package/dist/esm/mcp/transport/stomp-server-transport.d.ts +43 -0
- package/dist/esm/mcp/transport/stomp-server-transport.d.ts.map +1 -0
- package/dist/esm/mcp/transport/stomp-server-transport.js +128 -0
- package/dist/esm/mcp/transport/stomp-server-transport.js.map +1 -0
- package/dist/esm/mcp/transport/transport-manager.d.ts +38 -0
- package/dist/esm/mcp/transport/transport-manager.d.ts.map +1 -0
- package/dist/esm/mcp/transport/transport-manager.js +70 -0
- package/dist/esm/mcp/transport/transport-manager.js.map +1 -0
- package/dist/esm/plugin.d.ts +79 -0
- package/dist/esm/plugin.d.ts.map +1 -0
- package/dist/esm/plugin.js +81 -0
- package/dist/esm/plugin.js.map +1 -0
- package/dist/esm/services/bridge-protocol.d.ts +107 -0
- package/dist/esm/services/bridge-protocol.d.ts.map +1 -0
- package/dist/esm/services/bridge-protocol.js +6 -0
- package/dist/esm/services/bridge-protocol.js.map +1 -0
- package/nodejs-assets/nodejs-project/main.js +1971 -0
- package/nodejs-assets/nodejs-project/mcp-agent-tools.js +57 -0
- package/nodejs-assets/nodejs-project/mcp-bridge-client.js +98 -0
- package/nodejs-assets/nodejs-project/package-lock.json +3639 -0
- package/nodejs-assets/nodejs-project/package.json +18 -0
- package/package.json +80 -0
|
@@ -0,0 +1,1971 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mobile-claw Node.js worker entry point.
|
|
3
|
+
*
|
|
4
|
+
* Runs inside the embedded Node.js runtime provided by Capacitor-NodeJS.
|
|
5
|
+
* Communicates with the Capacitor WebView UI via the bridge channel.
|
|
6
|
+
*
|
|
7
|
+
* Responsibilities:
|
|
8
|
+
* - Agent orchestration (pi-agent-core Agent class)
|
|
9
|
+
* - LLM API calls (pi-ai streaming)
|
|
10
|
+
* - File tools (read/write/edit/find/grep/ls)
|
|
11
|
+
* - Code execution (JS VM sandbox + Python Pyodide sandbox)
|
|
12
|
+
* - Git tools (isomorphic-git)
|
|
13
|
+
* - Approval gates (tool.approve / tool.approval_request)
|
|
14
|
+
* - Session management (JSONL transcripts)
|
|
15
|
+
* - Auth profile management (auth-profiles.json)
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
// ── Node.js v18 polyfills (Capacitor-NodeJS ships v18.20.4) ──────────────
|
|
19
|
+
// undici and Anthropic SDK expect globals that only exist in Node.js >=20.
|
|
20
|
+
import { Blob } from 'node:buffer';
|
|
21
|
+
if (typeof globalThis.File === 'undefined') {
|
|
22
|
+
globalThis.File = class File extends Blob {
|
|
23
|
+
#name; #lastModified;
|
|
24
|
+
constructor(bits, name, opts = {}) {
|
|
25
|
+
super(bits, opts);
|
|
26
|
+
this.#name = name;
|
|
27
|
+
this.#lastModified = opts.lastModified ?? Date.now();
|
|
28
|
+
}
|
|
29
|
+
get name() { return this.#name; }
|
|
30
|
+
get lastModified() { return this.#lastModified; }
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
if (typeof globalThis.FormData === 'undefined') {
|
|
34
|
+
// Minimal FormData polyfill — undici needs it at module load time
|
|
35
|
+
const entries = Symbol('entries');
|
|
36
|
+
globalThis.FormData = class FormData {
|
|
37
|
+
constructor() { this[entries] = []; }
|
|
38
|
+
append(k, v, f) { this[entries].push([k, v, f]); }
|
|
39
|
+
get(k) { const e = this[entries].find(([n]) => n === k); return e ? e[1] : null; }
|
|
40
|
+
getAll(k) { return this[entries].filter(([n]) => n === k).map(e => e[1]); }
|
|
41
|
+
has(k) { return this[entries].some(([n]) => n === k); }
|
|
42
|
+
delete(k) { this[entries] = this[entries].filter(([n]) => n !== k); }
|
|
43
|
+
*[Symbol.iterator]() { yield* this[entries]; }
|
|
44
|
+
forEach(cb) { this[entries].forEach(([k, v]) => cb(v, k, this)); }
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// bridge is a builtin module injected by Capacitor-NodeJS via NODE_PATH.
|
|
49
|
+
// ESM resolution ignores NODE_PATH (Node.js v18), so use createRequire to load it.
|
|
50
|
+
import { createRequire } from 'node:module';
|
|
51
|
+
const _require = createRequire(import.meta.url);
|
|
52
|
+
const { channel } = _require('bridge');
|
|
53
|
+
|
|
54
|
+
import { resolve, join } from 'node:path';
|
|
55
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
56
|
+
|
|
57
|
+
// ── MCP bridge (device tools from WebView) ────────────────────────────────
|
|
58
|
+
import { initMcpBridge } from './mcp-bridge-client.js';
|
|
59
|
+
import { buildMcpAgentTools } from './mcp-agent-tools.js';
|
|
60
|
+
|
|
61
|
+
const mcpBridge = initMcpBridge(channel);
|
|
62
|
+
|
|
63
|
+
// Cache for discovered MCP tools — avoids re-discovery on every agent run
|
|
64
|
+
let cachedMcpTools = null;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Discover MCP device tools from the WebView server.
|
|
68
|
+
* Returns AgentTool[] or empty array if bridge is not available.
|
|
69
|
+
* Uses a 3-second timeout for graceful degradation.
|
|
70
|
+
*/
|
|
71
|
+
async function discoverMcpTools() {
|
|
72
|
+
if (cachedMcpTools) return cachedMcpTools;
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
const mcpToolDescriptors = await Promise.race([
|
|
76
|
+
mcpBridge.listTools(),
|
|
77
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 10_000)),
|
|
78
|
+
]);
|
|
79
|
+
|
|
80
|
+
cachedMcpTools = buildMcpAgentTools(mcpBridge, mcpToolDescriptors);
|
|
81
|
+
console.log(`[mobile-claw] Discovered ${cachedMcpTools.length} MCP device tools`);
|
|
82
|
+
return cachedMcpTools;
|
|
83
|
+
} catch (err) {
|
|
84
|
+
console.warn(`[mobile-claw] MCP bridge not available, using local tools only: ${err.message}`);
|
|
85
|
+
return [];
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ── OpenClaw filesystem paths ─────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
// On mobile, the app sandbox provides a writable documents directory.
|
|
92
|
+
// Capacitor-NodeJS sets DATADIR to /data/data/<package>/files/nodejs/data
|
|
93
|
+
const APP_DATA_DIR = process.env.DATADIR
|
|
94
|
+
|| process.env.CAPACITOR_DATA_DIR
|
|
95
|
+
|| join(process.env.HOME || '/data', '.openclaw');
|
|
96
|
+
|
|
97
|
+
const OPENCLAW_ROOT = APP_DATA_DIR;
|
|
98
|
+
|
|
99
|
+
// Ensure directory structure exists
|
|
100
|
+
function ensureOpenClawDirs() {
|
|
101
|
+
const dirs = [
|
|
102
|
+
OPENCLAW_ROOT,
|
|
103
|
+
join(OPENCLAW_ROOT, 'agents', 'main', 'agent'),
|
|
104
|
+
join(OPENCLAW_ROOT, 'agents', 'main', 'sessions'),
|
|
105
|
+
join(OPENCLAW_ROOT, 'workspace'),
|
|
106
|
+
join(OPENCLAW_ROOT, 'workspace', '.openclaw'),
|
|
107
|
+
];
|
|
108
|
+
|
|
109
|
+
for (const dir of dirs) {
|
|
110
|
+
if (!existsSync(dir)) {
|
|
111
|
+
mkdirSync(dir, { recursive: true });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Create default openclaw.json if it doesn't exist
|
|
116
|
+
const configPath = join(OPENCLAW_ROOT, 'openclaw.json');
|
|
117
|
+
if (!existsSync(configPath)) {
|
|
118
|
+
writeFileSync(configPath, JSON.stringify({
|
|
119
|
+
gateway: { port: 18789 },
|
|
120
|
+
agents: {
|
|
121
|
+
defaults: {
|
|
122
|
+
model: { primary: 'anthropic/claude-sonnet-4-5' },
|
|
123
|
+
},
|
|
124
|
+
list: [
|
|
125
|
+
{
|
|
126
|
+
id: 'main',
|
|
127
|
+
default: true,
|
|
128
|
+
workspace: join(OPENCLAW_ROOT, 'workspace'),
|
|
129
|
+
},
|
|
130
|
+
],
|
|
131
|
+
},
|
|
132
|
+
models: {
|
|
133
|
+
providers: {
|
|
134
|
+
anthropic: {
|
|
135
|
+
models: [{ id: 'claude-sonnet-4-5' }],
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
}, null, 2));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Create default workspace files if they don't exist
|
|
143
|
+
const defaultFiles = {
|
|
144
|
+
'IDENTITY.md': `# Identity
|
|
145
|
+
|
|
146
|
+
Name: Claw
|
|
147
|
+
Role: On-device AI assistant
|
|
148
|
+
Platform: Mobile (Android)
|
|
149
|
+
|
|
150
|
+
You are **Claw**, an AI assistant that runs directly on the user's mobile device.
|
|
151
|
+
You are NOT Claude. You are Claw — a distinct agent powered by Anthropic's language model.
|
|
152
|
+
When asked who you are, always identify yourself as Claw.
|
|
153
|
+
|
|
154
|
+
## Capabilities
|
|
155
|
+
- Read, write, and edit files in the workspace
|
|
156
|
+
- Search files with grep and glob patterns
|
|
157
|
+
- Execute JavaScript and Python code in sandboxed environments
|
|
158
|
+
- Manage git repositories (init, add, commit, log, diff)
|
|
159
|
+
- Maintain persistent memory across conversations
|
|
160
|
+
|
|
161
|
+
## Behavior
|
|
162
|
+
- Be concise and direct — this is a mobile device with limited screen space
|
|
163
|
+
- Prefer short responses unless the user asks for detail
|
|
164
|
+
- When using tools, explain what you're doing briefly
|
|
165
|
+
- Ask for approval before writing files, editing code, or committing — the user sees an approval prompt
|
|
166
|
+
`,
|
|
167
|
+
'SOUL.md': `# Soul
|
|
168
|
+
|
|
169
|
+
You are a capable, resourceful assistant that helps users work with files, code, and projects on their mobile device.
|
|
170
|
+
|
|
171
|
+
## Core Principles
|
|
172
|
+
- **Accuracy over speed**: Read files before editing. Understand before acting.
|
|
173
|
+
- **Transparency**: When you use a tool, say what you're doing and why.
|
|
174
|
+
- **Respect the workspace**: Don't create unnecessary files. Don't modify what wasn't asked.
|
|
175
|
+
- **Security first**: Never execute code that could harm the device. Sandbox execution exists for safety.
|
|
176
|
+
- **Mobile-aware**: Keep responses concise. The user is on a phone.
|
|
177
|
+
|
|
178
|
+
## Tool Usage Guidelines
|
|
179
|
+
- Use \`read_file\` before \`edit_file\` — understand what's there first
|
|
180
|
+
- Use \`list_files\` to explore before assuming file locations
|
|
181
|
+
- Use \`grep_files\` to find specific content across the workspace
|
|
182
|
+
- Always explain file modifications before making them
|
|
183
|
+
- For code execution, prefer JavaScript unless Python is specifically needed
|
|
184
|
+
`,
|
|
185
|
+
'MEMORY.md': `# Memory
|
|
186
|
+
|
|
187
|
+
Persistent knowledge base. Claw updates this file to remember important context across conversations.
|
|
188
|
+
|
|
189
|
+
## Workspace
|
|
190
|
+
- Fresh workspace, no project loaded yet
|
|
191
|
+
|
|
192
|
+
## User Preferences
|
|
193
|
+
- (none recorded yet)
|
|
194
|
+
`,
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
for (const [filename, content] of Object.entries(defaultFiles)) {
|
|
198
|
+
const filePath = join(OPENCLAW_ROOT, 'workspace', filename);
|
|
199
|
+
if (!existsSync(filePath)) {
|
|
200
|
+
writeFileSync(filePath, content);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Create default auth-profiles.json if it doesn't exist
|
|
205
|
+
const authPath = join(OPENCLAW_ROOT, 'agents', 'main', 'agent', 'auth-profiles.json');
|
|
206
|
+
if (!existsSync(authPath)) {
|
|
207
|
+
writeFileSync(authPath, JSON.stringify({
|
|
208
|
+
version: 1,
|
|
209
|
+
profiles: {},
|
|
210
|
+
lastGood: {},
|
|
211
|
+
usageStats: {},
|
|
212
|
+
}, null, 2));
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Create default sessions.json if it doesn't exist
|
|
216
|
+
const sessionsPath = join(OPENCLAW_ROOT, 'agents', 'main', 'sessions', 'sessions.json');
|
|
217
|
+
if (!existsSync(sessionsPath)) {
|
|
218
|
+
writeFileSync(sessionsPath, JSON.stringify({}));
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ── Auth profile helpers ──────────────────────────────────────────────────
|
|
223
|
+
|
|
224
|
+
function loadAuthProfiles(agentId = 'main') {
|
|
225
|
+
const path = join(OPENCLAW_ROOT, 'agents', agentId, 'agent', 'auth-profiles.json');
|
|
226
|
+
try {
|
|
227
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
228
|
+
} catch {
|
|
229
|
+
return { version: 1, profiles: {}, lastGood: {}, usageStats: {} };
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function saveAuthProfiles(agentId, profiles) {
|
|
234
|
+
const filePath = join(OPENCLAW_ROOT, 'agents', agentId, 'agent', 'auth-profiles.json');
|
|
235
|
+
const tmpPath = filePath + '.tmp';
|
|
236
|
+
writeFileSync(tmpPath, JSON.stringify(profiles, null, 2));
|
|
237
|
+
renameSync(tmpPath, filePath);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function resolveApiKey(authProfiles) {
|
|
241
|
+
// Prefer lastGood profile if set
|
|
242
|
+
const lastGoodKey = authProfiles.lastGood?.anthropic;
|
|
243
|
+
if (lastGoodKey && authProfiles.profiles[lastGoodKey]) {
|
|
244
|
+
const p = authProfiles.profiles[lastGoodKey];
|
|
245
|
+
if (p.type === 'oauth' && p.access) return p.access;
|
|
246
|
+
if (p.type === 'api_key' && p.key) return p.key;
|
|
247
|
+
}
|
|
248
|
+
// Fallback: prefer OAuth over api_key
|
|
249
|
+
let fallbackApiKey = null;
|
|
250
|
+
for (const [, profile] of Object.entries(authProfiles.profiles)) {
|
|
251
|
+
if (profile.provider === 'anthropic' && profile.type === 'oauth' && profile.access) {
|
|
252
|
+
return profile.access;
|
|
253
|
+
}
|
|
254
|
+
if (profile.provider === 'anthropic' && profile.type === 'api_key' && profile.key && !fallbackApiKey) {
|
|
255
|
+
fallbackApiKey = profile.key;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return fallbackApiKey;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async function refreshOAuthTokenIfNeeded(agentId = 'main') {
|
|
262
|
+
const profiles = loadAuthProfiles(agentId);
|
|
263
|
+
for (const [key, profile] of Object.entries(profiles.profiles)) {
|
|
264
|
+
if (profile.provider !== 'anthropic' || profile.type !== 'oauth') continue;
|
|
265
|
+
if (!profile.refresh || !profile.expiresAt) continue;
|
|
266
|
+
const remaining = profile.expiresAt - Date.now();
|
|
267
|
+
if (remaining > 3600000) continue; // >1h remaining, no refresh needed
|
|
268
|
+
console.log(`[auth] OAuth token expires in ${(remaining/3600000).toFixed(1)}h — refreshing...`);
|
|
269
|
+
try {
|
|
270
|
+
const resp = await fetch('https://platform.claude.com/v1/oauth/token', {
|
|
271
|
+
method: 'POST',
|
|
272
|
+
headers: {
|
|
273
|
+
'Content-Type': 'application/json',
|
|
274
|
+
'anthropic-version': 'oauth-2025-04-20',
|
|
275
|
+
'User-Agent': 'mobile-claw/1.0.0',
|
|
276
|
+
},
|
|
277
|
+
body: JSON.stringify({
|
|
278
|
+
grant_type: 'refresh_token',
|
|
279
|
+
refresh_token: profile.refresh,
|
|
280
|
+
client_id: '9d1c250a-e61b-44d9-88ed-5944d1962f5e',
|
|
281
|
+
}),
|
|
282
|
+
});
|
|
283
|
+
if (!resp.ok) {
|
|
284
|
+
console.warn(`[auth] OAuth refresh failed: ${resp.status}`);
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
const data = await resp.json();
|
|
288
|
+
profile.access = data.access_token;
|
|
289
|
+
profile.refresh = data.refresh_token;
|
|
290
|
+
profile.expiresAt = Date.now() + (data.expires_in || 28800) * 1000;
|
|
291
|
+
saveAuthProfiles(agentId, profiles);
|
|
292
|
+
console.log(`[auth] OAuth token refreshed, expires in ${((data.expires_in || 28800)/3600).toFixed(1)}h`);
|
|
293
|
+
} catch (err) {
|
|
294
|
+
console.warn(`[auth] OAuth refresh error: ${err.message}`);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// ── File tools ────────────────────────────────────────────────────────────
|
|
300
|
+
|
|
301
|
+
import {
|
|
302
|
+
readdirSync,
|
|
303
|
+
renameSync,
|
|
304
|
+
statSync,
|
|
305
|
+
} from 'node:fs';
|
|
306
|
+
import * as nodeFs from 'node:fs';
|
|
307
|
+
import vm from 'node:vm';
|
|
308
|
+
|
|
309
|
+
const SKIP_DIRS = new Set(['.git', '.openclaw', 'node_modules']);
|
|
310
|
+
|
|
311
|
+
function readFileTool(args) {
|
|
312
|
+
const filePath = resolve(join(OPENCLAW_ROOT, 'workspace'), args.path);
|
|
313
|
+
// Prevent path traversal outside workspace
|
|
314
|
+
if (!filePath.startsWith(resolve(join(OPENCLAW_ROOT, 'workspace')))) {
|
|
315
|
+
return { error: 'Access denied: path outside workspace' };
|
|
316
|
+
}
|
|
317
|
+
try {
|
|
318
|
+
const content = readFileSync(filePath, 'utf8');
|
|
319
|
+
return { content };
|
|
320
|
+
} catch (err) {
|
|
321
|
+
return { error: `Failed to read file: ${err.message}` };
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function writeFileTool(args) {
|
|
326
|
+
const filePath = resolve(join(OPENCLAW_ROOT, 'workspace'), args.path);
|
|
327
|
+
if (!filePath.startsWith(resolve(join(OPENCLAW_ROOT, 'workspace')))) {
|
|
328
|
+
return { error: 'Access denied: path outside workspace' };
|
|
329
|
+
}
|
|
330
|
+
try {
|
|
331
|
+
const dir = filePath.substring(0, filePath.lastIndexOf('/'));
|
|
332
|
+
mkdirSync(dir, { recursive: true });
|
|
333
|
+
writeFileSync(filePath, args.content);
|
|
334
|
+
return { success: true, path: args.path };
|
|
335
|
+
} catch (err) {
|
|
336
|
+
return { error: `Failed to write file: ${err.message}` };
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function listFilesTool(args) {
|
|
341
|
+
const dirPath = resolve(join(OPENCLAW_ROOT, 'workspace'), args.path || '.');
|
|
342
|
+
if (!dirPath.startsWith(resolve(join(OPENCLAW_ROOT, 'workspace')))) {
|
|
343
|
+
return { error: 'Access denied: path outside workspace' };
|
|
344
|
+
}
|
|
345
|
+
try {
|
|
346
|
+
const entries = readdirSync(dirPath, { withFileTypes: true })
|
|
347
|
+
.filter((e) => !SKIP_DIRS.has(e.name));
|
|
348
|
+
return {
|
|
349
|
+
entries: entries.map((e) => ({
|
|
350
|
+
name: e.name,
|
|
351
|
+
type: e.isDirectory() ? 'directory' : 'file',
|
|
352
|
+
size: e.isFile() ? statSync(join(dirPath, e.name)).size : undefined,
|
|
353
|
+
})),
|
|
354
|
+
};
|
|
355
|
+
} catch (err) {
|
|
356
|
+
return { error: `Failed to list directory: ${err.message}` };
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// ── Enhanced file tools (Phase 3) ────────────────────────────────────────
|
|
361
|
+
|
|
362
|
+
function globToRegex(glob) {
|
|
363
|
+
const escaped = glob
|
|
364
|
+
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
365
|
+
.replace(/\*/g, '.*')
|
|
366
|
+
.replace(/\?/g, '.');
|
|
367
|
+
return new RegExp(`^${escaped}$`, 'i');
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function grepFilesTool(args) {
|
|
371
|
+
const WORKSPACE = resolve(join(OPENCLAW_ROOT, 'workspace'));
|
|
372
|
+
const searchPath = resolve(WORKSPACE, args.path || '.');
|
|
373
|
+
if (!searchPath.startsWith(WORKSPACE)) {
|
|
374
|
+
return { error: 'Access denied: path outside workspace' };
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
try {
|
|
378
|
+
const flags = args.case_insensitive ? 'gi' : 'g';
|
|
379
|
+
const regex = new RegExp(args.pattern, flags);
|
|
380
|
+
const matches = [];
|
|
381
|
+
const MAX_MATCHES = 200;
|
|
382
|
+
|
|
383
|
+
function searchDir(dirPath) {
|
|
384
|
+
if (matches.length >= MAX_MATCHES) return;
|
|
385
|
+
const entries = readdirSync(dirPath, { withFileTypes: true });
|
|
386
|
+
for (const entry of entries) {
|
|
387
|
+
if (matches.length >= MAX_MATCHES) return;
|
|
388
|
+
if (SKIP_DIRS.has(entry.name)) continue;
|
|
389
|
+
const fullPath = join(dirPath, entry.name);
|
|
390
|
+
if (entry.isDirectory()) {
|
|
391
|
+
searchDir(fullPath);
|
|
392
|
+
} else if (entry.isFile()) {
|
|
393
|
+
try {
|
|
394
|
+
const content = readFileSync(fullPath, 'utf8');
|
|
395
|
+
const lines = content.split('\n');
|
|
396
|
+
for (let i = 0; i < lines.length; i++) {
|
|
397
|
+
if (matches.length >= MAX_MATCHES) break;
|
|
398
|
+
regex.lastIndex = 0;
|
|
399
|
+
if (regex.test(lines[i])) {
|
|
400
|
+
const relPath = fullPath.substring(WORKSPACE.length + 1);
|
|
401
|
+
matches.push({
|
|
402
|
+
file: relPath,
|
|
403
|
+
line: i + 1,
|
|
404
|
+
content: lines[i].substring(0, 500),
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
} catch {
|
|
409
|
+
// skip binary or unreadable files
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
try {
|
|
416
|
+
if (statSync(searchPath).isFile()) {
|
|
417
|
+
const content = readFileSync(searchPath, 'utf8');
|
|
418
|
+
const lines = content.split('\n');
|
|
419
|
+
const relPath = searchPath.substring(WORKSPACE.length + 1);
|
|
420
|
+
const regex2 = new RegExp(args.pattern, flags);
|
|
421
|
+
for (let i = 0; i < lines.length && matches.length < MAX_MATCHES; i++) {
|
|
422
|
+
regex2.lastIndex = 0;
|
|
423
|
+
if (regex2.test(lines[i])) {
|
|
424
|
+
matches.push({ file: relPath, line: i + 1, content: lines[i].substring(0, 500) });
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
} else {
|
|
428
|
+
searchDir(searchPath);
|
|
429
|
+
}
|
|
430
|
+
} catch {
|
|
431
|
+
searchDir(searchPath);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
return { matches, total: matches.length };
|
|
435
|
+
} catch (err) {
|
|
436
|
+
return { error: `Search failed: ${err.message}` };
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function findFilesTool(args) {
|
|
441
|
+
const WORKSPACE = resolve(join(OPENCLAW_ROOT, 'workspace'));
|
|
442
|
+
const searchPath = resolve(WORKSPACE, args.path || '.');
|
|
443
|
+
if (!searchPath.startsWith(WORKSPACE)) {
|
|
444
|
+
return { error: 'Access denied: path outside workspace' };
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
try {
|
|
448
|
+
const results = [];
|
|
449
|
+
const pattern = globToRegex(args.pattern);
|
|
450
|
+
const MAX_RESULTS = 200;
|
|
451
|
+
|
|
452
|
+
function searchDir(dirPath) {
|
|
453
|
+
if (results.length >= MAX_RESULTS) return;
|
|
454
|
+
const entries = readdirSync(dirPath, { withFileTypes: true });
|
|
455
|
+
for (const entry of entries) {
|
|
456
|
+
if (results.length >= MAX_RESULTS) return;
|
|
457
|
+
if (SKIP_DIRS.has(entry.name)) continue;
|
|
458
|
+
const fullPath = join(dirPath, entry.name);
|
|
459
|
+
if (pattern.test(entry.name)) {
|
|
460
|
+
const relPath = fullPath.substring(WORKSPACE.length + 1);
|
|
461
|
+
results.push({
|
|
462
|
+
path: relPath,
|
|
463
|
+
type: entry.isDirectory() ? 'directory' : 'file',
|
|
464
|
+
size: entry.isFile() ? statSync(fullPath).size : undefined,
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
if (entry.isDirectory()) {
|
|
468
|
+
searchDir(fullPath);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
searchDir(searchPath);
|
|
474
|
+
return { files: results, total: results.length };
|
|
475
|
+
} catch (err) {
|
|
476
|
+
return { error: `Find failed: ${err.message}` };
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function editFileTool(args) {
|
|
481
|
+
const WORKSPACE = resolve(join(OPENCLAW_ROOT, 'workspace'));
|
|
482
|
+
const filePath = resolve(WORKSPACE, args.path);
|
|
483
|
+
if (!filePath.startsWith(WORKSPACE)) {
|
|
484
|
+
return { error: 'Access denied: path outside workspace' };
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
try {
|
|
488
|
+
const content = readFileSync(filePath, 'utf8');
|
|
489
|
+
const index = content.indexOf(args.old_text);
|
|
490
|
+
if (index === -1) {
|
|
491
|
+
return { error: 'old_text not found in file. Use read_file to verify the exact content.' };
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
const newContent = content.substring(0, index) + args.new_text + content.substring(index + args.old_text.length);
|
|
495
|
+
writeFileSync(filePath, newContent);
|
|
496
|
+
|
|
497
|
+
return { success: true, path: args.path, replacements: 1 };
|
|
498
|
+
} catch (err) {
|
|
499
|
+
return { error: `Failed to edit file: ${err.message}` };
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// ── Code execution (Phase 3) ─────────────────────────────────────────────
|
|
504
|
+
|
|
505
|
+
function executeJsTool(args) {
|
|
506
|
+
const stdoutLines = [];
|
|
507
|
+
const sandbox = {
|
|
508
|
+
console: {
|
|
509
|
+
log: (...a) => stdoutLines.push(a.map(String).join(' ')),
|
|
510
|
+
warn: (...a) => stdoutLines.push('[warn] ' + a.map(String).join(' ')),
|
|
511
|
+
error: (...a) => stdoutLines.push('[error] ' + a.map(String).join(' ')),
|
|
512
|
+
},
|
|
513
|
+
Math, Date, JSON, parseInt, parseFloat, isNaN, isFinite,
|
|
514
|
+
String, Number, Boolean, Array, Object, Map, Set, RegExp,
|
|
515
|
+
Error, TypeError, RangeError, Promise, Symbol,
|
|
516
|
+
process: undefined, require: undefined, global: undefined,
|
|
517
|
+
globalThis: undefined, Buffer: undefined,
|
|
518
|
+
setTimeout: undefined, setInterval: undefined,
|
|
519
|
+
};
|
|
520
|
+
const context = vm.createContext(sandbox);
|
|
521
|
+
try {
|
|
522
|
+
const result = vm.runInContext(args.code, context, { timeout: 5000, filename: 'sandbox.js' });
|
|
523
|
+
const stdout = stdoutLines.join('\n');
|
|
524
|
+
return {
|
|
525
|
+
stdout: stdout.substring(0, 50000),
|
|
526
|
+
result: result !== undefined ? String(result) : undefined,
|
|
527
|
+
};
|
|
528
|
+
} catch (err) {
|
|
529
|
+
return {
|
|
530
|
+
stdout: stdoutLines.join('\n').substring(0, 50000),
|
|
531
|
+
error: err.message || 'Execution failed',
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// ── Python execution (Phase 5) ───────────────────────────────────────────
|
|
537
|
+
|
|
538
|
+
import { loadPyodide } from 'pyodide';
|
|
539
|
+
|
|
540
|
+
let pyodideInstance = null;
|
|
541
|
+
|
|
542
|
+
async function getPyodide() {
|
|
543
|
+
if (!pyodideInstance) {
|
|
544
|
+
pyodideInstance = await loadPyodide();
|
|
545
|
+
// Block dangerous modules for sandbox security
|
|
546
|
+
pyodideInstance.runPython(`
|
|
547
|
+
import sys
|
|
548
|
+
for _mod in ['subprocess', 'socket', 'http', 'urllib', 'ftplib', 'smtplib',
|
|
549
|
+
'webbrowser', 'ctypes', 'multiprocessing', 'shutil', 'tempfile',
|
|
550
|
+
'signal', 'resource']:
|
|
551
|
+
sys.modules[_mod] = None
|
|
552
|
+
del _mod
|
|
553
|
+
`);
|
|
554
|
+
}
|
|
555
|
+
return pyodideInstance;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
async function executePythonTool(args) {
|
|
559
|
+
const stdoutLines = [];
|
|
560
|
+
const stderrLines = [];
|
|
561
|
+
|
|
562
|
+
try {
|
|
563
|
+
const pyodide = await getPyodide();
|
|
564
|
+
|
|
565
|
+
// Redirect stdout/stderr for this execution
|
|
566
|
+
pyodide.setStdout({ batched: (line) => stdoutLines.push(line) });
|
|
567
|
+
pyodide.setStderr({ batched: (line) => stderrLines.push('[stderr] ' + line) });
|
|
568
|
+
|
|
569
|
+
// Run with timeout (5 seconds, matching JS sandbox)
|
|
570
|
+
const result = await Promise.race([
|
|
571
|
+
pyodide.runPythonAsync(args.code),
|
|
572
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error('Execution timed out (5s)')), 5000)),
|
|
573
|
+
]);
|
|
574
|
+
|
|
575
|
+
const stdout = [...stdoutLines, ...stderrLines].join('\n');
|
|
576
|
+
return {
|
|
577
|
+
stdout: stdout.substring(0, 50000),
|
|
578
|
+
result: result !== undefined && result !== null ? String(result) : undefined,
|
|
579
|
+
};
|
|
580
|
+
} catch (err) {
|
|
581
|
+
const stdout = [...stdoutLines, ...stderrLines].join('\n');
|
|
582
|
+
return {
|
|
583
|
+
stdout: stdout.substring(0, 50000),
|
|
584
|
+
error: err.message || 'Python execution failed',
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// ── Git tools (Phase 3) ──────────────────────────────────────────────────
|
|
590
|
+
|
|
591
|
+
import git from 'isomorphic-git';
|
|
592
|
+
|
|
593
|
+
async function gitInitTool(args) {
|
|
594
|
+
const WORKSPACE = resolve(join(OPENCLAW_ROOT, 'workspace'));
|
|
595
|
+
const defaultBranch = args.default_branch || 'main';
|
|
596
|
+
try {
|
|
597
|
+
await git.init({ fs: nodeFs, dir: WORKSPACE, defaultBranch });
|
|
598
|
+
|
|
599
|
+
// Auto-create .gitignore if it doesn't exist
|
|
600
|
+
const gitignorePath = join(WORKSPACE, '.gitignore');
|
|
601
|
+
if (!existsSync(gitignorePath)) {
|
|
602
|
+
writeFileSync(gitignorePath, '.openclaw/\n');
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
return { success: true, message: `Initialized git repository with branch "${defaultBranch}"` };
|
|
606
|
+
} catch (err) {
|
|
607
|
+
return { error: `Failed to initialize git: ${err.message}` };
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
async function gitStatusTool() {
|
|
612
|
+
const WORKSPACE = resolve(join(OPENCLAW_ROOT, 'workspace'));
|
|
613
|
+
try {
|
|
614
|
+
const matrix = await git.statusMatrix({ fs: nodeFs, dir: WORKSPACE });
|
|
615
|
+
const files = matrix.map(([filepath, head, workdir, stage]) => {
|
|
616
|
+
let status = 'unmodified';
|
|
617
|
+
if (head === 0 && workdir === 2 && stage === 0) status = 'untracked';
|
|
618
|
+
else if (head === 0 && workdir === 2 && stage === 2) status = 'added';
|
|
619
|
+
else if (head === 0 && workdir === 2 && stage === 3) status = 'added (modified)';
|
|
620
|
+
else if (head === 1 && workdir === 2 && stage === 1) status = 'modified (unstaged)';
|
|
621
|
+
else if (head === 1 && workdir === 2 && stage === 2) status = 'modified (staged)';
|
|
622
|
+
else if (head === 1 && workdir === 2 && stage === 3) status = 'modified (staged + unstaged)';
|
|
623
|
+
else if (head === 1 && workdir === 0 && stage === 0) status = 'deleted (unstaged)';
|
|
624
|
+
else if (head === 1 && workdir === 0 && stage === 1) status = 'deleted (staged)';
|
|
625
|
+
return { path: filepath, status };
|
|
626
|
+
}).filter(f => f.status !== 'unmodified');
|
|
627
|
+
return { files };
|
|
628
|
+
} catch (err) {
|
|
629
|
+
return { error: `Failed to get status: ${err.message}` };
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
async function gitAddTool(args) {
|
|
634
|
+
const WORKSPACE = resolve(join(OPENCLAW_ROOT, 'workspace'));
|
|
635
|
+
try {
|
|
636
|
+
if (args.path === '.') {
|
|
637
|
+
const matrix = await git.statusMatrix({ fs: nodeFs, dir: WORKSPACE });
|
|
638
|
+
for (const [filepath, head, workdir, stage] of matrix) {
|
|
639
|
+
if (workdir === 0) {
|
|
640
|
+
await git.remove({ fs: nodeFs, dir: WORKSPACE, filepath });
|
|
641
|
+
} else if (head !== workdir || workdir !== stage) {
|
|
642
|
+
await git.add({ fs: nodeFs, dir: WORKSPACE, filepath });
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
return { success: true, message: 'All changes staged' };
|
|
646
|
+
} else {
|
|
647
|
+
await git.add({ fs: nodeFs, dir: WORKSPACE, filepath: args.path });
|
|
648
|
+
return { success: true, path: args.path };
|
|
649
|
+
}
|
|
650
|
+
} catch (err) {
|
|
651
|
+
return { error: `Failed to stage: ${err.message}` };
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
async function gitCommitTool(args) {
|
|
656
|
+
const WORKSPACE = resolve(join(OPENCLAW_ROOT, 'workspace'));
|
|
657
|
+
try {
|
|
658
|
+
const sha = await git.commit({
|
|
659
|
+
fs: nodeFs,
|
|
660
|
+
dir: WORKSPACE,
|
|
661
|
+
message: args.message,
|
|
662
|
+
author: {
|
|
663
|
+
name: args.author_name || 'mobile-claw',
|
|
664
|
+
email: args.author_email || 'agent@mobile-claw.local',
|
|
665
|
+
},
|
|
666
|
+
});
|
|
667
|
+
return { success: true, sha, message: args.message };
|
|
668
|
+
} catch (err) {
|
|
669
|
+
return { error: `Failed to commit: ${err.message}` };
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
async function gitLogTool(args) {
|
|
674
|
+
const WORKSPACE = resolve(join(OPENCLAW_ROOT, 'workspace'));
|
|
675
|
+
try {
|
|
676
|
+
const commits = await git.log({
|
|
677
|
+
fs: nodeFs,
|
|
678
|
+
dir: WORKSPACE,
|
|
679
|
+
depth: args.max_count || 10,
|
|
680
|
+
});
|
|
681
|
+
return {
|
|
682
|
+
commits: commits.map(c => ({
|
|
683
|
+
sha: c.oid,
|
|
684
|
+
message: c.commit.message,
|
|
685
|
+
author: c.commit.author.name,
|
|
686
|
+
email: c.commit.author.email,
|
|
687
|
+
timestamp: new Date(c.commit.author.timestamp * 1000).toISOString(),
|
|
688
|
+
})),
|
|
689
|
+
};
|
|
690
|
+
} catch (err) {
|
|
691
|
+
return { error: `Failed to get log: ${err.message}` };
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
async function gitDiffTool(args) {
|
|
696
|
+
const WORKSPACE = resolve(join(OPENCLAW_ROOT, 'workspace'));
|
|
697
|
+
try {
|
|
698
|
+
const matrix = await git.statusMatrix({ fs: nodeFs, dir: WORKSPACE });
|
|
699
|
+
const changes = [];
|
|
700
|
+
|
|
701
|
+
// Resolve HEAD to a commit OID (readBlob needs a SHA, not a ref)
|
|
702
|
+
let headOid = null;
|
|
703
|
+
try {
|
|
704
|
+
headOid = await git.resolveRef({ fs: nodeFs, dir: WORKSPACE, ref: 'HEAD' });
|
|
705
|
+
} catch { /* no commits yet */ }
|
|
706
|
+
|
|
707
|
+
for (const [filepath, head, workdir, stage] of matrix) {
|
|
708
|
+
const isRelevant = args.cached
|
|
709
|
+
? (stage !== head)
|
|
710
|
+
: (workdir !== stage);
|
|
711
|
+
|
|
712
|
+
if (!isRelevant) continue;
|
|
713
|
+
|
|
714
|
+
let currentContent = null;
|
|
715
|
+
try {
|
|
716
|
+
currentContent = readFileSync(join(WORKSPACE, filepath), 'utf8');
|
|
717
|
+
} catch { /* file deleted */ }
|
|
718
|
+
|
|
719
|
+
let previousContent = null;
|
|
720
|
+
if (headOid) {
|
|
721
|
+
try {
|
|
722
|
+
const { blob } = await git.readBlob({ fs: nodeFs, dir: WORKSPACE, oid: headOid, filepath });
|
|
723
|
+
previousContent = new TextDecoder().decode(blob);
|
|
724
|
+
} catch { /* new file */ }
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
changes.push({
|
|
728
|
+
path: filepath,
|
|
729
|
+
previous: previousContent ? previousContent.substring(0, 2000) : null,
|
|
730
|
+
current: currentContent ? currentContent.substring(0, 2000) : null,
|
|
731
|
+
type: !previousContent ? 'added' : !currentContent ? 'deleted' : 'modified',
|
|
732
|
+
});
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
return { changes };
|
|
736
|
+
} catch (err) {
|
|
737
|
+
return { error: `Failed to get diff: ${err.message}` };
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// ── Approval gate (Phase 4) ──────────────────────────────────────────────
|
|
742
|
+
|
|
743
|
+
// Tools that require user approval before execution
|
|
744
|
+
const APPROVAL_REQUIRED = new Set(['write_file', 'edit_file', 'execute_js', 'execute_python', 'git_commit']);
|
|
745
|
+
|
|
746
|
+
// Map of toolCallId → resolver callback for pending approvals
|
|
747
|
+
const pendingApprovals = new Map();
|
|
748
|
+
const APPROVAL_TTL_MS = 120_000; // Auto-deny after 2 minutes
|
|
749
|
+
|
|
750
|
+
function waitForApproval(toolCallId, signal) {
|
|
751
|
+
return new Promise((resolve) => {
|
|
752
|
+
// TTL: auto-deny after 2 minutes if user doesn't respond
|
|
753
|
+
const ttlTimer = setTimeout(() => {
|
|
754
|
+
if (pendingApprovals.has(toolCallId)) {
|
|
755
|
+
pendingApprovals.delete(toolCallId);
|
|
756
|
+
channel.send('message', { type: 'tool.approval_expired', toolCallId });
|
|
757
|
+
resolve(false);
|
|
758
|
+
}
|
|
759
|
+
}, APPROVAL_TTL_MS);
|
|
760
|
+
|
|
761
|
+
pendingApprovals.set(toolCallId, (approved) => {
|
|
762
|
+
clearTimeout(ttlTimer);
|
|
763
|
+
resolve(approved);
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
if (signal) {
|
|
767
|
+
const onAbort = () => {
|
|
768
|
+
clearTimeout(ttlTimer);
|
|
769
|
+
if (pendingApprovals.has(toolCallId)) {
|
|
770
|
+
pendingApprovals.delete(toolCallId);
|
|
771
|
+
resolve(false);
|
|
772
|
+
}
|
|
773
|
+
};
|
|
774
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
775
|
+
}
|
|
776
|
+
});
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
function wrapWithApproval(toolName, executeFn) {
|
|
780
|
+
return async (toolCallId, params, signal, onUpdate) => {
|
|
781
|
+
// Send approval request to UI
|
|
782
|
+
channel.send('message', {
|
|
783
|
+
type: 'tool.approval_request',
|
|
784
|
+
toolCallId,
|
|
785
|
+
toolName,
|
|
786
|
+
args: params,
|
|
787
|
+
});
|
|
788
|
+
|
|
789
|
+
// Wait for approval response from UI
|
|
790
|
+
const approved = await waitForApproval(toolCallId, signal);
|
|
791
|
+
|
|
792
|
+
if (!approved) {
|
|
793
|
+
return {
|
|
794
|
+
content: [{ type: 'text', text: 'Tool execution denied by user.' }],
|
|
795
|
+
details: { denied: true },
|
|
796
|
+
};
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
// Execute the actual tool
|
|
800
|
+
return executeFn(toolCallId, params, signal, onUpdate);
|
|
801
|
+
};
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
// ── Agent loop (Phase 4 — pi-agent-core) ─────────────────────────────────
|
|
805
|
+
|
|
806
|
+
import { Agent } from '@mariozechner/pi-agent-core';
|
|
807
|
+
import { getModel } from '@mariozechner/pi-ai';
|
|
808
|
+
import { Type } from '@sinclair/typebox';
|
|
809
|
+
|
|
810
|
+
// Module-level agent instance (persists across prompts for multi-turn conversation)
|
|
811
|
+
let currentAgent = null;
|
|
812
|
+
let currentAbortController = null;
|
|
813
|
+
let currentSessionKey = null;
|
|
814
|
+
let persistedMessageCount = 0; // Tracks how many messages have been written to JSONL
|
|
815
|
+
|
|
816
|
+
// Idempotency: track recent message keys to silently drop duplicates
|
|
817
|
+
const recentIdempotencyKeys = new Set();
|
|
818
|
+
const MAX_IDEMPOTENCY_KEYS = 100;
|
|
819
|
+
|
|
820
|
+
function loadSystemPrompt() {
|
|
821
|
+
const workspaceDir = join(OPENCLAW_ROOT, 'workspace');
|
|
822
|
+
let systemPrompt = '';
|
|
823
|
+
|
|
824
|
+
try {
|
|
825
|
+
const identityPath = join(workspaceDir, 'IDENTITY.md');
|
|
826
|
+
if (existsSync(identityPath)) {
|
|
827
|
+
systemPrompt += readFileSync(identityPath, 'utf8') + '\n\n';
|
|
828
|
+
}
|
|
829
|
+
const soulPath = join(workspaceDir, 'SOUL.md');
|
|
830
|
+
if (existsSync(soulPath)) {
|
|
831
|
+
systemPrompt += readFileSync(soulPath, 'utf8') + '\n\n';
|
|
832
|
+
}
|
|
833
|
+
const memoryPath = join(workspaceDir, 'MEMORY.md');
|
|
834
|
+
if (existsSync(memoryPath)) {
|
|
835
|
+
systemPrompt += '## Memory\n' + readFileSync(memoryPath, 'utf8') + '\n\n';
|
|
836
|
+
}
|
|
837
|
+
} catch {
|
|
838
|
+
// Non-fatal: workspace files are optional
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
if (!systemPrompt) {
|
|
842
|
+
systemPrompt = 'You are a helpful AI assistant running on a mobile device.';
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
return systemPrompt;
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
// Helper to convert tool handler result to AgentToolResult format
|
|
849
|
+
function toToolResult(result) {
|
|
850
|
+
return {
|
|
851
|
+
content: [{ type: 'text', text: JSON.stringify(result) }],
|
|
852
|
+
details: result,
|
|
853
|
+
};
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
function buildAgentTools() {
|
|
857
|
+
const toolDefs = [
|
|
858
|
+
{
|
|
859
|
+
name: 'read_file',
|
|
860
|
+
label: 'Read File',
|
|
861
|
+
description: 'Read the contents of a file in the workspace.',
|
|
862
|
+
parameters: Type.Object({
|
|
863
|
+
path: Type.String({ description: 'Relative path from workspace root' }),
|
|
864
|
+
}),
|
|
865
|
+
execute: async (_id, params) => toToolResult(readFileTool(params)),
|
|
866
|
+
},
|
|
867
|
+
{
|
|
868
|
+
name: 'write_file',
|
|
869
|
+
label: 'Write File',
|
|
870
|
+
description: 'Write content to a file in the workspace. Creates parent directories if needed.',
|
|
871
|
+
parameters: Type.Object({
|
|
872
|
+
path: Type.String({ description: 'Relative path from workspace root' }),
|
|
873
|
+
content: Type.String({ description: 'File content to write' }),
|
|
874
|
+
}),
|
|
875
|
+
execute: async (_id, params) => toToolResult(writeFileTool(params)),
|
|
876
|
+
},
|
|
877
|
+
{
|
|
878
|
+
name: 'list_files',
|
|
879
|
+
label: 'List Files',
|
|
880
|
+
description: 'List files and directories in a workspace directory.',
|
|
881
|
+
parameters: Type.Object({
|
|
882
|
+
path: Type.Optional(Type.String({ description: 'Relative path from workspace root (default: ".")' })),
|
|
883
|
+
}),
|
|
884
|
+
execute: async (_id, params) => toToolResult(listFilesTool(params)),
|
|
885
|
+
},
|
|
886
|
+
{
|
|
887
|
+
name: 'grep_files',
|
|
888
|
+
label: 'Grep Files',
|
|
889
|
+
description: 'Search file contents by regex pattern. Returns matching lines with file paths and line numbers.',
|
|
890
|
+
parameters: Type.Object({
|
|
891
|
+
pattern: Type.String({ description: 'Regular expression pattern to search for' }),
|
|
892
|
+
path: Type.Optional(Type.String({ description: 'Directory or file to search in (default: "." for entire workspace)' })),
|
|
893
|
+
case_insensitive: Type.Optional(Type.Boolean({ description: 'If true, search is case-insensitive (default: false)' })),
|
|
894
|
+
}),
|
|
895
|
+
execute: async (_id, params) => toToolResult(grepFilesTool(params)),
|
|
896
|
+
},
|
|
897
|
+
{
|
|
898
|
+
name: 'find_files',
|
|
899
|
+
label: 'Find Files',
|
|
900
|
+
description: 'Find files by name pattern (glob matching). Searches recursively from the given path.',
|
|
901
|
+
parameters: Type.Object({
|
|
902
|
+
pattern: Type.String({ description: 'Glob pattern to match file names (e.g. "*.ts", "test-*")' }),
|
|
903
|
+
path: Type.Optional(Type.String({ description: 'Directory to search in (default: "." for entire workspace)' })),
|
|
904
|
+
}),
|
|
905
|
+
execute: async (_id, params) => toToolResult(findFilesTool(params)),
|
|
906
|
+
},
|
|
907
|
+
{
|
|
908
|
+
name: 'edit_file',
|
|
909
|
+
label: 'Edit File',
|
|
910
|
+
description: 'Apply a surgical edit to a file: find exact old_text and replace it with new_text. Only the first occurrence is replaced. Use read_file first to see the current content.',
|
|
911
|
+
parameters: Type.Object({
|
|
912
|
+
path: Type.String({ description: 'Relative path from workspace root' }),
|
|
913
|
+
old_text: Type.String({ description: 'Exact text to find (must match precisely including whitespace)' }),
|
|
914
|
+
new_text: Type.String({ description: 'Replacement text' }),
|
|
915
|
+
}),
|
|
916
|
+
execute: async (_id, params) => toToolResult(editFileTool(params)),
|
|
917
|
+
},
|
|
918
|
+
{
|
|
919
|
+
name: 'execute_js',
|
|
920
|
+
label: 'Execute JS',
|
|
921
|
+
description: 'Execute JavaScript code in a sandboxed VM. Returns stdout (captured console.log output) and the result of the last expression. No access to require, process, fs, or network. 5-second timeout.',
|
|
922
|
+
parameters: Type.Object({
|
|
923
|
+
code: Type.String({ description: 'JavaScript code to execute' }),
|
|
924
|
+
}),
|
|
925
|
+
execute: async (_id, params) => toToolResult(executeJsTool(params)),
|
|
926
|
+
},
|
|
927
|
+
{
|
|
928
|
+
name: 'execute_python',
|
|
929
|
+
label: 'Execute Python',
|
|
930
|
+
description: 'Execute Python code in a sandboxed Pyodide (WebAssembly) environment. Returns stdout (captured print output) and the result of the last expression. Includes math, json, re, collections, itertools, functools, datetime. No filesystem, network, or subprocess access. 5-second timeout.',
|
|
931
|
+
parameters: Type.Object({
|
|
932
|
+
code: Type.String({ description: 'Python code to execute' }),
|
|
933
|
+
}),
|
|
934
|
+
execute: async (_id, params) => toToolResult(await executePythonTool(params)),
|
|
935
|
+
},
|
|
936
|
+
{
|
|
937
|
+
name: 'git_init',
|
|
938
|
+
label: 'Git Init',
|
|
939
|
+
description: 'Initialize a new git repository in the workspace. Auto-creates .gitignore.',
|
|
940
|
+
parameters: Type.Object({
|
|
941
|
+
default_branch: Type.Optional(Type.String({ description: 'Default branch name (default: "main")' })),
|
|
942
|
+
}),
|
|
943
|
+
execute: async (_id, params) => toToolResult(await gitInitTool(params)),
|
|
944
|
+
},
|
|
945
|
+
{
|
|
946
|
+
name: 'git_status',
|
|
947
|
+
label: 'Git Status',
|
|
948
|
+
description: 'Show the working tree status: staged, unstaged, and untracked files.',
|
|
949
|
+
parameters: Type.Object({}),
|
|
950
|
+
execute: async () => toToolResult(await gitStatusTool()),
|
|
951
|
+
},
|
|
952
|
+
{
|
|
953
|
+
name: 'git_add',
|
|
954
|
+
label: 'Git Add',
|
|
955
|
+
description: 'Stage files for commit. Use "." to stage all changes.',
|
|
956
|
+
parameters: Type.Object({
|
|
957
|
+
path: Type.String({ description: 'File path or "." for all files' }),
|
|
958
|
+
}),
|
|
959
|
+
execute: async (_id, params) => toToolResult(await gitAddTool(params)),
|
|
960
|
+
},
|
|
961
|
+
{
|
|
962
|
+
name: 'git_commit',
|
|
963
|
+
label: 'Git Commit',
|
|
964
|
+
description: 'Create a git commit with the staged changes.',
|
|
965
|
+
parameters: Type.Object({
|
|
966
|
+
message: Type.String({ description: 'Commit message' }),
|
|
967
|
+
author_name: Type.Optional(Type.String({ description: 'Author name (default: "mobile-claw")' })),
|
|
968
|
+
author_email: Type.Optional(Type.String({ description: 'Author email (default: "agent@mobile-claw.local")' })),
|
|
969
|
+
}),
|
|
970
|
+
execute: async (_id, params) => toToolResult(await gitCommitTool(params)),
|
|
971
|
+
},
|
|
972
|
+
{
|
|
973
|
+
name: 'git_log',
|
|
974
|
+
label: 'Git Log',
|
|
975
|
+
description: 'Show the commit log. Returns the most recent N commits.',
|
|
976
|
+
parameters: Type.Object({
|
|
977
|
+
max_count: Type.Optional(Type.Number({ description: 'Maximum number of commits to return (default: 10)' })),
|
|
978
|
+
}),
|
|
979
|
+
execute: async (_id, params) => toToolResult(await gitLogTool(params)),
|
|
980
|
+
},
|
|
981
|
+
{
|
|
982
|
+
name: 'git_diff',
|
|
983
|
+
label: 'Git Diff',
|
|
984
|
+
description: 'Show diffs of file changes. Without arguments, shows unstaged changes. With cached=true, shows staged changes.',
|
|
985
|
+
parameters: Type.Object({
|
|
986
|
+
cached: Type.Optional(Type.Boolean({ description: 'If true, show staged changes instead of unstaged' })),
|
|
987
|
+
}),
|
|
988
|
+
execute: async (_id, params) => toToolResult(await gitDiffTool(params)),
|
|
989
|
+
},
|
|
990
|
+
];
|
|
991
|
+
|
|
992
|
+
// Wrap tools that require approval
|
|
993
|
+
return toolDefs.map(tool => ({
|
|
994
|
+
...tool,
|
|
995
|
+
execute: APPROVAL_REQUIRED.has(tool.name)
|
|
996
|
+
? wrapWithApproval(tool.name, tool.execute)
|
|
997
|
+
: tool.execute,
|
|
998
|
+
}));
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
// ── AgentEvent → Bridge event mapping ────────────────────────────────────
|
|
1002
|
+
|
|
1003
|
+
function bridgeEvent(event) {
|
|
1004
|
+
switch (event.type) {
|
|
1005
|
+
case 'message_update': {
|
|
1006
|
+
const e = event.assistantMessageEvent;
|
|
1007
|
+
if (e.type === 'text_delta') {
|
|
1008
|
+
channel.send('message', { type: 'agent.event', eventType: 'text_delta', data: { text: e.delta } });
|
|
1009
|
+
}
|
|
1010
|
+
if (e.type === 'thinking_delta') {
|
|
1011
|
+
channel.send('message', { type: 'agent.event', eventType: 'thinking', data: { text: e.delta } });
|
|
1012
|
+
}
|
|
1013
|
+
break;
|
|
1014
|
+
}
|
|
1015
|
+
case 'tool_execution_start':
|
|
1016
|
+
channel.send('message', {
|
|
1017
|
+
type: 'agent.event',
|
|
1018
|
+
eventType: 'tool_use',
|
|
1019
|
+
data: { toolName: event.toolName, toolCallId: event.toolCallId, args: event.args },
|
|
1020
|
+
});
|
|
1021
|
+
break;
|
|
1022
|
+
case 'tool_execution_end':
|
|
1023
|
+
channel.send('message', {
|
|
1024
|
+
type: 'agent.event',
|
|
1025
|
+
eventType: 'tool_result',
|
|
1026
|
+
data: { toolName: event.toolName, toolCallId: event.toolCallId, result: event.result },
|
|
1027
|
+
});
|
|
1028
|
+
break;
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
// ── Session management helpers ───────────────────────────────────────────
|
|
1033
|
+
|
|
1034
|
+
function extractUsage(agent) {
|
|
1035
|
+
let input = 0, output = 0;
|
|
1036
|
+
for (const msg of agent.state.messages) {
|
|
1037
|
+
if (msg.role === 'assistant' && msg.usage) {
|
|
1038
|
+
input += msg.usage.input;
|
|
1039
|
+
output += msg.usage.output;
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
return { inputTokens: input, outputTokens: output, totalTokens: input + output };
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
// Deduplicate messages loaded from JSONL (handles legacy duplication bug)
|
|
1046
|
+
function deduplicateMessages(messages) {
|
|
1047
|
+
const seen = new Set();
|
|
1048
|
+
return messages.filter(m => {
|
|
1049
|
+
const contentKey = typeof m.content === 'string'
|
|
1050
|
+
? m.content.substring(0, 100)
|
|
1051
|
+
: JSON.stringify(m.content).substring(0, 100);
|
|
1052
|
+
const key = `${m.role}:${m.timestamp || ''}:${contentKey}`;
|
|
1053
|
+
if (seen.has(key)) return false;
|
|
1054
|
+
seen.add(key);
|
|
1055
|
+
return true;
|
|
1056
|
+
});
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
// Rebuild sessions.json index from JSONL files on disk (recovery path)
|
|
1060
|
+
function rebuildSessionIndex(agentId) {
|
|
1061
|
+
const sessionsDir = join(OPENCLAW_ROOT, 'agents', agentId, 'sessions');
|
|
1062
|
+
try {
|
|
1063
|
+
const jsonlFiles = readdirSync(sessionsDir).filter(f => f.endsWith('.jsonl'));
|
|
1064
|
+
const index = { [agentId]: {} };
|
|
1065
|
+
for (const file of jsonlFiles) {
|
|
1066
|
+
const sessionKey = file.replace('.jsonl', '').replace('_', '/');
|
|
1067
|
+
const stat = statSync(join(sessionsDir, file));
|
|
1068
|
+
index[agentId][sessionKey] = {
|
|
1069
|
+
sessionId: sessionKey,
|
|
1070
|
+
createdAt: stat.birthtimeMs || stat.ctimeMs,
|
|
1071
|
+
updatedAt: stat.mtimeMs,
|
|
1072
|
+
model: 'anthropic/claude-sonnet-4-5',
|
|
1073
|
+
totalTokens: 0,
|
|
1074
|
+
};
|
|
1075
|
+
}
|
|
1076
|
+
console.log(`[rebuildSessionIndex] Rebuilt index with ${jsonlFiles.length} sessions`);
|
|
1077
|
+
return index;
|
|
1078
|
+
} catch {
|
|
1079
|
+
return { [agentId]: {} };
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
function saveSession(agent, agentId, sessionKey, startTime) {
|
|
1084
|
+
const sessionsDir = join(OPENCLAW_ROOT, 'agents', agentId, 'sessions');
|
|
1085
|
+
const sessionFile = join(sessionsDir, `${sessionKey.replace('/', '_')}.jsonl`);
|
|
1086
|
+
|
|
1087
|
+
// Append only NEW messages since last save (delta)
|
|
1088
|
+
const allMessages = agent.state.messages;
|
|
1089
|
+
for (let i = persistedMessageCount; i < allMessages.length; i++) {
|
|
1090
|
+
nodeFs.appendFileSync(sessionFile, JSON.stringify(allMessages[i]) + '\n');
|
|
1091
|
+
}
|
|
1092
|
+
persistedMessageCount = allMessages.length;
|
|
1093
|
+
|
|
1094
|
+
// Update sessions.json index (atomic: write tmp then rename)
|
|
1095
|
+
const usage = extractUsage(agent);
|
|
1096
|
+
const sessionsJsonPath = join(sessionsDir, 'sessions.json');
|
|
1097
|
+
try {
|
|
1098
|
+
let index = {};
|
|
1099
|
+
try { index = JSON.parse(readFileSync(sessionsJsonPath, 'utf8')); } catch {
|
|
1100
|
+
// Index missing or corrupt — rebuild from JSONL files
|
|
1101
|
+
index = rebuildSessionIndex(agentId);
|
|
1102
|
+
}
|
|
1103
|
+
if (!index[agentId]) index[agentId] = {};
|
|
1104
|
+
index[agentId][sessionKey] = {
|
|
1105
|
+
sessionId: sessionKey,
|
|
1106
|
+
createdAt: index[agentId][sessionKey]?.createdAt || startTime,
|
|
1107
|
+
updatedAt: Date.now(),
|
|
1108
|
+
model: 'anthropic/claude-sonnet-4-5',
|
|
1109
|
+
totalTokens: usage.totalTokens,
|
|
1110
|
+
};
|
|
1111
|
+
const tmpPath = sessionsJsonPath + '.tmp';
|
|
1112
|
+
writeFileSync(tmpPath, JSON.stringify(index, null, 2));
|
|
1113
|
+
renameSync(tmpPath, sessionsJsonPath);
|
|
1114
|
+
} catch { /* non-fatal */ }
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
// ── Error classification ─────────────────────────────────────────────────
|
|
1118
|
+
|
|
1119
|
+
function isTransientError(err) {
|
|
1120
|
+
const status = err.status || err.statusCode;
|
|
1121
|
+
if (status === 429 || status === 503 || status === 502) return true;
|
|
1122
|
+
if (err.code === 'ECONNRESET' || err.code === 'ETIMEDOUT' || err.code === 'ENOTFOUND') return true;
|
|
1123
|
+
if (err.message?.includes('overloaded') || err.message?.includes('rate limit')) return true;
|
|
1124
|
+
return false;
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
let lastFailedPrompt = null;
|
|
1128
|
+
|
|
1129
|
+
// ── Setup skill ─────────────────────────────────────────────────────────
|
|
1130
|
+
|
|
1131
|
+
const SETUP_MILESTONES = [
|
|
1132
|
+
'user_name_captured',
|
|
1133
|
+
'work_context_captured',
|
|
1134
|
+
'personality_defined',
|
|
1135
|
+
'identity_generated',
|
|
1136
|
+
'memory_seeded',
|
|
1137
|
+
'soul_generated',
|
|
1138
|
+
'user_confirmed',
|
|
1139
|
+
];
|
|
1140
|
+
|
|
1141
|
+
const SETUP_SYSTEM_PROMPT = `You are Shell, a personal AI assistant being set up for the first time on a user's device.
|
|
1142
|
+
|
|
1143
|
+
Your job is to interview the user through a warm, friendly conversation to learn about them and configure yourself. This is NOT a rigid form — it's a natural dialogue. Be curious, responsive, and adapt your questions based on what they share.
|
|
1144
|
+
|
|
1145
|
+
## Interview Goals
|
|
1146
|
+
|
|
1147
|
+
You need to gather enough information to generate three configuration files:
|
|
1148
|
+
1. **IDENTITY.md** — Who you are: your name, personality traits, communication style
|
|
1149
|
+
2. **MEMORY.md** — What you know about the user: their name, work context, preferences, timezone
|
|
1150
|
+
3. **SOUL.md** — Your deeper character: values, quirks, how you approach problems
|
|
1151
|
+
|
|
1152
|
+
## Conversation Flow
|
|
1153
|
+
|
|
1154
|
+
1. **Greet & introduce yourself** — You're a freshly hatched AI. Be excited but not overwhelming.
|
|
1155
|
+
2. **Learn the user's name** — Ask what they'd like to be called. → milestone: user_name_captured
|
|
1156
|
+
3. **Understand their work** — What do they do? What projects are they working on? → milestone: work_context_captured
|
|
1157
|
+
4. **Define your personality** — Ask how they want you to communicate. Casual? Formal? Funny? Direct? → milestone: personality_defined
|
|
1158
|
+
5. **Generate IDENTITY.md** — Use the setup_complete tool when ready. → milestone: identity_generated
|
|
1159
|
+
6. **Seed MEMORY.md** — Capture key facts about the user. → milestone: memory_seeded
|
|
1160
|
+
7. **Generate SOUL.md** — Define your character based on the conversation. → milestone: soul_generated
|
|
1161
|
+
8. **Confirm with the user** — Show a summary of what you've learned and how you'll behave. Ask if they want to adjust anything. → milestone: user_confirmed
|
|
1162
|
+
|
|
1163
|
+
## Rules
|
|
1164
|
+
|
|
1165
|
+
- Keep the conversation to **5-8 exchanges** total. Don't over-interview.
|
|
1166
|
+
- **Never skip steps**. Each milestone must be reached naturally through conversation.
|
|
1167
|
+
- Use the \`setup_milestone\` tool after each milestone is reached.
|
|
1168
|
+
- Use the \`setup_complete\` tool ONLY after the user confirms the summary.
|
|
1169
|
+
- If the user wants to use a device tool (location, contacts), ask for explicit permission first.
|
|
1170
|
+
- You can suggest a Shell name (based on personality) but the user gets final say.
|
|
1171
|
+
- Be creative with your personality — you're being born right now. Make it memorable.
|
|
1172
|
+
|
|
1173
|
+
## Output Format
|
|
1174
|
+
|
|
1175
|
+
When calling \`setup_complete\`, provide:
|
|
1176
|
+
- \`shellName\`: The chosen name for the Shell (default: "Shell")
|
|
1177
|
+
- \`identity\`: Full IDENTITY.md content
|
|
1178
|
+
- \`memory\`: Full MEMORY.md content
|
|
1179
|
+
- \`soul\`: Full SOUL.md content
|
|
1180
|
+
|
|
1181
|
+
All three files should be in Markdown format with clear sections.`;
|
|
1182
|
+
|
|
1183
|
+
const setupMilestonesReached = new Set();
|
|
1184
|
+
|
|
1185
|
+
async function runSetupSkill(agentId) {
|
|
1186
|
+
await refreshOAuthTokenIfNeeded(agentId);
|
|
1187
|
+
const authProfiles = loadAuthProfiles(agentId);
|
|
1188
|
+
const apiKey = resolveApiKey(authProfiles);
|
|
1189
|
+
|
|
1190
|
+
if (!apiKey) {
|
|
1191
|
+
channel.send('message', {
|
|
1192
|
+
type: 'agent.error',
|
|
1193
|
+
error: 'No AI provider configured. Connect a provider first.',
|
|
1194
|
+
});
|
|
1195
|
+
return;
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
const modelId = 'claude-sonnet-4-5';
|
|
1199
|
+
const model = getModel('anthropic', modelId);
|
|
1200
|
+
|
|
1201
|
+
// Setup-specific tools: milestone + complete
|
|
1202
|
+
const setupTools = [
|
|
1203
|
+
{
|
|
1204
|
+
name: 'setup_milestone',
|
|
1205
|
+
label: 'Setup Milestone',
|
|
1206
|
+
description: 'Report that a setup milestone has been reached during the onboarding interview.',
|
|
1207
|
+
parameters: Type.Object({
|
|
1208
|
+
milestone: Type.String({ description: 'The milestone name' }),
|
|
1209
|
+
}),
|
|
1210
|
+
execute: async (_id, params) => {
|
|
1211
|
+
const m = params.milestone;
|
|
1212
|
+
if (SETUP_MILESTONES.includes(m)) {
|
|
1213
|
+
setupMilestonesReached.add(m);
|
|
1214
|
+
channel.send('message', {
|
|
1215
|
+
type: 'setup.milestone',
|
|
1216
|
+
milestone: m,
|
|
1217
|
+
completedCount: setupMilestonesReached.size,
|
|
1218
|
+
});
|
|
1219
|
+
}
|
|
1220
|
+
return toToolResult({ success: true, milestone: m, completedCount: setupMilestonesReached.size });
|
|
1221
|
+
},
|
|
1222
|
+
},
|
|
1223
|
+
{
|
|
1224
|
+
name: 'setup_complete',
|
|
1225
|
+
label: 'Setup Complete',
|
|
1226
|
+
description: 'Complete the setup process. Call this ONLY after all milestones are reached and the user has confirmed the summary.',
|
|
1227
|
+
parameters: Type.Object({
|
|
1228
|
+
shellName: Type.String({ description: 'The chosen name for the Shell' }),
|
|
1229
|
+
identity: Type.String({ description: 'Full IDENTITY.md content' }),
|
|
1230
|
+
memory: Type.String({ description: 'Full MEMORY.md content' }),
|
|
1231
|
+
soul: Type.String({ description: 'Full SOUL.md content' }),
|
|
1232
|
+
}),
|
|
1233
|
+
execute: async (_id, params) => {
|
|
1234
|
+
// Write configuration files to workspace
|
|
1235
|
+
const workspaceDir = join(OPENCLAW_ROOT, 'workspace');
|
|
1236
|
+
mkdirSync(workspaceDir, { recursive: true });
|
|
1237
|
+
|
|
1238
|
+
writeFileSync(join(workspaceDir, 'IDENTITY.md'), params.identity);
|
|
1239
|
+
writeFileSync(join(workspaceDir, 'MEMORY.md'), params.memory);
|
|
1240
|
+
writeFileSync(join(workspaceDir, 'SOUL.md'), params.soul);
|
|
1241
|
+
|
|
1242
|
+
// Emit completion to UI
|
|
1243
|
+
channel.send('message', {
|
|
1244
|
+
type: 'setup.complete',
|
|
1245
|
+
shellName: params.shellName,
|
|
1246
|
+
files: {
|
|
1247
|
+
'IDENTITY.md': params.identity,
|
|
1248
|
+
'MEMORY.md': params.memory,
|
|
1249
|
+
'SOUL.md': params.soul,
|
|
1250
|
+
},
|
|
1251
|
+
});
|
|
1252
|
+
|
|
1253
|
+
return toToolResult({ success: true, shellName: params.shellName });
|
|
1254
|
+
},
|
|
1255
|
+
},
|
|
1256
|
+
];
|
|
1257
|
+
|
|
1258
|
+
// Merge setup tools with base file tools + MCP tools
|
|
1259
|
+
const baseTools = buildAgentTools();
|
|
1260
|
+
const mcpTools = await discoverMcpTools();
|
|
1261
|
+
const tools = [...setupTools, ...baseTools, ...mcpTools];
|
|
1262
|
+
|
|
1263
|
+
// Create a fresh agent with setup system prompt
|
|
1264
|
+
const sessionKey = `setup/${Date.now()}`;
|
|
1265
|
+
const agent = new Agent({
|
|
1266
|
+
initialState: {
|
|
1267
|
+
systemPrompt: SETUP_SYSTEM_PROMPT,
|
|
1268
|
+
model,
|
|
1269
|
+
tools,
|
|
1270
|
+
thinkingLevel: 'off',
|
|
1271
|
+
},
|
|
1272
|
+
convertToLlm: (messages) => messages.filter(m =>
|
|
1273
|
+
m.role === 'user' || m.role === 'assistant' || m.role === 'toolResult'
|
|
1274
|
+
),
|
|
1275
|
+
getApiKey: () => apiKey,
|
|
1276
|
+
});
|
|
1277
|
+
currentAgent = agent;
|
|
1278
|
+
currentSessionKey = sessionKey;
|
|
1279
|
+
persistedMessageCount = 0;
|
|
1280
|
+
setupMilestonesReached.clear();
|
|
1281
|
+
|
|
1282
|
+
// Subscribe to events
|
|
1283
|
+
agent.subscribe((event) => {
|
|
1284
|
+
bridgeEvent(event);
|
|
1285
|
+
if (event.type === 'tool_execution_end' && sessionKey) {
|
|
1286
|
+
try {
|
|
1287
|
+
const sessionsDir = join(OPENCLAW_ROOT, 'agents', agentId, 'sessions');
|
|
1288
|
+
mkdirSync(sessionsDir, { recursive: true });
|
|
1289
|
+
const sessionFile = join(sessionsDir, `${sessionKey.replace('/', '_')}.jsonl`);
|
|
1290
|
+
const allMessages = agent.state.messages;
|
|
1291
|
+
for (let i = persistedMessageCount; i < allMessages.length; i++) {
|
|
1292
|
+
nodeFs.appendFileSync(sessionFile, JSON.stringify(allMessages[i]) + '\n');
|
|
1293
|
+
}
|
|
1294
|
+
persistedMessageCount = allMessages.length;
|
|
1295
|
+
} catch { /* non-fatal */ }
|
|
1296
|
+
}
|
|
1297
|
+
});
|
|
1298
|
+
|
|
1299
|
+
// Kick off the setup conversation — agent speaks first
|
|
1300
|
+
const startTime = Date.now();
|
|
1301
|
+
try {
|
|
1302
|
+
await agent.prompt('Begin the setup interview. Greet the user warmly and start by asking their name.');
|
|
1303
|
+
await agent.waitForIdle();
|
|
1304
|
+
|
|
1305
|
+
saveSession(agent, agentId, sessionKey, startTime);
|
|
1306
|
+
|
|
1307
|
+
const usage = extractUsage(agent);
|
|
1308
|
+
channel.send('message', {
|
|
1309
|
+
type: 'agent.completed',
|
|
1310
|
+
sessionKey,
|
|
1311
|
+
usage,
|
|
1312
|
+
cumulativeUsage: usage,
|
|
1313
|
+
durationMs: Date.now() - startTime,
|
|
1314
|
+
});
|
|
1315
|
+
} catch (err) {
|
|
1316
|
+
channel.send('message', {
|
|
1317
|
+
type: 'agent.error',
|
|
1318
|
+
error: err.message || 'Setup skill error',
|
|
1319
|
+
code: err.status ? String(err.status) : undefined,
|
|
1320
|
+
retryable: isTransientError(err),
|
|
1321
|
+
});
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
// ── Main agent run function ──────────────────────────────────────────────
|
|
1326
|
+
|
|
1327
|
+
async function runAgentLoop(agentId, sessionKey, prompt, requestedModel) {
|
|
1328
|
+
await refreshOAuthTokenIfNeeded(agentId);
|
|
1329
|
+
const authProfiles = loadAuthProfiles(agentId);
|
|
1330
|
+
const apiKey = resolveApiKey(authProfiles);
|
|
1331
|
+
|
|
1332
|
+
if (!apiKey) {
|
|
1333
|
+
channel.send('message', {
|
|
1334
|
+
type: 'agent.error',
|
|
1335
|
+
error: 'No Anthropic API key configured. Go to Settings to add one.',
|
|
1336
|
+
});
|
|
1337
|
+
return;
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
const systemPrompt = loadSystemPrompt();
|
|
1341
|
+
const modelId = requestedModel || 'claude-sonnet-4-5';
|
|
1342
|
+
const model = getModel('anthropic', modelId);
|
|
1343
|
+
|
|
1344
|
+
// Merge local tools with MCP device tools (if bridge is available)
|
|
1345
|
+
const localTools = buildAgentTools();
|
|
1346
|
+
const mcpTools = await discoverMcpTools();
|
|
1347
|
+
const tools = [...localTools, ...mcpTools];
|
|
1348
|
+
|
|
1349
|
+
// Create Agent instance
|
|
1350
|
+
const agent = new Agent({
|
|
1351
|
+
initialState: {
|
|
1352
|
+
systemPrompt,
|
|
1353
|
+
model,
|
|
1354
|
+
tools,
|
|
1355
|
+
thinkingLevel: 'off',
|
|
1356
|
+
},
|
|
1357
|
+
convertToLlm: (messages) => messages.filter(m =>
|
|
1358
|
+
m.role === 'user' || m.role === 'assistant' || m.role === 'toolResult'
|
|
1359
|
+
),
|
|
1360
|
+
getApiKey: () => apiKey,
|
|
1361
|
+
});
|
|
1362
|
+
currentAgent = agent;
|
|
1363
|
+
persistedMessageCount = 0;
|
|
1364
|
+
|
|
1365
|
+
// Subscribe to events → bridge channel + mid-turn checkpointing
|
|
1366
|
+
agent.subscribe((event) => {
|
|
1367
|
+
bridgeEvent(event);
|
|
1368
|
+
// Checkpoint after each tool execution (crash loses at most the current tool call)
|
|
1369
|
+
if (event.type === 'tool_execution_end' && sessionKey) {
|
|
1370
|
+
try {
|
|
1371
|
+
const sessionsDir = join(OPENCLAW_ROOT, 'agents', agentId, 'sessions');
|
|
1372
|
+
const sessionFile = join(sessionsDir, `${sessionKey.replace('/', '_')}.jsonl`);
|
|
1373
|
+
const allMessages = agent.state.messages;
|
|
1374
|
+
for (let i = persistedMessageCount; i < allMessages.length; i++) {
|
|
1375
|
+
nodeFs.appendFileSync(sessionFile, JSON.stringify(allMessages[i]) + '\n');
|
|
1376
|
+
}
|
|
1377
|
+
persistedMessageCount = allMessages.length;
|
|
1378
|
+
} catch { /* non-fatal: full save will happen on turn completion */ }
|
|
1379
|
+
}
|
|
1380
|
+
});
|
|
1381
|
+
|
|
1382
|
+
// Run
|
|
1383
|
+
const startTime = Date.now();
|
|
1384
|
+
try {
|
|
1385
|
+
await agent.prompt(prompt);
|
|
1386
|
+
await agent.waitForIdle();
|
|
1387
|
+
|
|
1388
|
+
// Save session + send completion
|
|
1389
|
+
currentSessionKey = sessionKey;
|
|
1390
|
+
saveSession(agent, agentId, sessionKey, startTime);
|
|
1391
|
+
|
|
1392
|
+
const usage = extractUsage(agent);
|
|
1393
|
+
channel.send('message', {
|
|
1394
|
+
type: 'agent.completed',
|
|
1395
|
+
sessionKey,
|
|
1396
|
+
usage,
|
|
1397
|
+
cumulativeUsage: usage,
|
|
1398
|
+
durationMs: Date.now() - startTime,
|
|
1399
|
+
});
|
|
1400
|
+
// Keep currentAgent alive for multi-turn follow-ups
|
|
1401
|
+
currentAbortController = null;
|
|
1402
|
+
} catch (err) {
|
|
1403
|
+
const retryable = isTransientError(err);
|
|
1404
|
+
channel.send('message', {
|
|
1405
|
+
type: 'agent.error',
|
|
1406
|
+
error: err.message || 'Unknown error during agent execution',
|
|
1407
|
+
code: err.status ? String(err.status) : undefined,
|
|
1408
|
+
retryable,
|
|
1409
|
+
});
|
|
1410
|
+
if (retryable) {
|
|
1411
|
+
// Keep agent alive for retry — only clear the abort controller
|
|
1412
|
+
lastFailedPrompt = prompt;
|
|
1413
|
+
} else {
|
|
1414
|
+
currentAgent = null;
|
|
1415
|
+
currentSessionKey = null;
|
|
1416
|
+
lastFailedPrompt = null;
|
|
1417
|
+
}
|
|
1418
|
+
currentAbortController = null;
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
// ── Message handler ───────────────────────────────────────────────────────
|
|
1423
|
+
|
|
1424
|
+
channel.addListener('message', async (event) => {
|
|
1425
|
+
const msg = event;
|
|
1426
|
+
|
|
1427
|
+
switch (msg.type) {
|
|
1428
|
+
case 'agent.start': {
|
|
1429
|
+
// Idempotency: silently drop duplicate messages
|
|
1430
|
+
if (msg.idempotencyKey) {
|
|
1431
|
+
if (recentIdempotencyKeys.has(msg.idempotencyKey)) break;
|
|
1432
|
+
recentIdempotencyKeys.add(msg.idempotencyKey);
|
|
1433
|
+
if (recentIdempotencyKeys.size > MAX_IDEMPOTENCY_KEYS) {
|
|
1434
|
+
const first = recentIdempotencyKeys.values().next().value;
|
|
1435
|
+
recentIdempotencyKeys.delete(first);
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
// Echo user prompt back so the chat UI can display it
|
|
1440
|
+
channel.send('message', {
|
|
1441
|
+
type: 'agent.event',
|
|
1442
|
+
eventType: 'user_message',
|
|
1443
|
+
data: { text: msg.prompt, sessionKey: msg.sessionKey || currentSessionKey },
|
|
1444
|
+
});
|
|
1445
|
+
|
|
1446
|
+
if (currentAgent && currentAgent.state.messages.length > 0) {
|
|
1447
|
+
// Continue existing conversation — use prompt() to re-enter the agent loop.
|
|
1448
|
+
// followUp() only enqueues; it doesn't re-enter _runLoop() on an idle agent.
|
|
1449
|
+
|
|
1450
|
+
// Auto-abort in-flight turn before sending new message
|
|
1451
|
+
if (currentAgent.state.isStreaming) {
|
|
1452
|
+
currentAgent.abort();
|
|
1453
|
+
await currentAgent.waitForIdle();
|
|
1454
|
+
channel.send('message', {
|
|
1455
|
+
type: 'agent.event',
|
|
1456
|
+
eventType: 'interrupted',
|
|
1457
|
+
data: { reason: 'New message sent while streaming' },
|
|
1458
|
+
});
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
const startTime = Date.now();
|
|
1462
|
+
const agentId = msg.agentId || 'main';
|
|
1463
|
+
const sessionKey = currentSessionKey || msg.sessionKey;
|
|
1464
|
+
try {
|
|
1465
|
+
await currentAgent.prompt(msg.prompt);
|
|
1466
|
+
await currentAgent.waitForIdle();
|
|
1467
|
+
|
|
1468
|
+
saveSession(currentAgent, agentId, sessionKey, startTime);
|
|
1469
|
+
|
|
1470
|
+
const usage = extractUsage(currentAgent);
|
|
1471
|
+
channel.send('message', {
|
|
1472
|
+
type: 'agent.completed',
|
|
1473
|
+
sessionKey,
|
|
1474
|
+
usage,
|
|
1475
|
+
cumulativeUsage: usage,
|
|
1476
|
+
durationMs: Date.now() - startTime,
|
|
1477
|
+
});
|
|
1478
|
+
} catch (err) {
|
|
1479
|
+
const retryable = isTransientError(err);
|
|
1480
|
+
channel.send('message', {
|
|
1481
|
+
type: 'agent.error',
|
|
1482
|
+
error: err.message || 'Follow-up error',
|
|
1483
|
+
code: err.status ? String(err.status) : undefined,
|
|
1484
|
+
retryable,
|
|
1485
|
+
});
|
|
1486
|
+
if (retryable) {
|
|
1487
|
+
lastFailedPrompt = msg.prompt;
|
|
1488
|
+
} else {
|
|
1489
|
+
currentAgent = null;
|
|
1490
|
+
currentSessionKey = null;
|
|
1491
|
+
lastFailedPrompt = null;
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
} else {
|
|
1495
|
+
// New conversation
|
|
1496
|
+
currentAbortController = new AbortController();
|
|
1497
|
+
await runAgentLoop(msg.agentId, msg.sessionKey, msg.prompt, msg.model);
|
|
1498
|
+
}
|
|
1499
|
+
break;
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
case 'agent.stop': {
|
|
1503
|
+
if (currentAgent) {
|
|
1504
|
+
currentAgent.abort();
|
|
1505
|
+
}
|
|
1506
|
+
if (currentAbortController) {
|
|
1507
|
+
currentAbortController.abort();
|
|
1508
|
+
currentAbortController = null;
|
|
1509
|
+
}
|
|
1510
|
+
break;
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
case 'agent.retry': {
|
|
1514
|
+
// Retry last failed prompt (only works after a transient error)
|
|
1515
|
+
if (!currentAgent || !lastFailedPrompt) {
|
|
1516
|
+
channel.send('message', {
|
|
1517
|
+
type: 'agent.error',
|
|
1518
|
+
error: 'Nothing to retry — no agent or no failed prompt.',
|
|
1519
|
+
retryable: false,
|
|
1520
|
+
});
|
|
1521
|
+
break;
|
|
1522
|
+
}
|
|
1523
|
+
const retryPrompt = lastFailedPrompt;
|
|
1524
|
+
lastFailedPrompt = null;
|
|
1525
|
+
const retryAgentId = msg.agentId || 'main';
|
|
1526
|
+
const retrySessionKey = currentSessionKey || msg.sessionKey;
|
|
1527
|
+
const retryStart = Date.now();
|
|
1528
|
+
try {
|
|
1529
|
+
await currentAgent.prompt(retryPrompt);
|
|
1530
|
+
await currentAgent.waitForIdle();
|
|
1531
|
+
saveSession(currentAgent, retryAgentId, retrySessionKey, retryStart);
|
|
1532
|
+
const usage = extractUsage(currentAgent);
|
|
1533
|
+
channel.send('message', {
|
|
1534
|
+
type: 'agent.completed',
|
|
1535
|
+
sessionKey: retrySessionKey,
|
|
1536
|
+
usage,
|
|
1537
|
+
cumulativeUsage: usage,
|
|
1538
|
+
durationMs: Date.now() - retryStart,
|
|
1539
|
+
});
|
|
1540
|
+
} catch (err) {
|
|
1541
|
+
const retryable = isTransientError(err);
|
|
1542
|
+
channel.send('message', {
|
|
1543
|
+
type: 'agent.error',
|
|
1544
|
+
error: err.message || 'Retry failed',
|
|
1545
|
+
code: err.status ? String(err.status) : undefined,
|
|
1546
|
+
retryable,
|
|
1547
|
+
});
|
|
1548
|
+
if (retryable) {
|
|
1549
|
+
lastFailedPrompt = retryPrompt;
|
|
1550
|
+
} else {
|
|
1551
|
+
currentAgent = null;
|
|
1552
|
+
currentSessionKey = null;
|
|
1553
|
+
lastFailedPrompt = null;
|
|
1554
|
+
}
|
|
1555
|
+
}
|
|
1556
|
+
break;
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
case 'tool.approve': {
|
|
1560
|
+
const resolver = pendingApprovals.get(msg.toolCallId);
|
|
1561
|
+
if (resolver) {
|
|
1562
|
+
pendingApprovals.delete(msg.toolCallId);
|
|
1563
|
+
resolver(msg.approved !== false);
|
|
1564
|
+
}
|
|
1565
|
+
break;
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
case 'agent.steer': {
|
|
1569
|
+
if (currentAgent) {
|
|
1570
|
+
currentAgent.steer({ role: 'user', content: msg.text, timestamp: Date.now() });
|
|
1571
|
+
}
|
|
1572
|
+
break;
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
case 'skill.start': {
|
|
1576
|
+
if (msg.skill === 'setup') {
|
|
1577
|
+
const agentId = msg.agentId || 'main';
|
|
1578
|
+
await runSetupSkill(agentId);
|
|
1579
|
+
} else {
|
|
1580
|
+
channel.send('message', {
|
|
1581
|
+
type: 'agent.error',
|
|
1582
|
+
error: `Unknown skill: ${msg.skill}`,
|
|
1583
|
+
});
|
|
1584
|
+
}
|
|
1585
|
+
break;
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
case 'oauth.exchange': {
|
|
1589
|
+
// Perform OAuth token exchange in Node.js (bypasses Capacitor HTTP / CORS)
|
|
1590
|
+
const { tokenUrl, body } = msg;
|
|
1591
|
+
try {
|
|
1592
|
+
const resp = await fetch(tokenUrl, {
|
|
1593
|
+
method: 'POST',
|
|
1594
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1595
|
+
body: JSON.stringify(body),
|
|
1596
|
+
});
|
|
1597
|
+
const text = await resp.text();
|
|
1598
|
+
let data;
|
|
1599
|
+
try { data = JSON.parse(text); } catch { data = null; }
|
|
1600
|
+
channel.send('message', {
|
|
1601
|
+
type: 'oauth.exchange.result',
|
|
1602
|
+
success: resp.ok,
|
|
1603
|
+
status: resp.status,
|
|
1604
|
+
data,
|
|
1605
|
+
text: resp.ok ? undefined : text,
|
|
1606
|
+
});
|
|
1607
|
+
} catch (err) {
|
|
1608
|
+
channel.send('message', {
|
|
1609
|
+
type: 'oauth.exchange.result',
|
|
1610
|
+
success: false,
|
|
1611
|
+
error: err.message,
|
|
1612
|
+
});
|
|
1613
|
+
}
|
|
1614
|
+
break;
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
case 'config.update': {
|
|
1618
|
+
const { action, provider, apiKey, model } = msg.config;
|
|
1619
|
+
if (action === 'setApiKey') {
|
|
1620
|
+
const profiles = loadAuthProfiles('main');
|
|
1621
|
+
const profileKey = `${provider}:default`;
|
|
1622
|
+
profiles.profiles[profileKey] = {
|
|
1623
|
+
type: 'api_key',
|
|
1624
|
+
provider,
|
|
1625
|
+
key: apiKey,
|
|
1626
|
+
};
|
|
1627
|
+
profiles.lastGood = profiles.lastGood || {};
|
|
1628
|
+
profiles.lastGood[provider] = profileKey;
|
|
1629
|
+
saveAuthProfiles('main', profiles);
|
|
1630
|
+
channel.send('message', { type: 'config.update.result', success: true });
|
|
1631
|
+
} else if (action === 'setOAuth') {
|
|
1632
|
+
const profiles = loadAuthProfiles('main');
|
|
1633
|
+
const profileKey = `${provider}:oauth`;
|
|
1634
|
+
profiles.profiles[profileKey] = {
|
|
1635
|
+
type: 'oauth',
|
|
1636
|
+
provider,
|
|
1637
|
+
access: msg.config.accessToken,
|
|
1638
|
+
refresh: msg.config.refreshToken,
|
|
1639
|
+
expiresAt: msg.config.expiresAt,
|
|
1640
|
+
};
|
|
1641
|
+
profiles.lastGood = profiles.lastGood || {};
|
|
1642
|
+
profiles.lastGood[provider] = profileKey;
|
|
1643
|
+
saveAuthProfiles('main', profiles);
|
|
1644
|
+
channel.send('message', { type: 'config.update.result', success: true });
|
|
1645
|
+
}
|
|
1646
|
+
break;
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
case 'file.read': {
|
|
1650
|
+
const result = readFileTool({ path: msg.path });
|
|
1651
|
+
channel.send('message', {
|
|
1652
|
+
type: 'file.read.result',
|
|
1653
|
+
path: msg.path,
|
|
1654
|
+
content: result.content || '',
|
|
1655
|
+
error: result.error,
|
|
1656
|
+
});
|
|
1657
|
+
break;
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1660
|
+
case 'file.write': {
|
|
1661
|
+
writeFileTool({ path: msg.path, content: msg.content });
|
|
1662
|
+
break;
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
case 'config.status': {
|
|
1666
|
+
const profiles = loadAuthProfiles('main');
|
|
1667
|
+
let hasKey = false;
|
|
1668
|
+
let masked = '';
|
|
1669
|
+
for (const [, profile] of Object.entries(profiles.profiles)) {
|
|
1670
|
+
if (profile.provider === 'anthropic') {
|
|
1671
|
+
const key = profile.key || profile.access || '';
|
|
1672
|
+
if (key) {
|
|
1673
|
+
hasKey = true;
|
|
1674
|
+
masked = key.length > 11
|
|
1675
|
+
? key.substring(0, 7) + '***' + key.substring(key.length - 4)
|
|
1676
|
+
: '***';
|
|
1677
|
+
break;
|
|
1678
|
+
}
|
|
1679
|
+
}
|
|
1680
|
+
}
|
|
1681
|
+
channel.send('message', { type: 'config.status.result', hasKey, masked });
|
|
1682
|
+
break;
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1685
|
+
case 'config.models': {
|
|
1686
|
+
const models = [
|
|
1687
|
+
{ id: 'claude-sonnet-4-5', name: 'Claude Sonnet 4.5', description: 'Fast and capable', default: true },
|
|
1688
|
+
{ id: 'claude-haiku-3-5', name: 'Claude Haiku 3.5', description: 'Quick and lightweight' },
|
|
1689
|
+
{ id: 'claude-opus-4', name: 'Claude Opus 4', description: 'Most capable' },
|
|
1690
|
+
];
|
|
1691
|
+
channel.send('message', { type: 'config.models.result', models });
|
|
1692
|
+
break;
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
case 'session.list': {
|
|
1696
|
+
const agentId = msg.agentId || 'main';
|
|
1697
|
+
const sessionsJsonPath = join(OPENCLAW_ROOT, 'agents', agentId, 'sessions', 'sessions.json');
|
|
1698
|
+
let sessions = [];
|
|
1699
|
+
try {
|
|
1700
|
+
let raw;
|
|
1701
|
+
try { raw = JSON.parse(readFileSync(sessionsJsonPath, 'utf8')); }
|
|
1702
|
+
catch { raw = rebuildSessionIndex(agentId); }
|
|
1703
|
+
// Index can be flat { sessionKey: {...} } or nested { agentId: { sessionKey: {...} } }
|
|
1704
|
+
const entries = raw[agentId] || raw;
|
|
1705
|
+
sessions = Object.values(entries)
|
|
1706
|
+
.filter(s => s && typeof s === 'object' && s.sessionId)
|
|
1707
|
+
.map(s => ({
|
|
1708
|
+
sessionKey: s.sessionId,
|
|
1709
|
+
sessionId: s.sessionId,
|
|
1710
|
+
updatedAt: s.updatedAt || s.createdAt || 0,
|
|
1711
|
+
model: s.model,
|
|
1712
|
+
totalTokens: s.totalTokens,
|
|
1713
|
+
}));
|
|
1714
|
+
sessions.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
|
|
1715
|
+
} catch {
|
|
1716
|
+
// No sessions yet
|
|
1717
|
+
}
|
|
1718
|
+
channel.send('message', { type: 'session.list.result', agentId, sessions });
|
|
1719
|
+
break;
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
case 'session.clear': {
|
|
1723
|
+
// Clears in-memory state; JSONL transcripts are preserved for history
|
|
1724
|
+
currentAgent = null;
|
|
1725
|
+
currentSessionKey = null;
|
|
1726
|
+
currentAbortController = null;
|
|
1727
|
+
persistedMessageCount = 0;
|
|
1728
|
+
channel.send('message', { type: 'session.clear.result', success: true });
|
|
1729
|
+
break;
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
case 'session.latest': {
|
|
1733
|
+
// Returns the most recent session key from sessions.json
|
|
1734
|
+
const agentId = msg.agentId || 'main';
|
|
1735
|
+
const sessionsJsonPath = join(OPENCLAW_ROOT, 'agents', agentId, 'sessions', 'sessions.json');
|
|
1736
|
+
try {
|
|
1737
|
+
let raw;
|
|
1738
|
+
try { raw = JSON.parse(readFileSync(sessionsJsonPath, 'utf8')); }
|
|
1739
|
+
catch { raw = rebuildSessionIndex(agentId); }
|
|
1740
|
+
const entries = raw[agentId] || raw;
|
|
1741
|
+
const sorted = Object.values(entries)
|
|
1742
|
+
.filter(s => s && typeof s === 'object' && s.sessionId)
|
|
1743
|
+
.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
|
|
1744
|
+
const latest = sorted[0] || null;
|
|
1745
|
+
channel.send('message', {
|
|
1746
|
+
type: 'session.latest.result',
|
|
1747
|
+
sessionKey: latest?.sessionId || null,
|
|
1748
|
+
session: latest,
|
|
1749
|
+
});
|
|
1750
|
+
} catch {
|
|
1751
|
+
channel.send('message', { type: 'session.latest.result', sessionKey: null, session: null });
|
|
1752
|
+
}
|
|
1753
|
+
break;
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
case 'session.load': {
|
|
1757
|
+
// Reads JSONL transcript and returns messages converted to UI format
|
|
1758
|
+
const agentId = msg.agentId || 'main';
|
|
1759
|
+
const sessionKey = msg.sessionKey;
|
|
1760
|
+
if (!sessionKey) {
|
|
1761
|
+
channel.send('message', { type: 'session.load.result', error: 'No sessionKey provided', messages: [] });
|
|
1762
|
+
break;
|
|
1763
|
+
}
|
|
1764
|
+
const sessionsDir = join(OPENCLAW_ROOT, 'agents', agentId, 'sessions');
|
|
1765
|
+
const sessionFile = join(sessionsDir, `${sessionKey.replace('/', '_')}.jsonl`);
|
|
1766
|
+
try {
|
|
1767
|
+
const raw = readFileSync(sessionFile, 'utf8');
|
|
1768
|
+
const lines = raw.split('\n').filter(l => l.trim());
|
|
1769
|
+
|
|
1770
|
+
// Parse lines (skip corrupted), then deduplicate (handles legacy duplication bug)
|
|
1771
|
+
const parsed = [];
|
|
1772
|
+
for (const line of lines) {
|
|
1773
|
+
try { parsed.push(JSON.parse(line)); }
|
|
1774
|
+
catch { console.warn(`[session.load] Skipping corrupted JSONL line: ${line.substring(0, 80)}`); }
|
|
1775
|
+
}
|
|
1776
|
+
const messages = deduplicateMessages(parsed);
|
|
1777
|
+
|
|
1778
|
+
// Convert to UI message format
|
|
1779
|
+
const uiMessages = [];
|
|
1780
|
+
let seq = 0;
|
|
1781
|
+
for (const m of messages) {
|
|
1782
|
+
const ts = m.timestamp ? new Date(m.timestamp).toISOString() : new Date().toISOString();
|
|
1783
|
+
|
|
1784
|
+
if (m.role === 'user') {
|
|
1785
|
+
seq++;
|
|
1786
|
+
const text = typeof m.content === 'string' ? m.content : (Array.isArray(m.content) ? m.content.map(c => c.text || '').join('') : '');
|
|
1787
|
+
uiMessages.push({ uuid: `hist-user-${seq}`, role: 'user', content: text, created_at: ts, sequence: seq });
|
|
1788
|
+
} else if (m.role === 'assistant') {
|
|
1789
|
+
const content = m.content || [];
|
|
1790
|
+
// Split assistant message: text blocks + tool_call blocks
|
|
1791
|
+
const textParts = content.filter(c => c.type === 'text').map(c => c.text).join('');
|
|
1792
|
+
const toolCalls = content.filter(c => c.type === 'tool_call');
|
|
1793
|
+
|
|
1794
|
+
// Emit tool_use entries first
|
|
1795
|
+
for (const tc of toolCalls) {
|
|
1796
|
+
seq++;
|
|
1797
|
+
uiMessages.push({
|
|
1798
|
+
uuid: `hist-tc-${tc.id || seq}`,
|
|
1799
|
+
role: 'tool_use',
|
|
1800
|
+
tool_use_id: tc.id,
|
|
1801
|
+
tool_name: tc.name,
|
|
1802
|
+
content: tc.input,
|
|
1803
|
+
created_at: ts,
|
|
1804
|
+
sequence: seq,
|
|
1805
|
+
});
|
|
1806
|
+
}
|
|
1807
|
+
|
|
1808
|
+
// Then emit text if present
|
|
1809
|
+
if (textParts) {
|
|
1810
|
+
seq++;
|
|
1811
|
+
uiMessages.push({
|
|
1812
|
+
uuid: `hist-asst-${seq}`,
|
|
1813
|
+
role: 'assistant',
|
|
1814
|
+
content: [{ type: 'text', text: textParts }],
|
|
1815
|
+
model: m.model || 'mobile-claw',
|
|
1816
|
+
created_at: ts,
|
|
1817
|
+
sequence: seq,
|
|
1818
|
+
});
|
|
1819
|
+
}
|
|
1820
|
+
} else if (m.role === 'toolResult') {
|
|
1821
|
+
seq++;
|
|
1822
|
+
const text = Array.isArray(m.content) ? m.content.map(c => c.text || '').join('') : JSON.stringify(m.content);
|
|
1823
|
+
uiMessages.push({
|
|
1824
|
+
uuid: `hist-tr-${m.toolCallId || seq}`,
|
|
1825
|
+
role: 'tool_result',
|
|
1826
|
+
tool_use_id: m.toolCallId,
|
|
1827
|
+
content: text,
|
|
1828
|
+
is_error: m.isError || false,
|
|
1829
|
+
created_at: ts,
|
|
1830
|
+
sequence: seq,
|
|
1831
|
+
});
|
|
1832
|
+
}
|
|
1833
|
+
}
|
|
1834
|
+
|
|
1835
|
+
channel.send('message', { type: 'session.load.result', sessionKey, messages: uiMessages });
|
|
1836
|
+
} catch (err) {
|
|
1837
|
+
channel.send('message', { type: 'session.load.result', sessionKey, error: err.message, messages: [] });
|
|
1838
|
+
}
|
|
1839
|
+
break;
|
|
1840
|
+
}
|
|
1841
|
+
|
|
1842
|
+
case 'session.resume': {
|
|
1843
|
+
// Restores conversation context: creates Agent, hydrates with saved messages
|
|
1844
|
+
const agentId = msg.agentId || 'main';
|
|
1845
|
+
const sessionKey = msg.sessionKey;
|
|
1846
|
+
if (!sessionKey) {
|
|
1847
|
+
channel.send('message', { type: 'session.resume.result', error: 'No sessionKey provided' });
|
|
1848
|
+
break;
|
|
1849
|
+
}
|
|
1850
|
+
|
|
1851
|
+
const authProfiles = loadAuthProfiles(agentId);
|
|
1852
|
+
const apiKey = resolveApiKey(authProfiles);
|
|
1853
|
+
if (!apiKey) {
|
|
1854
|
+
channel.send('message', { type: 'session.resume.result', error: 'No API key configured' });
|
|
1855
|
+
break;
|
|
1856
|
+
}
|
|
1857
|
+
|
|
1858
|
+
const sessionsDir = join(OPENCLAW_ROOT, 'agents', agentId, 'sessions');
|
|
1859
|
+
const sessionFile = join(sessionsDir, `${sessionKey.replace('/', '_')}.jsonl`);
|
|
1860
|
+
try {
|
|
1861
|
+
const raw = readFileSync(sessionFile, 'utf8');
|
|
1862
|
+
const lines = raw.split('\n').filter(l => l.trim());
|
|
1863
|
+
const rawMessages = [];
|
|
1864
|
+
for (const line of lines) {
|
|
1865
|
+
try { rawMessages.push(JSON.parse(line)); }
|
|
1866
|
+
catch { console.warn(`[session.resume] Skipping corrupted JSONL line`); }
|
|
1867
|
+
}
|
|
1868
|
+
const agentMessages = deduplicateMessages(rawMessages);
|
|
1869
|
+
|
|
1870
|
+
const systemPrompt = loadSystemPrompt();
|
|
1871
|
+
const model = getModel('anthropic', 'claude-sonnet-4-5');
|
|
1872
|
+
|
|
1873
|
+
// Merge local tools with MCP device tools (if bridge is available)
|
|
1874
|
+
const localTools = buildAgentTools();
|
|
1875
|
+
const mcpTools = await discoverMcpTools();
|
|
1876
|
+
const tools = [...localTools, ...mcpTools];
|
|
1877
|
+
|
|
1878
|
+
const agent = new Agent({
|
|
1879
|
+
initialState: { systemPrompt, model, tools, thinkingLevel: 'off' },
|
|
1880
|
+
convertToLlm: (messages) => messages.filter(m =>
|
|
1881
|
+
m.role === 'user' || m.role === 'assistant' || m.role === 'toolResult'
|
|
1882
|
+
),
|
|
1883
|
+
getApiKey: () => apiKey,
|
|
1884
|
+
});
|
|
1885
|
+
|
|
1886
|
+
agent.replaceMessages(agentMessages);
|
|
1887
|
+
agent.subscribe((event) => { bridgeEvent(event); });
|
|
1888
|
+
|
|
1889
|
+
currentAgent = agent;
|
|
1890
|
+
currentSessionKey = sessionKey;
|
|
1891
|
+
persistedMessageCount = agentMessages.length;
|
|
1892
|
+
|
|
1893
|
+
channel.send('message', {
|
|
1894
|
+
type: 'session.resume.result',
|
|
1895
|
+
sessionKey,
|
|
1896
|
+
messageCount: agentMessages.length,
|
|
1897
|
+
success: true,
|
|
1898
|
+
});
|
|
1899
|
+
} catch (err) {
|
|
1900
|
+
channel.send('message', { type: 'session.resume.result', error: err.message });
|
|
1901
|
+
}
|
|
1902
|
+
break;
|
|
1903
|
+
}
|
|
1904
|
+
|
|
1905
|
+
case 'tool.invoke': {
|
|
1906
|
+
// Direct tool invocation for E2E testing — bypasses LLM agent loop
|
|
1907
|
+
const toolMap = {
|
|
1908
|
+
read_file: readFileTool,
|
|
1909
|
+
write_file: writeFileTool,
|
|
1910
|
+
list_files: listFilesTool,
|
|
1911
|
+
grep_files: grepFilesTool,
|
|
1912
|
+
find_files: findFilesTool,
|
|
1913
|
+
edit_file: editFileTool,
|
|
1914
|
+
execute_js: executeJsTool,
|
|
1915
|
+
execute_python: executePythonTool,
|
|
1916
|
+
git_init: gitInitTool,
|
|
1917
|
+
git_status: gitStatusTool,
|
|
1918
|
+
git_add: gitAddTool,
|
|
1919
|
+
git_commit: gitCommitTool,
|
|
1920
|
+
git_log: gitLogTool,
|
|
1921
|
+
git_diff: gitDiffTool,
|
|
1922
|
+
};
|
|
1923
|
+
const fn = toolMap[msg.toolName];
|
|
1924
|
+
if (!fn) {
|
|
1925
|
+
// Fallback: try MCP device tool via bridge (e.g. device_get_info, clipboard_read)
|
|
1926
|
+
try {
|
|
1927
|
+
const mcpResult = await mcpBridge.callTool(msg.toolName, msg.args || {});
|
|
1928
|
+
channel.send('message', { type: 'tool.invoke.result', toolName: msg.toolName, result: mcpResult });
|
|
1929
|
+
} catch (mcpErr) {
|
|
1930
|
+
channel.send('message', { type: 'tool.invoke.result', toolName: msg.toolName, error: mcpErr.message });
|
|
1931
|
+
}
|
|
1932
|
+
} else {
|
|
1933
|
+
try {
|
|
1934
|
+
const result = await fn(msg.args || {});
|
|
1935
|
+
channel.send('message', { type: 'tool.invoke.result', toolName: msg.toolName, result });
|
|
1936
|
+
} catch (err) {
|
|
1937
|
+
channel.send('message', { type: 'tool.invoke.result', toolName: msg.toolName, error: err.message });
|
|
1938
|
+
}
|
|
1939
|
+
}
|
|
1940
|
+
break;
|
|
1941
|
+
}
|
|
1942
|
+
|
|
1943
|
+
default:
|
|
1944
|
+
console.log('[Worker] Unknown message type:', msg.type);
|
|
1945
|
+
}
|
|
1946
|
+
});
|
|
1947
|
+
|
|
1948
|
+
// ── Init ──────────────────────────────────────────────────────────────────
|
|
1949
|
+
|
|
1950
|
+
ensureOpenClawDirs();
|
|
1951
|
+
|
|
1952
|
+
// Pre-discover MCP device tools at startup (non-blocking, best-effort).
|
|
1953
|
+
// Results are cached so the first agent run doesn't wait for discovery.
|
|
1954
|
+
discoverMcpTools().then(tools => {
|
|
1955
|
+
channel.send('message', {
|
|
1956
|
+
type: 'worker.ready',
|
|
1957
|
+
nodeVersion: process.version,
|
|
1958
|
+
openclawRoot: OPENCLAW_ROOT,
|
|
1959
|
+
mcpToolCount: tools.length,
|
|
1960
|
+
});
|
|
1961
|
+
console.log(`[mobile-claw worker] Ready. Node ${process.version}, root=${OPENCLAW_ROOT}, mcpTools=${tools.length}`);
|
|
1962
|
+
}).catch(() => {
|
|
1963
|
+
// MCP bridge not available — proceed without device tools
|
|
1964
|
+
channel.send('message', {
|
|
1965
|
+
type: 'worker.ready',
|
|
1966
|
+
nodeVersion: process.version,
|
|
1967
|
+
openclawRoot: OPENCLAW_ROOT,
|
|
1968
|
+
mcpToolCount: 0,
|
|
1969
|
+
});
|
|
1970
|
+
console.log(`[mobile-claw worker] Ready (no MCP). Node ${process.version}, root=${OPENCLAW_ROOT}`);
|
|
1971
|
+
});
|