@claw-link/gateway-host 0.1.2 → 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 +135 -29
- package/src/adapters/cli.js +8 -1
- package/src/adapters/openclaw.js +26 -4
- package/src/config.js +14 -2
- 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,9 +11,9 @@ 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
|
-
const PROJECT_REF_DEFAULT = 'rgzinqbdnesinmbshgtc';
|
|
17
17
|
const RUNTIMES = ['openclaw', 'hermes', 'claude', 'codex', 'cursor'];
|
|
18
18
|
const SERVICE_LABEL = 'co.clawlink.host';
|
|
19
19
|
|
|
@@ -22,10 +22,10 @@ function ask(rl, q, def) {
|
|
|
22
22
|
}
|
|
23
23
|
function execSafe(cmd) { try { return execSync(cmd, { stdio: ['ignore', 'pipe', 'ignore'] }).toString(); } catch { return null; } }
|
|
24
24
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
cfg.bridge_url =
|
|
25
|
+
// The bridge URL is baked in (config.DEFAULT_BRIDGE_URL / CLAWLINK_BRIDGE_URL env)
|
|
26
|
+
// — operators never type it. We just ensure it's populated.
|
|
27
|
+
function ensureBridgeUrl(cfg) {
|
|
28
|
+
if (!cfg.bridge_url) cfg.bridge_url = config.DEFAULT_BRIDGE_URL;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
// Add one agent with ONLY its Host Token. The token resolves the agent + runtime
|
|
@@ -47,23 +47,40 @@ async function addAgentInteractive(cfg, rl) {
|
|
|
47
47
|
console.log(` ✓ Agent ${resp.agent_id} — runtime: ${runtime}`);
|
|
48
48
|
|
|
49
49
|
const detected = detectRuntimes();
|
|
50
|
+
const defaultWs = config.defaultWorkDir(runtime, resp.agent_id);
|
|
51
|
+
|
|
50
52
|
if (runtime === 'openclaw') {
|
|
51
53
|
agent.local_gateway_url = await ask(rl, ' Local OpenClaw gateway URL', 'http://localhost:3000');
|
|
52
54
|
agent.local_gateway_token = await ask(rl, ' Local OpenClaw gateway token (optional)', '');
|
|
53
|
-
|
|
54
|
-
agent.binary = await ask(rl, ` '${runtime}' not found on PATH — full path to the binary`, runtime);
|
|
55
|
+
agent.work_dir = defaultWs;
|
|
55
56
|
} else {
|
|
56
|
-
|
|
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
|
+
}
|
|
57
73
|
}
|
|
58
74
|
|
|
59
|
-
//
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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(', ')}`);
|
|
65
83
|
}
|
|
66
|
-
agent.work_dir = ws;
|
|
67
84
|
}
|
|
68
85
|
return agent;
|
|
69
86
|
}
|
|
@@ -80,7 +97,7 @@ async function run() {
|
|
|
80
97
|
console.log('\n ClawLink Host Gateway — setup\n ─────────────────────────────\n');
|
|
81
98
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
82
99
|
const cfg = config.load() || config.defaultConfig();
|
|
83
|
-
|
|
100
|
+
ensureBridgeUrl(cfg);
|
|
84
101
|
config.save(cfg);
|
|
85
102
|
|
|
86
103
|
let added = 0, more = true;
|
|
@@ -98,30 +115,99 @@ async function run() {
|
|
|
98
115
|
const installed = installService();
|
|
99
116
|
console.log(installed
|
|
100
117
|
? ' ✓ Installed + started the background service.'
|
|
101
|
-
: ' ⚠ 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');
|
|
102
119
|
console.log('\n Check status in ClawLink → Agents → Host Gateway (connection badge).\n');
|
|
103
120
|
}
|
|
104
121
|
|
|
105
122
|
// `install` — install the service only (configure agents later with `add-agent`).
|
|
106
123
|
async function installOnly() {
|
|
107
|
-
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
108
124
|
const cfg = config.load() || config.defaultConfig();
|
|
109
|
-
|
|
125
|
+
ensureBridgeUrl(cfg);
|
|
110
126
|
config.save(cfg);
|
|
111
|
-
rl.close();
|
|
112
127
|
const installed = installService();
|
|
113
128
|
console.log(installed ? ' ✓ Service installed.' : ' ⚠ Could not install the service automatically.');
|
|
114
129
|
console.log(' Next: npx @claw-link/gateway-host add-agent\n');
|
|
115
130
|
}
|
|
116
131
|
|
|
117
|
-
// `add-agent` — add one agent by token
|
|
132
|
+
// `add-agent` — add one agent by token, then restart the service to pick it up.
|
|
118
133
|
async function addAgentCmd() {
|
|
119
134
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
120
135
|
const cfg = config.load() || config.defaultConfig();
|
|
121
|
-
|
|
136
|
+
ensureBridgeUrl(cfg);
|
|
122
137
|
const agent = await addAgentInteractive(cfg, rl);
|
|
123
138
|
rl.close();
|
|
124
|
-
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\`).`);
|
|
125
211
|
}
|
|
126
212
|
|
|
127
213
|
function nodeBin() { return process.execPath; }
|
|
@@ -140,7 +226,7 @@ function installService() {
|
|
|
140
226
|
<plist version="1.0"><dict>
|
|
141
227
|
<key>Label</key><string>${SERVICE_LABEL}</string>
|
|
142
228
|
<key>ProgramArguments</key>
|
|
143
|
-
<array><string>${nodeBin()}</string><string>${cliEntry()}</string><string>
|
|
229
|
+
<array><string>${nodeBin()}</string><string>${cliEntry()}</string><string>run</string></array>
|
|
144
230
|
<key>RunAtLoad</key><true/>
|
|
145
231
|
<key>KeepAlive</key><true/>
|
|
146
232
|
<key>StandardOutPath</key><string>${logPath}</string>
|
|
@@ -162,7 +248,7 @@ Description=ClawLink Host Gateway
|
|
|
162
248
|
After=network-online.target
|
|
163
249
|
|
|
164
250
|
[Service]
|
|
165
|
-
ExecStart=${nodeBin()} ${cliEntry()}
|
|
251
|
+
ExecStart=${nodeBin()} ${cliEntry()} run
|
|
166
252
|
Restart=always
|
|
167
253
|
RestartSec=5
|
|
168
254
|
|
|
@@ -175,17 +261,37 @@ WantedBy=default.target
|
|
|
175
261
|
}
|
|
176
262
|
if (platform === 'win32') {
|
|
177
263
|
// Best effort: a scheduled task that runs at logon.
|
|
178
|
-
const taskCmd = `"${nodeBin()}" "${cliEntry()}"
|
|
264
|
+
const taskCmd = `"${nodeBin()}" "${cliEntry()}" run`;
|
|
179
265
|
return execSafe(`schtasks /Create /F /SC ONLOGON /TN ClawLinkHost /TR ${JSON.stringify(taskCmd)}`) !== null;
|
|
180
266
|
}
|
|
181
267
|
} catch { /* fall through */ }
|
|
182
268
|
return false;
|
|
183
269
|
}
|
|
184
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
|
+
|
|
185
285
|
function status() {
|
|
186
286
|
const cfg = config.load();
|
|
187
|
-
if (!cfg) { console.log('Not configured. Run:
|
|
188
|
-
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);
|
|
189
292
|
}
|
|
190
293
|
|
|
191
|
-
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
|
@@ -11,6 +11,12 @@ const path = require('path');
|
|
|
11
11
|
const HOME_DIR = path.join(os.homedir(), '.clawlink-host');
|
|
12
12
|
const CONFIG_PATH = path.join(HOME_DIR, 'config.json');
|
|
13
13
|
|
|
14
|
+
// The ClawLink host-bridge endpoint is a fixed public URL — baked in so operators
|
|
15
|
+
// only ever paste a Host Token. Override only for a self-hosted ClawLink (env var).
|
|
16
|
+
const DEFAULT_BRIDGE_URL =
|
|
17
|
+
process.env.CLAWLINK_BRIDGE_URL ||
|
|
18
|
+
'https://rgzinqbdnesinmbshgtc.supabase.co/functions/v1/host-bridge';
|
|
19
|
+
|
|
14
20
|
// Runtimes whose CLI runs inside a project WORKSPACE directory (coding agents).
|
|
15
21
|
// OpenClaw (HTTP) and Hermes (~/.hermes context) don't need one.
|
|
16
22
|
const WORKSPACE_RUNTIMES = ['claude', 'codex', 'cursor'];
|
|
@@ -22,6 +28,12 @@ function expandHome(p) {
|
|
|
22
28
|
return p;
|
|
23
29
|
}
|
|
24
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
|
+
|
|
25
37
|
function ensureHomeDir() {
|
|
26
38
|
fs.mkdirSync(HOME_DIR, { recursive: true });
|
|
27
39
|
try { fs.chmodSync(HOME_DIR, 0o700); } catch { /* best effort (e.g. Windows) */ }
|
|
@@ -29,7 +41,7 @@ function ensureHomeDir() {
|
|
|
29
41
|
|
|
30
42
|
function defaultConfig() {
|
|
31
43
|
return {
|
|
32
|
-
bridge_url:
|
|
44
|
+
bridge_url: DEFAULT_BRIDGE_URL,
|
|
33
45
|
instance_id: require('crypto').randomUUID(),
|
|
34
46
|
poll_interval_ms: 1500,
|
|
35
47
|
heartbeat_ms: 10000,
|
|
@@ -81,4 +93,4 @@ function redacted(cfg) {
|
|
|
81
93
|
return clone;
|
|
82
94
|
}
|
|
83
95
|
|
|
84
|
-
module.exports = { HOME_DIR, CONFIG_PATH, 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.
|