@claw-link/gateway-host 0.1.3 → 0.2.1

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.3",
3
+ "version": "0.2.1",
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
7
  "gateway-host": "bin/cli.js",
8
+ "clhost": "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"
@@ -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
- } else if (!detected[runtime]) {
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
- 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
+ }
56
73
  }
57
74
 
58
- // Coding runtimes run inside a project workspace (host-specific, can't be pulled).
59
- if (config.WORKSPACE_RUNTIMES.includes(runtime)) {
60
- let ws = '';
61
- while (!ws) {
62
- ws = await ask(rl, ` Workspace directory for ${runtime} (the project it runs in)`, process.cwd());
63
- 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(', ')}`);
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: npx @claw-link/gateway-host start');
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 (no service reinstall).
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) { 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\`).`);
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>start</string></array>
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()} start
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()}" start`;
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: npx @claw-link/gateway-host setup'); return; }
185
- 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);
186
292
  }
187
293
 
188
- 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
@@ -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 };
@@ -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.