@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 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 directory** — Claude, Codex, and Cursor are coding agents that run inside
47
- a project directory. The setup wizard asks for that **workspace directory** per agent
48
- and the CLI is run there (`cwd`). OpenClaw (HTTP) and Hermes (its own `~/.hermes`
49
- context) don't use one.
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
- npx @claw-link/gateway-host install # install the background service (one-time)
55
- npx @claw-link/gateway-host add-agent # add an agent by pasting its Host Token
56
- npx @claw-link/gateway-host setup # install + add-agent in one flow
57
- npx @claw-link/gateway-host start # run in the foreground
58
- npx @claw-link/gateway-host status # show configured agents (secrets redacted)
59
- npx @claw-link/gateway-host rotate # rotate a Host Token / move to a new machine
60
- npx @claw-link/gateway-host uninstall # stop + remove the service
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. Commands: setup | start | status | rotate | uninstall
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
- case 'start':
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. Open ClawLink → Agents → your agent → Host Gateway → "Rotate token".',
33
- ' This also clears the machine binding, so a new host can claim it.',
34
- ' 2. Copy the new token, then run: npx @claw-link/gateway-host add-agent',
35
- ' (paste the new token). The old token + old machine stop working immediately.',
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: gateway-host <command>',
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
- ' setup install + add-agent in one flow (default)',
49
- ' start Run the worker in the foreground (used by the service)',
50
- ' status Show configured agents + last connection',
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: gateway-host help`);
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.1.2",
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 start"
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
  }
@@ -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
- async function ensureBridgeUrl(cfg, rl) {
26
- if (cfg.bridge_url) return;
27
- const ref = await ask(rl, ' Supabase project ref', PROJECT_REF_DEFAULT);
28
- cfg.bridge_url = `https://${ref}.supabase.co/functions/v1/host-bridge`;
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
- } else if (!detected[runtime]) {
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
- console.log(` using ${runtime}: ${detected[runtime]}`);
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
- // Coding runtimes run inside a project workspace (host-specific, can't be pulled).
60
- if (config.WORKSPACE_RUNTIMES.includes(runtime)) {
61
- let ws = '';
62
- while (!ws) {
63
- ws = await ask(rl, ` Workspace directory for ${runtime} (the project it runs in)`, process.cwd());
64
- if (ws && !fs.existsSync(config.expandHome(ws))) { console.log(` ⚠ ${ws} does not exist.`); ws = ''; }
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
- await ensureBridgeUrl(cfg, rl);
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: npx @claw-link/gateway-host start');
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
- await ensureBridgeUrl(cfg, rl);
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 (no service reinstall).
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
- await ensureBridgeUrl(cfg, rl);
136
+ ensureBridgeUrl(cfg);
122
137
  const agent = await addAgentInteractive(cfg, rl);
123
138
  rl.close();
124
- if (agent) { saveAgent(cfg, agent); console.log(`\n ✓ Added agent ${agent.agent_id}. Restart the service or it will pick it up shortly.\n`); }
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>start</string></array>
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()} start
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()}" start`;
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: npx @claw-link/gateway-host setup'); return; }
188
- console.log(JSON.stringify(config.redacted(cfg), null, 2));
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 = { run, installOnly, addAgentCmd, status, installService, SERVICE_LABEL };
294
+ module.exports = {
295
+ run, installOnly, addAgentCmd, removeAgentCmd, listAgents, status,
296
+ serviceControl, serviceControlCmd, installService, SERVICE_LABEL,
297
+ };
@@ -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
- const fullPrompt = systemPrompt ? `${systemPrompt}\n\n---\n\nUser request:\n${userPrompt}` : userPrompt;
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.
@@ -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
- messages.push({ role: 'user', content: job.content || '' });
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
- for (const tc of toolCalls) {
56
- const name = tc.function?.name || tc.name;
57
- if (name) yield { type: 'tool', content: name };
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 };
@@ -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 = path.join(config.HOME_DIR, 'work', agentCfg.agent_id || 'default');
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
- processJob(bridge, agentCfg, job).finally(() => { inFlight--; });
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,3 @@
1
+ {
2
+ "model": "sonnet"
3
+ }
@@ -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.