@claw-link/gateway-host 0.3.7 → 0.3.9
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/bin/cli.js +5 -0
- package/package.json +1 -1
- package/scripts/install.js +144 -50
- package/src/adapters/claude.js +2 -1
- package/src/adapters/cli.js +46 -12
- package/src/adapters/codex.js +5 -2
- package/src/adapters/cursor.js +2 -1
- package/src/adapters/hermes.js +4 -2
- package/src/config.js +3 -0
- package/src/worker.js +11 -8
- package/templates/claude/CLAUDE.md +17 -3
- package/templates/codex/AGENTS.md +17 -3
- package/templates/cursor/AGENTS.md +17 -3
package/bin/cli.js
CHANGED
|
@@ -32,6 +32,10 @@ async function main() {
|
|
|
32
32
|
case 'update-token':
|
|
33
33
|
return require('../scripts/install').retokenCmd();
|
|
34
34
|
|
|
35
|
+
case 'set-model':
|
|
36
|
+
case 'model':
|
|
37
|
+
return require('../scripts/install').setModelCmd();
|
|
38
|
+
|
|
35
39
|
case 'agents':
|
|
36
40
|
case 'list':
|
|
37
41
|
return require('../scripts/install').listAgents();
|
|
@@ -96,6 +100,7 @@ async function main() {
|
|
|
96
100
|
' add-agent Add an agent by pasting its Host Token (pulls runtime from ClawLink)',
|
|
97
101
|
' rm-agent Remove a mapped agent (clhost rm-agent <id>)',
|
|
98
102
|
' retoken Re-enter a new Host Token for an existing agent (clhost retoken <id>)',
|
|
103
|
+
' set-model Set the model an agent’s runtime uses (clhost set-model <id> <model>)',
|
|
99
104
|
' agents List mapped agents',
|
|
100
105
|
' status Show bridge, service state, and mapped agents',
|
|
101
106
|
' doctor Diagnose config, runtime binaries, bridge + token/machine binding',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@claw-link/gateway-host",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.9",
|
|
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": {
|
package/scripts/install.js
CHANGED
|
@@ -139,9 +139,9 @@ async function run() {
|
|
|
139
139
|
onWait: (s) => process.stdout.write(`\r … still working: ${s.activeJobs} job(s) in flight `),
|
|
140
140
|
});
|
|
141
141
|
if (live && live.busy) process.stdout.write('\n');
|
|
142
|
-
console.log(
|
|
143
|
-
|
|
144
|
-
|
|
142
|
+
if (!r.restarted) console.log(' ⚠ Could not refresh the service automatically — run: clhost restart');
|
|
143
|
+
else if (r.confirmed) console.log(` ✓ Refreshed — now running v${r.version} (pid ${r.pid}).`);
|
|
144
|
+
else console.log(` ⚠ Restart triggered but the new worker hasn't come up on v${VERSION} yet — check \`clhost status\` in a few seconds.`);
|
|
145
145
|
reportCli();
|
|
146
146
|
console.log('\n Commands: clhost status · clhost agents · clhost add-agent · clhost transport-test\n');
|
|
147
147
|
return;
|
|
@@ -190,9 +190,10 @@ async function addAgentCmd() {
|
|
|
190
190
|
rl.close();
|
|
191
191
|
if (agent) {
|
|
192
192
|
saveAgent(cfg, agent);
|
|
193
|
-
const
|
|
194
|
-
|
|
195
|
-
|
|
193
|
+
const r = await gracefulServiceRestartCLI();
|
|
194
|
+
const note = !r.restarted ? ' Run `clhost restart` (or start) to apply.'
|
|
195
|
+
: r.confirmed ? ' Service restarted.' : ' Restarting — check `clhost status`.';
|
|
196
|
+
console.log(`\n ✓ Added agent ${agent.agent_id} (${agent.runtime}).` + note + '\n');
|
|
196
197
|
}
|
|
197
198
|
}
|
|
198
199
|
|
|
@@ -212,8 +213,10 @@ async function removeAgentCmd() {
|
|
|
212
213
|
cfg.agents = cfg.agents.filter((a) => !(a.agent_id && (a.agent_id === target || a.agent_id.startsWith(target))));
|
|
213
214
|
if (cfg.agents.length === before) { console.log(` No agent matched '${target}'.`); return; }
|
|
214
215
|
config.save(cfg);
|
|
215
|
-
const
|
|
216
|
-
|
|
216
|
+
const r = await gracefulServiceRestartCLI();
|
|
217
|
+
const note = !r.restarted ? ' Run `clhost restart` to apply.'
|
|
218
|
+
: r.confirmed ? ' Service restarted.' : ' Restarting — check `clhost status`.';
|
|
219
|
+
console.log(` ✓ Removed agent '${target}'.` + note);
|
|
217
220
|
}
|
|
218
221
|
|
|
219
222
|
function printAgents(cfg) {
|
|
@@ -223,7 +226,7 @@ function printAgents(cfg) {
|
|
|
223
226
|
for (const a of agents) {
|
|
224
227
|
const id = a.agent_id || '(unresolved)';
|
|
225
228
|
const tok = a.host_token ? `…${String(a.host_token).slice(-4)}` : '—';
|
|
226
|
-
console.log(` • ${id} runtime=${a.runtime || 'pull'} token=${tok}` + (a.work_dir ? ` work=${a.work_dir}` : ''));
|
|
229
|
+
console.log(` • ${id} runtime=${a.runtime || 'pull'} token=${tok}` + (a.model ? ` model=${a.model}` : '') + (a.work_dir ? ` work=${a.work_dir}` : ''));
|
|
227
230
|
}
|
|
228
231
|
// Show a concrete, copy-pasteable example (commands also accept any id prefix).
|
|
229
232
|
const example = agents[0].agent_id || '<id>';
|
|
@@ -270,12 +273,43 @@ async function retokenCmd() {
|
|
|
270
273
|
}
|
|
271
274
|
config.save(cfg);
|
|
272
275
|
rl.close(); // release stdin before the (possibly long) graceful drain so Ctrl-C is handled by our cleanup
|
|
273
|
-
const
|
|
274
|
-
|
|
275
|
-
|
|
276
|
+
const r = await gracefulServiceRestartCLI();
|
|
277
|
+
const note = !r.restarted ? ' Run `clhost restart` to apply.'
|
|
278
|
+
: r.confirmed ? ' Service restarted.' : ' Restarting — check `clhost status`.';
|
|
279
|
+
console.log(`\n ✓ Updated token for ${agent.agent_id} (…${token.slice(-4)}).` + note + '\n');
|
|
276
280
|
} finally { rl.close(); }
|
|
277
281
|
}
|
|
278
282
|
|
|
283
|
+
// `set-model <id-or-prefix> <model>` — set (or clear) the model an agent's runtime uses, then
|
|
284
|
+
// restart to apply. Coding runtimes map it to their own flag: claude `--model`, cursor/hermes
|
|
285
|
+
// `--model`, codex `-c model="…"`. Use `default` (or `none`) to clear the override.
|
|
286
|
+
async function setModelCmd() {
|
|
287
|
+
const cfg = config.load();
|
|
288
|
+
if (!cfg || !(cfg.agents || []).length) { console.log('No agents configured. Run: clhost add-agent'); return; }
|
|
289
|
+
const target = (process.argv[3] || '').trim();
|
|
290
|
+
const modelArg = (process.argv[4] || '').trim();
|
|
291
|
+
if (!target || !modelArg) {
|
|
292
|
+
console.log('Usage: clhost set-model <agent-id|prefix> <model>');
|
|
293
|
+
console.log(' e.g. clhost set-model 3f208761 sonnet (claude: sonnet | opus | haiku | full id)');
|
|
294
|
+
console.log(' clhost set-model my-agent default (clear → use the runtime default)\n');
|
|
295
|
+
printAgents(cfg);
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
const agent = cfg.agents.find((a) => a.agent_id && (a.agent_id === target || a.agent_id.startsWith(target)));
|
|
299
|
+
if (!agent) { console.log(` No agent matched '${target}'.`); return; }
|
|
300
|
+
|
|
301
|
+
const clear = ['default', 'none', '-'].includes(modelArg.toLowerCase());
|
|
302
|
+
agent.model = clear ? null : modelArg;
|
|
303
|
+
config.save(cfg);
|
|
304
|
+
if (!clear && agent.runtime === 'openclaw') {
|
|
305
|
+
console.log(' ⚠ OpenClaw agents get their model from the OpenClaw gateway, not from here — this override has no effect.');
|
|
306
|
+
}
|
|
307
|
+
const r = await gracefulServiceRestartCLI();
|
|
308
|
+
const note = !r.restarted ? ' Run `clhost restart` to apply.'
|
|
309
|
+
: r.confirmed ? ' Service restarted.' : ' Restarting — check `clhost status`.';
|
|
310
|
+
console.log(` ✓ ${clear ? `Cleared model for ${agent.agent_id} — using the ${agent.runtime} default.` : `Set ${agent.agent_id} model → ${modelArg} (${agent.runtime}).`}` + note);
|
|
311
|
+
}
|
|
312
|
+
|
|
279
313
|
// `start | stop | restart` — control the installed background service.
|
|
280
314
|
function serviceControl(action) {
|
|
281
315
|
const platform = process.platform;
|
|
@@ -297,7 +331,17 @@ function serviceControl(action) {
|
|
|
297
331
|
if (platform === 'win32') {
|
|
298
332
|
if (action === 'stop') return execSafe('schtasks /End /TN ClawLinkHost') !== null;
|
|
299
333
|
if (action === 'start') return execSafe('schtasks /Run /TN ClawLinkHost') !== null;
|
|
300
|
-
if (action === 'restart') {
|
|
334
|
+
if (action === 'restart') {
|
|
335
|
+
execSafe('schtasks /End /TN ClawLinkHost');
|
|
336
|
+
// /End is async, and the task is MultipleInstancesPolicy=IgnoreNew — so an immediate /Run can be
|
|
337
|
+
// SILENTLY DROPPED while the old instance is still stopping (the Windows analogue of the macOS
|
|
338
|
+
// bootout→bootstrap race). Give it a brief, LANGUAGE-NEUTRAL pause to stop before /Run: `ping -n
|
|
339
|
+
// 4 127.0.0.1` ≈ 3s (parsing schtasks' status would depend on the localized word "Running"; and
|
|
340
|
+
// `timeout` needs an interactive console). If /Run still doesn't take, restartAndConfirm's
|
|
341
|
+
// confirm-then-reinstall fallback recovers.
|
|
342
|
+
execSafe('ping -n 4 127.0.0.1');
|
|
343
|
+
return execSafe('schtasks /Run /TN ClawLinkHost') !== null;
|
|
344
|
+
}
|
|
301
345
|
}
|
|
302
346
|
} catch { /* fall through */ }
|
|
303
347
|
return false;
|
|
@@ -308,8 +352,10 @@ async function serviceControlCmd(action) {
|
|
|
308
352
|
// `stop` stays prompt: its SIGTERM already lets the worker finish in-flight work (2nd signal forces).
|
|
309
353
|
if (action === 'restart') {
|
|
310
354
|
const { force, timeoutMs } = parseDrainArgs();
|
|
311
|
-
const
|
|
312
|
-
console.log(
|
|
355
|
+
const r = await gracefulServiceRestartCLI({ force, timeoutMs });
|
|
356
|
+
if (!r.restarted) console.log(' ⚠ Could not restart the service (is it installed? run `clhost install`).');
|
|
357
|
+
else if (r.confirmed) console.log(` ✓ Service restarted — running v${r.version} (pid ${r.pid}).`);
|
|
358
|
+
else console.log(' ⚠ Restart triggered but the worker hasn’t reported yet — check `clhost status`.');
|
|
313
359
|
return;
|
|
314
360
|
}
|
|
315
361
|
const ok = serviceControl(action);
|
|
@@ -547,47 +593,90 @@ async function drainToIdle({ timeoutMs = 15 * 60 * 1000, force = false, onWait }
|
|
|
547
593
|
if (force) return { hadWorker: true, drained: false, forced: true };
|
|
548
594
|
|
|
549
595
|
control.requestDrain();
|
|
550
|
-
//
|
|
551
|
-
//
|
|
552
|
-
//
|
|
596
|
+
// NOTE: the Ctrl-C cleanup that clears the drain flag on abort lives in withDrainGuard(), which
|
|
597
|
+
// wraps this call AND the restart — so there's no window (between drain finishing and the restart)
|
|
598
|
+
// where an interrupt could leave the worker drained. Don't install a handler here.
|
|
599
|
+
const startedAt = Date.now();
|
|
600
|
+
let waited = false;
|
|
601
|
+
while (Date.now() - startedAt < timeoutMs) {
|
|
602
|
+
const s = control.liveStatus();
|
|
603
|
+
if (!s) return { hadWorker: true, drained: true, workerGone: true }; // worker exited → safe
|
|
604
|
+
// Safe to restart only once the worker has ACKED the drain (won't claim more) AND is idle.
|
|
605
|
+
if (s.drain && !s.busy) return { hadWorker: true, drained: true, waited };
|
|
606
|
+
if (s.busy && typeof onWait === 'function') { onWait(s); waited = true; }
|
|
607
|
+
await sleep(1500);
|
|
608
|
+
}
|
|
609
|
+
return { hadWorker: true, drained: false, timedOut: true };
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// Run an async drain+restart sequence under a Ctrl-C guard: if the operator interrupts at ANY point
|
|
613
|
+
// (during the drain wait OR the restart), clear the drain flag FIRST so the still-running worker
|
|
614
|
+
// resumes claiming — never left wedged — then exit. Spanning the whole sequence closes the gap that
|
|
615
|
+
// a per-drainToIdle handler would leave open between draining and restarting.
|
|
616
|
+
async function withDrainGuard(fn) {
|
|
553
617
|
const onInt = () => { try { control.clearDrain(); } catch { /* ignore */ } process.exit(130); };
|
|
554
618
|
process.on('SIGINT', onInt);
|
|
555
619
|
process.on('SIGTERM', onInt);
|
|
556
|
-
try {
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
620
|
+
try { return await fn(); }
|
|
621
|
+
finally { process.off('SIGINT', onInt); process.off('SIGTERM', onInt); }
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// After triggering a restart, wait until the NEW worker actually publishes status (a different pid
|
|
625
|
+
// than before and — if given — the expected version), so a caller never reports success while the old
|
|
626
|
+
// process is still winding down or a launchd unload/reload is mid-flight. Best-effort: confirmed=false
|
|
627
|
+
// on timeout. This is what makes `clhost update`'s "runs vX" honest instead of premature.
|
|
628
|
+
async function waitForWorkerUp(prevPid, wantVersion, timeoutMs = 25000) {
|
|
629
|
+
const startedAt = Date.now();
|
|
630
|
+
while (Date.now() - startedAt < timeoutMs) {
|
|
631
|
+
await sleep(1000);
|
|
632
|
+
const s = control.liveStatus();
|
|
633
|
+
if (s && s.pid !== prevPid && (!wantVersion || String(s.version) === String(wantVersion))) {
|
|
634
|
+
return { confirmed: true, version: s.version, pid: s.pid };
|
|
566
635
|
}
|
|
567
|
-
return { hadWorker: true, drained: false, timedOut: true };
|
|
568
|
-
} finally {
|
|
569
|
-
process.off('SIGINT', onInt);
|
|
570
|
-
process.off('SIGTERM', onInt);
|
|
571
636
|
}
|
|
637
|
+
const s = control.liveStatus();
|
|
638
|
+
return { confirmed: false, version: s && s.version, pid: s && s.pid };
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// Restart the service and CONFIRM the new worker came up. Prefer an IN-PLACE restart
|
|
642
|
+
// (kickstart -k / systemctl restart / schtasks) — it never unloads the job (no "Service: stopped"
|
|
643
|
+
// window like bootout→bootstrap) and relaunches from the stable app dir. Only if that fails to bring
|
|
644
|
+
// a worker up (service not loaded yet, or a stale Node path in the plist after an nvm/Node upgrade)
|
|
645
|
+
// fall back to a full (re)install, which rewrites the plist. Clears the drain flag unless a fresh
|
|
646
|
+
// worker is confirmed (which clears it on boot) so nothing is ever left drained.
|
|
647
|
+
async function restartAndConfirm({ stageCode = false, wantVersion, onRestart } = {}) {
|
|
648
|
+
if (stageCode) syncAppDir(); // copy the new code into the stable app dir the service runs from
|
|
649
|
+
const prevPid = (control.liveStatus() || {}).pid;
|
|
650
|
+
let notified = false;
|
|
651
|
+
const notify = () => { if (!notified && typeof onRestart === 'function') { notified = true; onRestart(); } };
|
|
652
|
+
let restarted = serviceControl('restart');
|
|
653
|
+
if (restarted) notify();
|
|
654
|
+
let up = restarted ? await waitForWorkerUp(prevPid, wantVersion) : { confirmed: false };
|
|
655
|
+
if (!up.confirmed) {
|
|
656
|
+
const reinstalled = installService(); // rewrites plist/unit (handles a moved Node) + reload
|
|
657
|
+
if (reinstalled) { restarted = true; notify(); up = await waitForWorkerUp(prevPid, wantVersion); }
|
|
658
|
+
}
|
|
659
|
+
if (!up.confirmed) control.clearDrain();
|
|
660
|
+
return { restarted, ...up };
|
|
572
661
|
}
|
|
573
662
|
|
|
574
|
-
//
|
|
575
|
-
// The fresh worker clears the drain flag on boot; if the restart fails we clear it here so the old
|
|
576
|
-
// worker resumes claiming instead of sitting permanently drained.
|
|
663
|
+
// `update` path — drain, then stage new code + restart, confirming the new version is actually live.
|
|
577
664
|
async function gracefulRestart(opts = {}) {
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
665
|
+
return withDrainGuard(async () => {
|
|
666
|
+
const drain = await drainToIdle(opts);
|
|
667
|
+
const r = await restartAndConfirm({ stageCode: true, wantVersion: VERSION, onRestart: opts.onRestart });
|
|
668
|
+
return { ...drain, ...r };
|
|
669
|
+
});
|
|
582
670
|
}
|
|
583
671
|
|
|
584
|
-
// Config-change restart (add/remove/retoken agent, `clhost restart`): drain
|
|
585
|
-
//
|
|
672
|
+
// Config-change restart (add/remove/retoken agent, `clhost restart`): drain first, then restart in
|
|
673
|
+
// place (no code re-stage — same version), confirming the worker actually bounced (new pid).
|
|
586
674
|
async function gracefulServiceRestart(opts = {}) {
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
675
|
+
return withDrainGuard(async () => {
|
|
676
|
+
const drain = await drainToIdle(opts);
|
|
677
|
+
const r = await restartAndConfirm({ stageCode: false, onRestart: opts.onRestart });
|
|
678
|
+
return { ...drain, ...r };
|
|
679
|
+
});
|
|
591
680
|
}
|
|
592
681
|
|
|
593
682
|
// Console wrapper around gracefulServiceRestart: prints a one-time "waiting" notice + a live progress
|
|
@@ -637,15 +726,20 @@ async function update() {
|
|
|
637
726
|
if (live && live.busy && !force) process.stdout.write('\n');
|
|
638
727
|
|
|
639
728
|
if (!r.restarted) { console.log(' ⚠ Could not refresh automatically — run: clhost restart'); return; }
|
|
640
|
-
if (r.
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
729
|
+
if (r.confirmed) {
|
|
730
|
+
// The success line now reflects what the new worker ACTUALLY reports (pid + version), not a guess.
|
|
731
|
+
const drained = r.hadWorker && !r.forced && !r.timedOut;
|
|
732
|
+
console.log(` ✓ ${drained ? 'Finished in-flight work, then restarted' : 'Restarted'} — now running v${r.version} (pid ${r.pid}).`);
|
|
733
|
+
if (r.timedOut) console.log(' ⚠ (A job was still running after the wait — it may have been interrupted.)');
|
|
734
|
+
} else {
|
|
735
|
+
console.log(` ⚠ Restart triggered but the new worker hasn't come up on v${VERSION} yet` +
|
|
736
|
+
(r.version ? ` (still seeing v${r.version})` : '') + ` — check \`clhost status\` in a few seconds (\`clhost logs\` if it doesn't appear).`);
|
|
737
|
+
}
|
|
644
738
|
console.log(' (To upgrade the `clhost` CLI you type: npm i -g @claw-link/gateway-host@latest)\n');
|
|
645
739
|
}
|
|
646
740
|
|
|
647
741
|
module.exports = {
|
|
648
|
-
run, installOnly, addAgentCmd, removeAgentCmd, retokenCmd, listAgents, status,
|
|
742
|
+
run, installOnly, addAgentCmd, removeAgentCmd, retokenCmd, setModelCmd, listAgents, status,
|
|
649
743
|
serviceControl, serviceControlCmd, installService, SERVICE_LABEL,
|
|
650
744
|
versionCmd, doctor, logs, update,
|
|
651
745
|
};
|
package/src/adapters/claude.js
CHANGED
|
@@ -10,10 +10,11 @@ module.exports = makeCliAdapter({
|
|
|
10
10
|
streamJson: true,
|
|
11
11
|
// Prompt via stdin keeps it out of argv/process listings.
|
|
12
12
|
promptViaStdin: true,
|
|
13
|
-
defaultArgs: (_fullPrompt, resumeId, _stdin, perm = []) => [
|
|
13
|
+
defaultArgs: (_fullPrompt, resumeId, _stdin, perm = [], model = '') => [
|
|
14
14
|
'-p',
|
|
15
15
|
'--output-format', 'stream-json',
|
|
16
16
|
'--verbose',
|
|
17
|
+
...(model ? ['--model', model] : []), // e.g. sonnet | opus | haiku | a full model id
|
|
17
18
|
...perm,
|
|
18
19
|
...(resumeId ? ['--resume', resumeId] : []),
|
|
19
20
|
],
|
package/src/adapters/cli.js
CHANGED
|
@@ -8,6 +8,39 @@ const path = require('path');
|
|
|
8
8
|
const { spawnStreaming, buildArgv } = require('./base');
|
|
9
9
|
const sessionStore = require('../session-store');
|
|
10
10
|
|
|
11
|
+
// Chat/harness attachments arrive as URLs (the public chat-attachments bucket). A sandboxed CLI
|
|
12
|
+
// can't reach a URL, so we DOWNLOAD each into the agent's workspace and reference the LOCAL path —
|
|
13
|
+
// even a read-only turn keeps its Read tool, so the agent can open the file. Falls back to the URL.
|
|
14
|
+
const MAX_ATTACHMENT_BYTES = Number(process.env.CLAWHOST_MAX_ATTACHMENT_BYTES) || 25 * 1024 * 1024;
|
|
15
|
+
async function localizeAttachments(atts, workDir, logger) {
|
|
16
|
+
const urls = (Array.isArray(atts) ? atts : [])
|
|
17
|
+
.map((a) => (typeof a === 'string' ? a : (a && a.url) || ''))
|
|
18
|
+
.filter(Boolean);
|
|
19
|
+
if (!urls.length) return '';
|
|
20
|
+
const relDir = path.join('.clawlink', 'attachments');
|
|
21
|
+
const absDir = path.join(workDir, relDir);
|
|
22
|
+
try { fs.mkdirSync(absDir, { recursive: true }); } catch { /* ignore */ }
|
|
23
|
+
const lines = [];
|
|
24
|
+
for (const url of urls) {
|
|
25
|
+
let localRel = null;
|
|
26
|
+
if (/^https?:\/\//i.test(url)) {
|
|
27
|
+
try {
|
|
28
|
+
const res = await fetch(url);
|
|
29
|
+
if (res.ok) {
|
|
30
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
31
|
+
if (buf.length <= MAX_ATTACHMENT_BYTES) {
|
|
32
|
+
const name = ((url.split('/').pop() || 'file').split('?')[0] || 'file').replace(/[^a-zA-Z0-9._-]/g, '_') || 'file';
|
|
33
|
+
fs.writeFileSync(path.join(absDir, name), buf);
|
|
34
|
+
localRel = path.join(relDir, name);
|
|
35
|
+
} else logger.warn(`attachment too large (${buf.length}B): ${url.slice(0, 60)}…`);
|
|
36
|
+
}
|
|
37
|
+
} catch (e) { logger.warn(`attachment download failed (${url.slice(0, 60)}…): ${e.message}`); }
|
|
38
|
+
}
|
|
39
|
+
lines.push(localRel ? `- ${localRel}` : `- ${url} (couldn't download — fetch it yourself if you can)`);
|
|
40
|
+
}
|
|
41
|
+
return `\n\nThe user attached these file(s). READ them (use your Read tool) before answering:\n${lines.join('\n')}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
11
44
|
// Generic parser: each non-empty stdout line is answer text.
|
|
12
45
|
function genericLineParser(line) {
|
|
13
46
|
const t = line.replace(/\r$/, '');
|
|
@@ -81,7 +114,7 @@ function makeCodexJsonParser(capture) {
|
|
|
81
114
|
}
|
|
82
115
|
|
|
83
116
|
/**
|
|
84
|
-
* @param cfg { name, binaries:[], defaultArgs(fullPrompt, resumeId, promptViaStdin, permArgs):[],
|
|
117
|
+
* @param cfg { name, binaries:[], defaultArgs(fullPrompt, resumeId, promptViaStdin, permArgs, model):[],
|
|
85
118
|
* streamJson?:bool, makeParser?:(capture)=>parseFn, permissions?:(scope)=>[],
|
|
86
119
|
* promptViaStdin?:bool, timeoutMs?:number }
|
|
87
120
|
*/
|
|
@@ -94,23 +127,21 @@ function makeCliAdapter(cfg) {
|
|
|
94
127
|
const binary = agent.binary || cfg.binaries[0];
|
|
95
128
|
const userPrompt = job.content || '';
|
|
96
129
|
const systemPrompt = job.system_prompt || '';
|
|
97
|
-
// Text CLIs can't take vision blocks — surface attachment URLs as references so
|
|
98
|
-
// the model is at least aware of them (override via args_template for file-aware CLIs).
|
|
99
|
-
const atts = Array.isArray(job.attachments) ? job.attachments : [];
|
|
100
|
-
const attBlock = atts.length
|
|
101
|
-
? '\n\nAttachments:\n' + atts.map((a) => `- ${typeof a === 'string' ? a : (a && a.url) || ''}`).join('\n')
|
|
102
|
-
: '';
|
|
103
|
-
const base = systemPrompt ? `${systemPrompt}\n\n---\n\nUser request:\n${userPrompt}` : userPrompt;
|
|
104
|
-
const fullPrompt = base + attBlock;
|
|
105
130
|
const clSession = job.session_key || job.session_id || null;
|
|
106
131
|
const resumeId = clSession ? sessionStore.get(cfg.name, clSession) : null;
|
|
107
132
|
// 0 = no timeout (default) — don't cut off long generations. Opt in with CLAWHOST_TIMEOUT_MS.
|
|
108
133
|
const timeoutMs = cfg.timeoutMs || Number(process.env.CLAWHOST_TIMEOUT_MS) || 0;
|
|
109
134
|
|
|
110
|
-
// Run inside the agent's workspace/scratch dir. Create it
|
|
111
|
-
// spawn never
|
|
135
|
+
// Run inside the agent's workspace/scratch dir. Create it FIRST so attachment downloads +
|
|
136
|
+
// the spawn never fail with ENOENT (coding workspaces are validated at setup).
|
|
112
137
|
try { fs.mkdirSync(agent.work_dir, { recursive: true }); } catch { /* ignore */ }
|
|
113
138
|
|
|
139
|
+
// Download any attachments into the workspace and reference their LOCAL paths, so the CLI
|
|
140
|
+
// (which can't reach a URL) can open them via its Read tool.
|
|
141
|
+
const attBlock = await localizeAttachments(job.attachments, agent.work_dir, logger);
|
|
142
|
+
const base = systemPrompt ? `${systemPrompt}\n\n---\n\nUser request:\n${userPrompt}` : userPrompt;
|
|
143
|
+
const fullPrompt = base + attBlock;
|
|
144
|
+
|
|
114
145
|
// Scope-based permissions: admin "configure" turns may write workspace files
|
|
115
146
|
// (GUARDRAILS/SOUL/etc.); customer/discovery turns are read-only and cannot
|
|
116
147
|
// edit or run shell. permArgs is handed to defaultArgs so each adapter can place
|
|
@@ -123,9 +154,12 @@ function makeCliAdapter(cfg) {
|
|
|
123
154
|
const permArgs = (!Array.isArray(agent.args_template) && cfg.permissions)
|
|
124
155
|
? cfg.permissions(writable ? 'configure' : (job.scope || 'customer'))
|
|
125
156
|
: [];
|
|
157
|
+
// Optional per-agent model override (e.g. `clhost set-model <id> sonnet`). Each adapter
|
|
158
|
+
// places it with the runtime's correct flag; ignored when an args_template takes over.
|
|
159
|
+
const model = (!Array.isArray(agent.args_template) && agent.model) ? String(agent.model) : '';
|
|
126
160
|
const argvFor = (rid) => Array.isArray(agent.args_template)
|
|
127
161
|
? buildArgv(agent.args_template, { prompt: fullPrompt, sessionId: rid || '', systemPrompt })
|
|
128
|
-
: cfg.defaultArgs(fullPrompt, rid, cfg.promptViaStdin, permArgs);
|
|
162
|
+
: cfg.defaultArgs(fullPrompt, rid, cfg.promptViaStdin, permArgs, model);
|
|
129
163
|
|
|
130
164
|
let emittedAny = false;
|
|
131
165
|
const runOnce = async function* (rid) {
|
package/src/adapters/codex.js
CHANGED
|
@@ -16,8 +16,11 @@ module.exports = makeCliAdapter({
|
|
|
16
16
|
binaries: ['codex'],
|
|
17
17
|
promptViaStdin: false,
|
|
18
18
|
makeParser: makeCodexJsonParser,
|
|
19
|
-
defaultArgs: (fullPrompt, resumeId, _stdin, perm = []) => {
|
|
20
|
-
|
|
19
|
+
defaultArgs: (fullPrompt, resumeId, _stdin, perm = [], model = '') => {
|
|
20
|
+
// Model via `-c model="…"` — the config-override form both `exec` and `exec resume` accept
|
|
21
|
+
// (like sandbox/approval above), so it must sit in `opts` BEFORE the `--` end-of-options.
|
|
22
|
+
const modelOpt = model ? ['-c', `model="${model}"`] : [];
|
|
23
|
+
const opts = ['--json', '--skip-git-repo-check', ...modelOpt, ...perm];
|
|
21
24
|
// `--` ends option parsing so a prompt starting with '-' is positional, never a flag.
|
|
22
25
|
return resumeId
|
|
23
26
|
? ['exec', 'resume', ...opts, resumeId, '--', fullPrompt]
|
package/src/adapters/cursor.js
CHANGED
|
@@ -7,5 +7,6 @@ module.exports = makeCliAdapter({
|
|
|
7
7
|
name: 'cursor',
|
|
8
8
|
binaries: ['cursor-agent', 'cursor'],
|
|
9
9
|
promptViaStdin: false,
|
|
10
|
-
defaultArgs: (fullPrompt
|
|
10
|
+
defaultArgs: (fullPrompt, _resumeId, _stdin, _perm = [], model = '') =>
|
|
11
|
+
[...(model ? ['--model', model] : []), '-p', fullPrompt],
|
|
11
12
|
});
|
package/src/adapters/hermes.js
CHANGED
|
@@ -7,8 +7,10 @@ const { makeCliAdapter } = require('./cli');
|
|
|
7
7
|
module.exports = makeCliAdapter({
|
|
8
8
|
name: 'hermes',
|
|
9
9
|
binaries: ['hermes'],
|
|
10
|
-
defaultArgs: (fullPrompt, resumeId) => [
|
|
11
|
-
'chat',
|
|
10
|
+
defaultArgs: (fullPrompt, resumeId, _stdin, _perm = [], model = '') => [
|
|
11
|
+
'chat',
|
|
12
|
+
...(model ? ['--model', model] : []),
|
|
13
|
+
'-q', fullPrompt,
|
|
12
14
|
...(resumeId ? ['--resume', resumeId] : []),
|
|
13
15
|
],
|
|
14
16
|
});
|
package/src/config.js
CHANGED
|
@@ -71,6 +71,9 @@ function normalizeAgent(a) {
|
|
|
71
71
|
host_token: a.host_token,
|
|
72
72
|
runtime: a.runtime || null,
|
|
73
73
|
binary: a.binary || null,
|
|
74
|
+
// Optional model override for coding runtimes (claude/codex/cursor/hermes). null = the
|
|
75
|
+
// runtime's own default. Set with `clhost set-model <id> <model>`.
|
|
76
|
+
model: a.model || null,
|
|
74
77
|
args_template: Array.isArray(a.args_template) ? a.args_template : null,
|
|
75
78
|
local_gateway_url: a.local_gateway_url || null,
|
|
76
79
|
local_gateway_token: a.local_gateway_token || null,
|
package/src/worker.js
CHANGED
|
@@ -221,18 +221,14 @@ async function startWorker() {
|
|
|
221
221
|
|
|
222
222
|
logger.info(`ClawLink Host Gateway v${VERSION} — ${agents.length} agent(s): ${agents.map((a) => `${(a.agent_id || 'pending').slice(0, 8)}…(${a.runtime || 'resolving'})`).join(', ')}`);
|
|
223
223
|
|
|
224
|
-
// H2H data plane: bring up the native QUIC transport once per host (shared by all agents). If no
|
|
225
|
-
// prebuilt native core exists for this platform, every transfer routes through the central relay.
|
|
226
224
|
const p2p = new P2P(logger);
|
|
227
|
-
const p2pAddr = await p2p.start();
|
|
228
|
-
logger.info(p2pAddr
|
|
229
|
-
? `H2H transport: native P2P active (${p2pAddr})`
|
|
230
|
-
: 'H2H transport: central relay (no native core for this platform — install acceleration later)');
|
|
231
225
|
|
|
232
226
|
// Advertise liveness + in-flight count so `clhost update`/`restart` can wait for this host to go
|
|
233
227
|
// idle before restarting, and pick up the CLI's drain flag (stop claiming, finish current work).
|
|
234
|
-
//
|
|
235
|
-
//
|
|
228
|
+
// Do this BEFORE the (possibly slow) P2P init, so a restart is CONFIRMED fast — waitForWorkerUp sees
|
|
229
|
+
// the new pid/version within ~1s even while iroh is still binding. Clear any stale flag first; and
|
|
230
|
+
// only ever honor a flag written AFTER this boot, so a leftover flag from a crashed update (or a
|
|
231
|
+
// clearDrain that failed) can never wedge a fresh worker. (pushStatus never touches p2p.)
|
|
236
232
|
control.clearDrain(); // drop any stale flag left by a previous life FIRST…
|
|
237
233
|
const bootAt = Date.now(); // …then anchor "now": only a drain requested after this point is honored.
|
|
238
234
|
const pushStatus = () => {
|
|
@@ -248,6 +244,13 @@ async function startWorker() {
|
|
|
248
244
|
const statusTimer = setInterval(pushStatus, 2000);
|
|
249
245
|
statusTimer.unref();
|
|
250
246
|
|
|
247
|
+
// H2H data plane: bring up the native QUIC transport once per host (shared by all agents). If no
|
|
248
|
+
// prebuilt native core exists for this platform, every transfer routes through the central relay.
|
|
249
|
+
const p2pAddr = await p2p.start();
|
|
250
|
+
logger.info(p2pAddr
|
|
251
|
+
? `H2H transport: native P2P active (${p2pAddr})`
|
|
252
|
+
: 'H2H transport: central relay (no native core for this platform — install acceleration later)');
|
|
253
|
+
|
|
251
254
|
// Graceful shutdown: stop claiming, let in-flight jobs finish (keeping P2P alive for them), then
|
|
252
255
|
// exit. A second signal forces an immediate exit; SHUTDOWN_DRAIN_MS bounds the wait.
|
|
253
256
|
let stopSignals = 0;
|
|
@@ -4,9 +4,23 @@ This folder is the working directory for a **ClawLink** customer-facing agent ru
|
|
|
4
4
|
locally via the ClawLink Host Gateway (`@claw-link/gateway-host`). Claude Code reads
|
|
5
5
|
this `CLAUDE.md` as project context.
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
## Read your configured instructions first
|
|
8
|
+
|
|
9
|
+
Skills, personas, and security settings are written to dedicated files in this directory.
|
|
10
|
+
**Before you respond, read whichever of these exist here — they hold your configured behaviour:**
|
|
11
|
+
|
|
12
|
+
- `GUARDRAILS.md` — hard security rules. **Never violate these**; they override everything.
|
|
13
|
+
- `BOOTSTRAP.md` — how to start up and behave.
|
|
14
|
+
- `SOUL.md` — your identity / persona.
|
|
15
|
+
- `TOOLS.md` — the tools and skills available to you and how to use them.
|
|
16
|
+
- `MEMORY.md` — remembered context and preferences.
|
|
17
|
+
|
|
18
|
+
Follow them on every turn. If a file is absent, skip it.
|
|
19
|
+
|
|
20
|
+
## Operating rules
|
|
21
|
+
|
|
22
|
+
- The agent's **persona, applied skills, and per-conversation instructions also arrive in
|
|
23
|
+
the system prompt of every request** — follow them together with the files above.
|
|
10
24
|
- Keep replies helpful, accurate, and on-task for the business you represent.
|
|
11
25
|
- Stay in scope: do not run destructive commands or act outside the customer's request.
|
|
12
26
|
- Never reveal secrets, tokens, config files, or host/system details.
|
|
@@ -4,9 +4,23 @@ This folder is the working directory for a **ClawLink** customer-facing agent ru
|
|
|
4
4
|
locally via the ClawLink Host Gateway (`@claw-link/gateway-host`). Codex reads
|
|
5
5
|
`AGENTS.md` as project context.
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
## Read your configured instructions first
|
|
8
|
+
|
|
9
|
+
Skills, personas, and security settings are written to dedicated files in this directory.
|
|
10
|
+
**Before you respond, read whichever of these exist here — they hold your configured behaviour:**
|
|
11
|
+
|
|
12
|
+
- `GUARDRAILS.md` — hard security rules. **Never violate these**; they override everything.
|
|
13
|
+
- `BOOTSTRAP.md` — how to start up and behave.
|
|
14
|
+
- `SOUL.md` — your identity / persona.
|
|
15
|
+
- `TOOLS.md` — the tools and skills available to you and how to use them.
|
|
16
|
+
- `MEMORY.md` — remembered context and preferences.
|
|
17
|
+
|
|
18
|
+
Follow them on every turn. If a file is absent, skip it.
|
|
19
|
+
|
|
20
|
+
## Operating rules
|
|
21
|
+
|
|
22
|
+
- The agent's **persona, applied skills, and per-conversation instructions also arrive in
|
|
23
|
+
the system prompt of every request** — follow them together with the files above.
|
|
10
24
|
- Keep replies helpful, accurate, and on-task for the business you represent.
|
|
11
25
|
- Stay in scope: do not run destructive commands or act outside the customer's request.
|
|
12
26
|
- Never reveal secrets, tokens, config files, or host/system details.
|
|
@@ -4,9 +4,23 @@ This folder is the working directory for a **ClawLink** customer-facing agent ru
|
|
|
4
4
|
locally via the ClawLink Host Gateway (`@claw-link/gateway-host`). The Cursor agent
|
|
5
5
|
reads `AGENTS.md` as project context.
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
## Read your configured instructions first
|
|
8
|
+
|
|
9
|
+
Skills, personas, and security settings are written to dedicated files in this directory.
|
|
10
|
+
**Before you respond, read whichever of these exist here — they hold your configured behaviour:**
|
|
11
|
+
|
|
12
|
+
- `GUARDRAILS.md` — hard security rules. **Never violate these**; they override everything.
|
|
13
|
+
- `BOOTSTRAP.md` — how to start up and behave.
|
|
14
|
+
- `SOUL.md` — your identity / persona.
|
|
15
|
+
- `TOOLS.md` — the tools and skills available to you and how to use them.
|
|
16
|
+
- `MEMORY.md` — remembered context and preferences.
|
|
17
|
+
|
|
18
|
+
Follow them on every turn. If a file is absent, skip it.
|
|
19
|
+
|
|
20
|
+
## Operating rules
|
|
21
|
+
|
|
22
|
+
- The agent's **persona, applied skills, and per-conversation instructions also arrive in
|
|
23
|
+
the system prompt of every request** — follow them together with the files above.
|
|
10
24
|
- Keep replies helpful, accurate, and on-task for the business you represent.
|
|
11
25
|
- Stay in scope: do not run destructive commands or act outside the customer's request.
|
|
12
26
|
- Never reveal secrets, tokens, config files, or host/system details.
|