@claw-link/gateway-host 0.1.3 → 0.2.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/README.md +32 -11
- package/bin/cli.js +34 -12
- package/package.json +8 -3
- package/scripts/install.js +128 -19
- package/src/adapters/cli.js +8 -1
- package/src/adapters/openclaw.js +26 -4
- package/src/config.js +7 -1
- package/src/scaffold.js +51 -0
- package/src/worker.js +22 -3
- package/templates/claude/.claude/settings.json +3 -0
- package/templates/claude/CLAUDE.md +15 -0
- package/templates/codex/AGENTS.md +14 -0
- package/templates/cursor/.cursor/rules/clawlink.mdc +14 -0
- package/templates/cursor/AGENTS.md +14 -0
- package/templates/hermes/README.md +9 -0
- package/templates/openclaw/README.md +9 -0
package/README.md
CHANGED
|
@@ -43,23 +43,44 @@ Each runtime's flags can be overridden per-agent via `args_template` in the conf
|
|
|
43
43
|
(placeholders `{prompt}`, `{sessionId}`, `{systemPrompt}` are substituted **per argv
|
|
44
44
|
element** — never through a shell).
|
|
45
45
|
|
|
46
|
-
**Workspace
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
46
|
+
**Workspace & baseline files** — each agent gets a managed workspace at
|
|
47
|
+
`~/.clawlink-host/workspace/<runtime>/agents/<agent_id>` by default (Claude/Codex/Cursor
|
|
48
|
+
can be pointed at your own project folder instead). When you add an agent, if the
|
|
49
|
+
runtime's **baseline files are missing**, `add-agent` asks before adding them and
|
|
50
|
+
**never overwrites** existing files — so pointing an agent at a real project is safe.
|
|
51
|
+
Baselines per runtime:
|
|
52
|
+
|
|
53
|
+
| Runtime | Baseline files |
|
|
54
|
+
|---------|----------------|
|
|
55
|
+
| Claude | `CLAUDE.md`, **`.claude/settings.json`** (pins a default `model` — `sonnet` — so the agent never silently falls back to a weaker model) |
|
|
56
|
+
| Codex | `AGENTS.md` |
|
|
57
|
+
| Cursor | `AGENTS.md`, **`.cursor/rules/clawlink.mdc`** (always-applied agent rules) |
|
|
58
|
+
| OpenClaw / Hermes | `README.md` (their real config lives in `~/.openclaw` / `~/.hermes`) |
|
|
59
|
+
|
|
60
|
+
Change the Claude model by editing `.claude/settings.json` (e.g. `"model": "opus"`).
|
|
61
|
+
For Codex/Cursor, set the model via their CLI (`args_template` in the config, or the
|
|
62
|
+
tool's own settings).
|
|
50
63
|
|
|
51
64
|
## Commands
|
|
52
65
|
|
|
66
|
+
After install the `clhost` command is available (aliases: `gateway-host`, `claw-host`):
|
|
67
|
+
|
|
53
68
|
```bash
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
69
|
+
clhost install # install the background service (one-time)
|
|
70
|
+
clhost add-agent # add an agent by pasting its Host Token
|
|
71
|
+
clhost rm-agent <id># remove a mapped agent
|
|
72
|
+
clhost agents # list mapped agents
|
|
73
|
+
clhost status # bridge + service state + mapped agents
|
|
74
|
+
clhost start # start the background service
|
|
75
|
+
clhost stop # stop the background service
|
|
76
|
+
clhost restart # restart the service (apply config changes)
|
|
77
|
+
clhost rotate # rotate a Host Token / move to a new machine
|
|
78
|
+
clhost uninstall # stop + remove the service
|
|
79
|
+
clhost run # (internal) run the worker in the foreground — the service uses this
|
|
61
80
|
```
|
|
62
81
|
|
|
82
|
+
(Use `npx @claw-link/gateway-host <command>` before a global install.)
|
|
83
|
+
|
|
63
84
|
## Config
|
|
64
85
|
|
|
65
86
|
`~/.clawlink-host/config.json` (chmod 600):
|
package/bin/cli.js
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
'use strict';
|
|
3
|
-
// ClawLink Host Gateway CLI
|
|
3
|
+
// ClawLink Host Gateway CLI (alias: clhost).
|
|
4
|
+
// Service control: start | stop | restart · internal worker entrypoint: run
|
|
4
5
|
|
|
5
6
|
const cmd = (process.argv[2] || '').toLowerCase();
|
|
6
7
|
|
|
7
8
|
async function main() {
|
|
8
9
|
switch (cmd) {
|
|
9
|
-
|
|
10
|
+
// Internal entrypoint the background service runs — the foreground worker loop.
|
|
11
|
+
case 'run':
|
|
10
12
|
return require('../src/worker').startWorker();
|
|
11
13
|
|
|
12
14
|
case 'setup':
|
|
@@ -20,6 +22,21 @@ async function main() {
|
|
|
20
22
|
case 'add':
|
|
21
23
|
return require('../scripts/install').addAgentCmd();
|
|
22
24
|
|
|
25
|
+
case 'rm-agent':
|
|
26
|
+
case 'remove-agent':
|
|
27
|
+
case 'rm':
|
|
28
|
+
return require('../scripts/install').removeAgentCmd();
|
|
29
|
+
|
|
30
|
+
case 'agents':
|
|
31
|
+
case 'list':
|
|
32
|
+
return require('../scripts/install').listAgents();
|
|
33
|
+
|
|
34
|
+
// Control the installed background service.
|
|
35
|
+
case 'start':
|
|
36
|
+
case 'stop':
|
|
37
|
+
case 'restart':
|
|
38
|
+
return require('../scripts/install').serviceControlCmd(cmd);
|
|
39
|
+
|
|
23
40
|
case 'uninstall':
|
|
24
41
|
return require('../scripts/uninstall').run();
|
|
25
42
|
|
|
@@ -29,10 +46,10 @@ async function main() {
|
|
|
29
46
|
case 'rotate':
|
|
30
47
|
console.log([
|
|
31
48
|
'To rotate a Host Token (also how you move an agent to a new machine):',
|
|
32
|
-
' 1.
|
|
33
|
-
'
|
|
34
|
-
' 2. Copy the new token, then run:
|
|
35
|
-
'
|
|
49
|
+
' 1. ClawLink → Agents → your agent → Host Gateway → "Rotate token"',
|
|
50
|
+
' (this clears the machine binding so a new host can claim it).',
|
|
51
|
+
' 2. Copy the new token, then run: clhost add-agent',
|
|
52
|
+
' The old token + old machine stop working immediately.',
|
|
36
53
|
].join('\n'));
|
|
37
54
|
return;
|
|
38
55
|
|
|
@@ -40,21 +57,26 @@ async function main() {
|
|
|
40
57
|
case '-h':
|
|
41
58
|
case 'help':
|
|
42
59
|
console.log([
|
|
43
|
-
'ClawLink Host Gateway',
|
|
60
|
+
'ClawLink Host Gateway (clhost)',
|
|
44
61
|
'',
|
|
45
|
-
'Usage:
|
|
62
|
+
'Usage: clhost <command>',
|
|
46
63
|
' install Install the background service (one-time)',
|
|
47
64
|
' add-agent Add an agent by pasting its Host Token (pulls runtime from ClawLink)',
|
|
48
|
-
'
|
|
49
|
-
'
|
|
50
|
-
' status Show
|
|
65
|
+
' rm-agent Remove a mapped agent (clhost rm-agent <id>)',
|
|
66
|
+
' agents List mapped agents',
|
|
67
|
+
' status Show bridge, service state, and mapped agents',
|
|
68
|
+
' start Start the background service',
|
|
69
|
+
' stop Stop the background service',
|
|
70
|
+
' restart Restart the background service (apply config changes)',
|
|
71
|
+
' setup install + add-agent in one flow',
|
|
51
72
|
' rotate How to rotate a Host Token / move to a new machine',
|
|
52
73
|
' uninstall Stop + remove the background service',
|
|
74
|
+
' run (internal) run the worker in the foreground — used by the service',
|
|
53
75
|
].join('\n'));
|
|
54
76
|
return;
|
|
55
77
|
|
|
56
78
|
default:
|
|
57
|
-
console.error(`Unknown command '${cmd}'. Try:
|
|
79
|
+
console.error(`Unknown command '${cmd}'. Try: clhost help`);
|
|
58
80
|
process.exit(1);
|
|
59
81
|
}
|
|
60
82
|
}
|
package/package.json
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@claw-link/gateway-host",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "ClawLink Host Gateway — a secure, outbound-only worker that bridges a local agent CLI (OpenClaw, Hermes, Claude, Codex, Cursor) to your ClawLink agents. No inbound ports; authenticated per-agent by a Host Token.",
|
|
5
5
|
"homepage": "https://claw-link.co",
|
|
6
6
|
"bin": {
|
|
7
|
+
"clhost": "bin/cli.js",
|
|
7
8
|
"gateway-host": "bin/cli.js",
|
|
8
9
|
"claw-host": "bin/cli.js"
|
|
9
10
|
},
|
|
10
11
|
"scripts": {
|
|
11
|
-
"start": "node bin/cli.js
|
|
12
|
+
"start": "node bin/cli.js run"
|
|
12
13
|
},
|
|
13
14
|
"engines": {
|
|
14
15
|
"node": ">=18"
|
|
@@ -17,6 +18,7 @@
|
|
|
17
18
|
"bin/",
|
|
18
19
|
"scripts/",
|
|
19
20
|
"src/",
|
|
21
|
+
"templates/",
|
|
20
22
|
"README.md",
|
|
21
23
|
"SECURITY.md",
|
|
22
24
|
"LICENSE"
|
|
@@ -31,5 +33,8 @@
|
|
|
31
33
|
"ai-agent",
|
|
32
34
|
"gateway"
|
|
33
35
|
],
|
|
34
|
-
"license": "MIT"
|
|
36
|
+
"license": "MIT",
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"@claw-link/gateway-host": "^0.1.3"
|
|
39
|
+
}
|
|
35
40
|
}
|
package/scripts/install.js
CHANGED
|
@@ -11,6 +11,7 @@ const { execSync } = require('child_process');
|
|
|
11
11
|
const config = require('../src/config');
|
|
12
12
|
const { detectRuntimes } = require('../src/detect');
|
|
13
13
|
const { Bridge } = require('../src/bridge');
|
|
14
|
+
const scaffold = require('../src/scaffold');
|
|
14
15
|
|
|
15
16
|
const VERSION = (() => { try { return require('../package.json').version; } catch { return '0.0.0'; } })();
|
|
16
17
|
const RUNTIMES = ['openclaw', 'hermes', 'claude', 'codex', 'cursor'];
|
|
@@ -46,23 +47,40 @@ async function addAgentInteractive(cfg, rl) {
|
|
|
46
47
|
console.log(` ✓ Agent ${resp.agent_id} — runtime: ${runtime}`);
|
|
47
48
|
|
|
48
49
|
const detected = detectRuntimes();
|
|
50
|
+
const defaultWs = config.defaultWorkDir(runtime, resp.agent_id);
|
|
51
|
+
|
|
49
52
|
if (runtime === 'openclaw') {
|
|
50
53
|
agent.local_gateway_url = await ask(rl, ' Local OpenClaw gateway URL', 'http://localhost:3000');
|
|
51
54
|
agent.local_gateway_token = await ask(rl, ' Local OpenClaw gateway token (optional)', '');
|
|
52
|
-
|
|
53
|
-
agent.binary = await ask(rl, ` '${runtime}' not found on PATH — full path to the binary`, runtime);
|
|
55
|
+
agent.work_dir = defaultWs;
|
|
54
56
|
} else {
|
|
55
|
-
|
|
57
|
+
if (!detected[runtime]) agent.binary = await ask(rl, ` '${runtime}' not found on PATH — full path to the binary`, runtime);
|
|
58
|
+
else console.log(` using ${runtime}: ${detected[runtime]}`);
|
|
59
|
+
|
|
60
|
+
// Coding runtimes run inside a project workspace. Default to the managed
|
|
61
|
+
// workspace/<runtime>/agents/<id> folder; an explicit project path is also fine.
|
|
62
|
+
if (config.WORKSPACE_RUNTIMES.includes(runtime)) {
|
|
63
|
+
let ws = '';
|
|
64
|
+
while (!ws) {
|
|
65
|
+
ws = await ask(rl, ` Workspace directory for ${runtime}`, defaultWs);
|
|
66
|
+
const resolved = config.expandHome(ws);
|
|
67
|
+
if (resolved !== defaultWs && !fs.existsSync(resolved)) { console.log(` ⚠ ${ws} does not exist.`); ws = ''; }
|
|
68
|
+
}
|
|
69
|
+
agent.work_dir = ws;
|
|
70
|
+
} else {
|
|
71
|
+
agent.work_dir = defaultWs; // hermes — runs in its own ~/.hermes context
|
|
72
|
+
}
|
|
56
73
|
}
|
|
57
74
|
|
|
58
|
-
//
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
75
|
+
// Baseline scaffolding — opt-in, only when the files are missing, never overwrites.
|
|
76
|
+
const wd = config.expandHome(agent.work_dir);
|
|
77
|
+
const missing = scaffold.missingBaselineFiles(runtime, wd);
|
|
78
|
+
if (missing.length) {
|
|
79
|
+
const ans = await ask(rl, ` Baseline ${runtime} agent files are missing in ${agent.work_dir} (${missing.join(', ')}). Add them? (Y/n)`, 'Y');
|
|
80
|
+
if (ans.toLowerCase().startsWith('y')) {
|
|
81
|
+
const copied = scaffold.copyBaseline(runtime, wd);
|
|
82
|
+
console.log(` ✓ Added baseline file(s): ${copied.join(', ')}`);
|
|
64
83
|
}
|
|
65
|
-
agent.work_dir = ws;
|
|
66
84
|
}
|
|
67
85
|
return agent;
|
|
68
86
|
}
|
|
@@ -97,7 +115,7 @@ async function run() {
|
|
|
97
115
|
const installed = installService();
|
|
98
116
|
console.log(installed
|
|
99
117
|
? ' ✓ Installed + started the background service.'
|
|
100
|
-
: ' ⚠ Could not install a background service automatically.\n Run it yourself with:
|
|
118
|
+
: ' ⚠ Could not install a background service automatically.\n Run it yourself with: clhost run');
|
|
101
119
|
console.log('\n Check status in ClawLink → Agents → Host Gateway (connection badge).\n');
|
|
102
120
|
}
|
|
103
121
|
|
|
@@ -111,14 +129,85 @@ async function installOnly() {
|
|
|
111
129
|
console.log(' Next: npx @claw-link/gateway-host add-agent\n');
|
|
112
130
|
}
|
|
113
131
|
|
|
114
|
-
// `add-agent` — add one agent by token
|
|
132
|
+
// `add-agent` — add one agent by token, then restart the service to pick it up.
|
|
115
133
|
async function addAgentCmd() {
|
|
116
134
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
117
135
|
const cfg = config.load() || config.defaultConfig();
|
|
118
136
|
ensureBridgeUrl(cfg);
|
|
119
137
|
const agent = await addAgentInteractive(cfg, rl);
|
|
120
138
|
rl.close();
|
|
121
|
-
if (agent) {
|
|
139
|
+
if (agent) {
|
|
140
|
+
saveAgent(cfg, agent);
|
|
141
|
+
const restarted = serviceControl('restart');
|
|
142
|
+
console.log(`\n ✓ Added agent ${agent.agent_id} (${agent.runtime}).` +
|
|
143
|
+
(restarted ? ' Service restarted.' : ' Run `clhost restart` (or start) to apply.') + '\n');
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// `rm-agent [id-or-prefix]` — remove a mapped agent from the config.
|
|
148
|
+
async function removeAgentCmd() {
|
|
149
|
+
const cfg = config.load();
|
|
150
|
+
if (!cfg || !(cfg.agents || []).length) { console.log('No agents configured.'); return; }
|
|
151
|
+
let target = (process.argv[3] || '').trim();
|
|
152
|
+
if (!target) {
|
|
153
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
154
|
+
printAgents(cfg);
|
|
155
|
+
target = await ask(rl, '\n Agent ID (or prefix) to remove');
|
|
156
|
+
rl.close();
|
|
157
|
+
}
|
|
158
|
+
if (!target) { console.log(' (nothing removed)'); return; }
|
|
159
|
+
const before = cfg.agents.length;
|
|
160
|
+
cfg.agents = cfg.agents.filter((a) => !(a.agent_id && (a.agent_id === target || a.agent_id.startsWith(target))));
|
|
161
|
+
if (cfg.agents.length === before) { console.log(` No agent matched '${target}'.`); return; }
|
|
162
|
+
config.save(cfg);
|
|
163
|
+
const restarted = serviceControl('restart');
|
|
164
|
+
console.log(` ✓ Removed agent '${target}'.` + (restarted ? ' Service restarted.' : ' Run `clhost restart` to apply.'));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function printAgents(cfg) {
|
|
168
|
+
const agents = (cfg && cfg.agents) || [];
|
|
169
|
+
if (!agents.length) { console.log(' No agents mapped. Run: clhost add-agent'); return; }
|
|
170
|
+
console.log(` ${agents.length} agent(s) mapped:`);
|
|
171
|
+
for (const a of agents) {
|
|
172
|
+
const id = (a.agent_id || '(unresolved)').slice(0, 8);
|
|
173
|
+
const tok = a.host_token ? `…${String(a.host_token).slice(-4)}` : '—';
|
|
174
|
+
console.log(` • ${id}… runtime=${a.runtime || 'pull'} token=${tok}` + (a.work_dir ? ` work=${a.work_dir}` : ''));
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// `agents` — list mapped agents.
|
|
179
|
+
function listAgents() { printAgents(config.load() || {}); }
|
|
180
|
+
|
|
181
|
+
// `start | stop | restart` — control the installed background service.
|
|
182
|
+
function serviceControl(action) {
|
|
183
|
+
const platform = process.platform;
|
|
184
|
+
try {
|
|
185
|
+
if (platform === 'darwin') {
|
|
186
|
+
const uid = (execSafe('id -u') || '').trim();
|
|
187
|
+
const label = SERVICE_LABEL;
|
|
188
|
+
if (action === 'restart') return execSafe(`launchctl kickstart -k "gui/${uid}/${label}"`) !== null;
|
|
189
|
+
if (action === 'stop') return execSafe(`launchctl bootout "gui/${uid}/${label}"`) !== null || execSafe(`launchctl kill SIGTERM "gui/${uid}/${label}"`) !== null;
|
|
190
|
+
if (action === 'start') {
|
|
191
|
+
const plist = path.join(os.homedir(), 'Library', 'LaunchAgents', `${label}.plist`);
|
|
192
|
+
execSafe(`launchctl bootstrap "gui/${uid}" "${plist}"`);
|
|
193
|
+
return execSafe(`launchctl kickstart "gui/${uid}/${label}"`) !== null;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
if (platform === 'linux') {
|
|
197
|
+
return execSafe(`systemctl --user ${action} clawlink-host`) !== null;
|
|
198
|
+
}
|
|
199
|
+
if (platform === 'win32') {
|
|
200
|
+
if (action === 'stop') return execSafe('schtasks /End /TN ClawLinkHost') !== null;
|
|
201
|
+
if (action === 'start') return execSafe('schtasks /Run /TN ClawLinkHost') !== null;
|
|
202
|
+
if (action === 'restart') { execSafe('schtasks /End /TN ClawLinkHost'); return execSafe('schtasks /Run /TN ClawLinkHost') !== null; }
|
|
203
|
+
}
|
|
204
|
+
} catch { /* fall through */ }
|
|
205
|
+
return false;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function serviceControlCmd(action) {
|
|
209
|
+
const ok = serviceControl(action);
|
|
210
|
+
console.log(ok ? ` ✓ Service ${action}ed.` : ` ⚠ Could not ${action} the service (is it installed? run \`clhost install\`).`);
|
|
122
211
|
}
|
|
123
212
|
|
|
124
213
|
function nodeBin() { return process.execPath; }
|
|
@@ -137,7 +226,7 @@ function installService() {
|
|
|
137
226
|
<plist version="1.0"><dict>
|
|
138
227
|
<key>Label</key><string>${SERVICE_LABEL}</string>
|
|
139
228
|
<key>ProgramArguments</key>
|
|
140
|
-
<array><string>${nodeBin()}</string><string>${cliEntry()}</string><string>
|
|
229
|
+
<array><string>${nodeBin()}</string><string>${cliEntry()}</string><string>run</string></array>
|
|
141
230
|
<key>RunAtLoad</key><true/>
|
|
142
231
|
<key>KeepAlive</key><true/>
|
|
143
232
|
<key>StandardOutPath</key><string>${logPath}</string>
|
|
@@ -159,7 +248,7 @@ Description=ClawLink Host Gateway
|
|
|
159
248
|
After=network-online.target
|
|
160
249
|
|
|
161
250
|
[Service]
|
|
162
|
-
ExecStart=${nodeBin()} ${cliEntry()}
|
|
251
|
+
ExecStart=${nodeBin()} ${cliEntry()} run
|
|
163
252
|
Restart=always
|
|
164
253
|
RestartSec=5
|
|
165
254
|
|
|
@@ -172,17 +261,37 @@ WantedBy=default.target
|
|
|
172
261
|
}
|
|
173
262
|
if (platform === 'win32') {
|
|
174
263
|
// Best effort: a scheduled task that runs at logon.
|
|
175
|
-
const taskCmd = `"${nodeBin()}" "${cliEntry()}"
|
|
264
|
+
const taskCmd = `"${nodeBin()}" "${cliEntry()}" run`;
|
|
176
265
|
return execSafe(`schtasks /Create /F /SC ONLOGON /TN ClawLinkHost /TR ${JSON.stringify(taskCmd)}`) !== null;
|
|
177
266
|
}
|
|
178
267
|
} catch { /* fall through */ }
|
|
179
268
|
return false;
|
|
180
269
|
}
|
|
181
270
|
|
|
271
|
+
function serviceRunning() {
|
|
272
|
+
const platform = process.platform;
|
|
273
|
+
if (platform === 'darwin') {
|
|
274
|
+
const uid = (execSafe('id -u') || '').trim();
|
|
275
|
+
const out = execSafe(`launchctl print "gui/${uid}/${SERVICE_LABEL}"`);
|
|
276
|
+
if (out === null) return false;
|
|
277
|
+
const m = out.match(/state\s*=\s*(\w+)/);
|
|
278
|
+
return m ? m[1] === 'running' : true; // present but state unknown → assume running
|
|
279
|
+
}
|
|
280
|
+
if (platform === 'linux') return (execSafe('systemctl --user is-active clawlink-host') || '').trim() === 'active';
|
|
281
|
+
if (platform === 'win32') return (execSafe('schtasks /Query /TN ClawLinkHost') || '').toLowerCase().includes('running');
|
|
282
|
+
return false;
|
|
283
|
+
}
|
|
284
|
+
|
|
182
285
|
function status() {
|
|
183
286
|
const cfg = config.load();
|
|
184
|
-
if (!cfg) { console.log('Not configured. Run:
|
|
185
|
-
console.log(
|
|
287
|
+
if (!cfg) { console.log('Not configured. Run: clhost setup'); return; }
|
|
288
|
+
console.log(` Bridge: ${cfg.bridge_url}`);
|
|
289
|
+
console.log(` Service: ${serviceRunning() ? '● running' : '○ stopped'}`);
|
|
290
|
+
console.log('');
|
|
291
|
+
printAgents(cfg);
|
|
186
292
|
}
|
|
187
293
|
|
|
188
|
-
module.exports = {
|
|
294
|
+
module.exports = {
|
|
295
|
+
run, installOnly, addAgentCmd, removeAgentCmd, listAgents, status,
|
|
296
|
+
serviceControl, serviceControlCmd, installService, SERVICE_LABEL,
|
|
297
|
+
};
|
package/src/adapters/cli.js
CHANGED
|
@@ -59,7 +59,14 @@ function makeCliAdapter(cfg) {
|
|
|
59
59
|
const binary = agent.binary || cfg.binaries[0];
|
|
60
60
|
const userPrompt = job.content || '';
|
|
61
61
|
const systemPrompt = job.system_prompt || '';
|
|
62
|
-
|
|
62
|
+
// Text CLIs can't take vision blocks — surface attachment URLs as references so
|
|
63
|
+
// the model is at least aware of them (override via args_template for file-aware CLIs).
|
|
64
|
+
const atts = Array.isArray(job.attachments) ? job.attachments : [];
|
|
65
|
+
const attBlock = atts.length
|
|
66
|
+
? '\n\nAttachments:\n' + atts.map((a) => `- ${typeof a === 'string' ? a : (a && a.url) || ''}`).join('\n')
|
|
67
|
+
: '';
|
|
68
|
+
const base = systemPrompt ? `${systemPrompt}\n\n---\n\nUser request:\n${userPrompt}` : userPrompt;
|
|
69
|
+
const fullPrompt = base + attBlock;
|
|
63
70
|
const clSession = job.session_key || job.session_id || null;
|
|
64
71
|
const resumeId = clSession ? sessionStore.get(cfg.name, clSession) : null;
|
|
65
72
|
// 0 = no timeout (default) — don't cut off long generations. Opt in with CLAWHOST_TIMEOUT_MS.
|
package/src/adapters/openclaw.js
CHANGED
|
@@ -29,7 +29,24 @@ module.exports = {
|
|
|
29
29
|
const { url, token } = resolveLocalGateway(agent);
|
|
30
30
|
const messages = [];
|
|
31
31
|
if (job.system_prompt) messages.push({ role: 'system', content: job.system_prompt });
|
|
32
|
-
|
|
32
|
+
|
|
33
|
+
// Attachments → multimodal user content (OpenAI-vision compatible), mirroring
|
|
34
|
+
// the inbound formatContentWithAttachments behaviour.
|
|
35
|
+
const atts = Array.isArray(job.attachments) ? job.attachments : [];
|
|
36
|
+
if (atts.length === 0) {
|
|
37
|
+
messages.push({ role: 'user', content: job.content || '' });
|
|
38
|
+
} else {
|
|
39
|
+
const parts = [];
|
|
40
|
+
if (job.content) parts.push({ type: 'text', text: job.content });
|
|
41
|
+
for (const a of atts) {
|
|
42
|
+
const u = typeof a === 'string' ? a : (a && a.url) || '';
|
|
43
|
+
if (!u) continue;
|
|
44
|
+
const isImage = u.startsWith('data:image/') || /\.(jpeg|jpg|png|webp|gif)($|\?)/i.test(u);
|
|
45
|
+
if (isImage) parts.push({ type: 'image_url', image_url: { url: u } });
|
|
46
|
+
else parts.push({ type: 'text', text: `\n[Attached file: ${u}]` });
|
|
47
|
+
}
|
|
48
|
+
messages.push({ role: 'user', content: parts });
|
|
49
|
+
}
|
|
33
50
|
|
|
34
51
|
// No timeout by default — long generations must not be dropped (production
|
|
35
52
|
// saw the 10-min cap kill legitimate long runs). Opt in via CLAWHOST_TIMEOUT_MS.
|
|
@@ -52,9 +69,14 @@ module.exports = {
|
|
|
52
69
|
const data = await res.json();
|
|
53
70
|
const content = data?.choices?.[0]?.message?.content ?? '';
|
|
54
71
|
const toolCalls = data?.choices?.[0]?.message?.tool_calls ?? [];
|
|
55
|
-
|
|
56
|
-
const
|
|
57
|
-
|
|
72
|
+
if (Array.isArray(toolCalls) && toolCalls.length) {
|
|
73
|
+
for (const tc of toolCalls) {
|
|
74
|
+
const name = tc.function?.name || tc.name;
|
|
75
|
+
if (name) yield { type: 'tool', content: name }; // activity-log line
|
|
76
|
+
}
|
|
77
|
+
// Structured: carries function.name + function.arguments so the worker can
|
|
78
|
+
// forward them to host-bridge → extractClomOutput (rich UI / clawlink_ui).
|
|
79
|
+
yield { type: 'tool_calls', toolCalls };
|
|
58
80
|
}
|
|
59
81
|
if (content) yield { type: 'chunk', content };
|
|
60
82
|
},
|
package/src/config.js
CHANGED
|
@@ -28,6 +28,12 @@ function expandHome(p) {
|
|
|
28
28
|
return p;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
// Default per-agent workspace: <home>/workspace/<runtime>/agents/<agent_id>.
|
|
32
|
+
// Used unless the operator sets an explicit work_dir.
|
|
33
|
+
function defaultWorkDir(runtime, agentId) {
|
|
34
|
+
return path.join(HOME_DIR, 'workspace', String(runtime || 'agent'), 'agents', String(agentId || 'default'));
|
|
35
|
+
}
|
|
36
|
+
|
|
31
37
|
function ensureHomeDir() {
|
|
32
38
|
fs.mkdirSync(HOME_DIR, { recursive: true });
|
|
33
39
|
try { fs.chmodSync(HOME_DIR, 0o700); } catch { /* best effort (e.g. Windows) */ }
|
|
@@ -87,4 +93,4 @@ function redacted(cfg) {
|
|
|
87
93
|
return clone;
|
|
88
94
|
}
|
|
89
95
|
|
|
90
|
-
module.exports = { HOME_DIR, CONFIG_PATH, DEFAULT_BRIDGE_URL, WORKSPACE_RUNTIMES, expandHome, ensureHomeDir, defaultConfig, load, save, normalizeAgent, redacted };
|
|
96
|
+
module.exports = { HOME_DIR, CONFIG_PATH, DEFAULT_BRIDGE_URL, WORKSPACE_RUNTIMES, expandHome, defaultWorkDir, ensureHomeDir, defaultConfig, load, save, normalizeAgent, redacted };
|
package/src/scaffold.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Per-runtime baseline files copied into an agent's workspace. Recursive (so nested
|
|
3
|
+
// files like .claude/settings.json and .cursor/rules/*.mdc are handled). We NEVER
|
|
4
|
+
// overwrite existing files (safe to point an agent at a real project), and copying
|
|
5
|
+
// is opt-in: callers check missingBaselineFiles() and prompt before copyBaseline().
|
|
6
|
+
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
|
|
10
|
+
const TEMPLATES_DIR = path.join(__dirname, '..', 'templates');
|
|
11
|
+
|
|
12
|
+
function templateDir(runtime) {
|
|
13
|
+
return path.join(TEMPLATES_DIR, String(runtime || ''));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// All baseline files for a runtime, as paths relative to the template root.
|
|
17
|
+
function requiredFiles(runtime) {
|
|
18
|
+
const root = templateDir(runtime);
|
|
19
|
+
const out = [];
|
|
20
|
+
const walk = (dir, rel) => {
|
|
21
|
+
let entries;
|
|
22
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
23
|
+
for (const e of entries) {
|
|
24
|
+
const abs = path.join(dir, e.name);
|
|
25
|
+
const r = rel ? path.join(rel, e.name) : e.name;
|
|
26
|
+
if (e.isDirectory()) walk(abs, r);
|
|
27
|
+
else if (e.isFile()) out.push(r);
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
walk(root, '');
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Which baseline files (relative paths) are NOT already present in workDir.
|
|
35
|
+
function missingBaselineFiles(runtime, workDir) {
|
|
36
|
+
return requiredFiles(runtime).filter((rel) => !fs.existsSync(path.join(workDir, rel)));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Copy only the missing baseline files into workDir (creating parent dirs).
|
|
40
|
+
function copyBaseline(runtime, workDir) {
|
|
41
|
+
const copied = [];
|
|
42
|
+
for (const rel of missingBaselineFiles(runtime, workDir)) {
|
|
43
|
+
const dest = path.join(workDir, rel);
|
|
44
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
45
|
+
fs.copyFileSync(path.join(templateDir(runtime), rel), dest);
|
|
46
|
+
copied.push(rel);
|
|
47
|
+
}
|
|
48
|
+
return copied;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
module.exports = { requiredFiles, missingBaselineFiles, copyBaseline };
|
package/src/worker.js
CHANGED
|
@@ -13,6 +13,11 @@ const { detectRuntimes } = require('./detect');
|
|
|
13
13
|
|
|
14
14
|
const VERSION = (() => { try { return require('../package.json').version; } catch { return '0.0.0'; } })();
|
|
15
15
|
|
|
16
|
+
// Hard upper bound so a truly-hung runtime can't pin a concurrency slot forever.
|
|
17
|
+
// Generous (matches the server's 15-min reaper) so legitimate long generations,
|
|
18
|
+
// which keep producing output, are never the ones that hit it.
|
|
19
|
+
const JOB_MAX_MS = Number(process.env.CLAWHOST_JOB_MAX_MS) || 15 * 60 * 1000;
|
|
20
|
+
|
|
16
21
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
17
22
|
let stopping = false;
|
|
18
23
|
|
|
@@ -21,14 +26,18 @@ async function processJob(bridge, agentCfg, job) {
|
|
|
21
26
|
const adapter = getAdapter(agentCfg.runtime);
|
|
22
27
|
let seq = 0;
|
|
23
28
|
let finalText = '';
|
|
29
|
+
let toolCalls = [];
|
|
24
30
|
try {
|
|
25
31
|
for await (const evt of adapter.run({ agent: agentCfg, job, logger })) {
|
|
26
32
|
if (!evt || !evt.type) continue;
|
|
33
|
+
// Structured tool calls aren't streamed as a chunk — collected for the final
|
|
34
|
+
// metadata so the server can extract rich UI (clawlink_ui).
|
|
35
|
+
if (evt.type === 'tool_calls') { if (Array.isArray(evt.toolCalls)) toolCalls = toolCalls.concat(evt.toolCalls); continue; }
|
|
27
36
|
if (evt.type === 'chunk') finalText += evt.content || '';
|
|
28
37
|
try { await bridge.stream(job.id, seq++, evt.type, String(evt.content ?? '')); }
|
|
29
38
|
catch (e) { logger.warn(`stream seq ${seq} failed: ${e.message}`); }
|
|
30
39
|
}
|
|
31
|
-
await withRetry(() => bridge.complete(job.id, finalText));
|
|
40
|
+
await withRetry(() => bridge.complete(job.id, finalText, toolCalls.length ? { tool_calls: toolCalls } : {}));
|
|
32
41
|
logger.info(`job ${job.id} done (${finalText.length} chars)`);
|
|
33
42
|
} catch (e) {
|
|
34
43
|
logger.error(`job ${job.id} failed: ${e.message}`);
|
|
@@ -55,7 +64,7 @@ async function runAgentLoop(agentCfg, cfg) {
|
|
|
55
64
|
if (found) agentCfg.binary = found;
|
|
56
65
|
else logger.warn(`no '${agentCfg.runtime}' binary found on PATH — set "binary" in config for ${agentCfg.agent_id}`);
|
|
57
66
|
}
|
|
58
|
-
if (!agentCfg.work_dir) agentCfg.work_dir =
|
|
67
|
+
if (!agentCfg.work_dir) agentCfg.work_dir = config.defaultWorkDir(agentCfg.runtime, agentCfg.agent_id);
|
|
59
68
|
|
|
60
69
|
logger.info(`registered ${agentCfg.agent_id} as '${agentCfg.runtime}'`);
|
|
61
70
|
} catch (e) {
|
|
@@ -79,7 +88,17 @@ async function runAgentLoop(agentCfg, cfg) {
|
|
|
79
88
|
catch (e) { logger.warn(`claim failed (${agentCfg.agent_id}): ${e.message}`); }
|
|
80
89
|
if (job) {
|
|
81
90
|
inFlight++; starts.push(Date.now());
|
|
82
|
-
|
|
91
|
+
// Watchdog: guarantees the slot is reclaimed and the job is failed back even
|
|
92
|
+
// if the adapter never settles (no per-adapter timeout by default).
|
|
93
|
+
const watchdog = new Promise((resolve) => setTimeout(() => resolve('__watchdog__'), JOB_MAX_MS).unref());
|
|
94
|
+
Promise.race([processJob(bridge, agentCfg, job), watchdog])
|
|
95
|
+
.then(async (r) => {
|
|
96
|
+
if (r === '__watchdog__') {
|
|
97
|
+
logger.error(`job ${job.id} exceeded ${JOB_MAX_MS}ms — reclaiming slot`);
|
|
98
|
+
try { await bridge.fail(job.id, `host watchdog: job exceeded ${JOB_MAX_MS}ms`); } catch { /* ignore */ }
|
|
99
|
+
}
|
|
100
|
+
})
|
|
101
|
+
.finally(() => { inFlight--; });
|
|
83
102
|
continue; // try to fill remaining concurrency immediately
|
|
84
103
|
}
|
|
85
104
|
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# ClawLink Agent Workspace (Claude)
|
|
2
|
+
|
|
3
|
+
This folder is the working directory for a **ClawLink** customer-facing agent run
|
|
4
|
+
locally via the ClawLink Host Gateway (`@claw-link/gateway-host`). Claude Code reads
|
|
5
|
+
this `CLAUDE.md` as project context.
|
|
6
|
+
|
|
7
|
+
Operating rules for this agent:
|
|
8
|
+
- The agent's **persona, applied skills, and per-conversation instructions arrive in
|
|
9
|
+
the system prompt of every request** — follow them as the source of truth.
|
|
10
|
+
- Keep replies helpful, accurate, and on-task for the business you represent.
|
|
11
|
+
- Stay in scope: do not run destructive commands or act outside the customer's request.
|
|
12
|
+
- Never reveal secrets, tokens, config files, or host/system details.
|
|
13
|
+
|
|
14
|
+
Add any project-specific context, knowledge, or files here — they become part of the
|
|
15
|
+
agent's working directory.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# ClawLink Agent Workspace (Codex)
|
|
2
|
+
|
|
3
|
+
This folder is the working directory for a **ClawLink** customer-facing agent run
|
|
4
|
+
locally via the ClawLink Host Gateway (`@claw-link/gateway-host`). Codex reads
|
|
5
|
+
`AGENTS.md` as project context.
|
|
6
|
+
|
|
7
|
+
Operating rules for this agent:
|
|
8
|
+
- The agent's **persona, applied skills, and per-conversation instructions arrive in
|
|
9
|
+
the system prompt of every request** — follow them as the source of truth.
|
|
10
|
+
- Keep replies helpful, accurate, and on-task for the business you represent.
|
|
11
|
+
- Stay in scope: do not run destructive commands or act outside the customer's request.
|
|
12
|
+
- Never reveal secrets, tokens, config files, or host/system details.
|
|
13
|
+
|
|
14
|
+
Add any project-specific context, knowledge, or files here.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: ClawLink customer-facing agent rules
|
|
3
|
+
alwaysApply: true
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# ClawLink Agent Rules
|
|
7
|
+
|
|
8
|
+
This workspace runs a ClawLink customer-facing agent via the ClawLink Host Gateway.
|
|
9
|
+
|
|
10
|
+
- The agent's **persona, applied skills, and per-conversation instructions arrive in
|
|
11
|
+
the system prompt of every request** — treat them as the source of truth.
|
|
12
|
+
- Keep replies helpful, accurate, and on-task for the business you represent.
|
|
13
|
+
- Stay in scope: do not run destructive commands or act outside the customer's request.
|
|
14
|
+
- Never reveal secrets, tokens, config files, or host/system details.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# ClawLink Agent Workspace (Cursor)
|
|
2
|
+
|
|
3
|
+
This folder is the working directory for a **ClawLink** customer-facing agent run
|
|
4
|
+
locally via the ClawLink Host Gateway (`@claw-link/gateway-host`). The Cursor agent
|
|
5
|
+
reads `AGENTS.md` as project context.
|
|
6
|
+
|
|
7
|
+
Operating rules for this agent:
|
|
8
|
+
- The agent's **persona, applied skills, and per-conversation instructions arrive in
|
|
9
|
+
the system prompt of every request** — follow them as the source of truth.
|
|
10
|
+
- Keep replies helpful, accurate, and on-task for the business you represent.
|
|
11
|
+
- Stay in scope: do not run destructive commands or act outside the customer's request.
|
|
12
|
+
- Never reveal secrets, tokens, config files, or host/system details.
|
|
13
|
+
|
|
14
|
+
Add any project-specific context, knowledge, or files here.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# ClawLink Agent Workspace (Hermes)
|
|
2
|
+
|
|
3
|
+
This folder is a per-agent working directory created by the ClawLink Host Gateway
|
|
4
|
+
(`@claw-link/gateway-host`). For the **Hermes** runtime, the agent's real identity and
|
|
5
|
+
memory live under `~/.hermes/` (SOUL.md, memories, skills), so this folder is mostly a
|
|
6
|
+
scratch/marker directory the CLI runs in.
|
|
7
|
+
|
|
8
|
+
Persona, skills, and per-conversation instructions are delivered by ClawLink in the
|
|
9
|
+
system prompt of each request.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# ClawLink Agent Workspace (OpenClaw)
|
|
2
|
+
|
|
3
|
+
This folder is a per-agent working directory created by the ClawLink Host Gateway
|
|
4
|
+
(`@claw-link/gateway-host`). For the **OpenClaw** runtime, requests are forwarded to
|
|
5
|
+
your local OpenClaw gateway over HTTP — the OpenClaw agent's real workspace lives under
|
|
6
|
+
`~/.openclaw/agents/<id>/`, so this folder is mostly a scratch/marker directory.
|
|
7
|
+
|
|
8
|
+
Persona, skills, and per-conversation instructions are delivered by ClawLink in the
|
|
9
|
+
system prompt of each request.
|