@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 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.7",
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": {
@@ -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(r.restarted
143
- ? (r.hadWorker && !r.timedOut ? 'Finished in-flight work, then refreshed to the latest version.' : ' ✓ Service refreshed to the latest version.')
144
- : 'Could not refresh the service automaticallyrun: clhost restart');
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 { restarted } = await gracefulServiceRestartCLI();
194
- console.log(`\n ✓ Added agent ${agent.agent_id} (${agent.runtime}).` +
195
- (restarted ? ' Service restarted.' : ' Run `clhost restart` (or start) to apply.') + '\n');
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 { restarted } = await gracefulServiceRestartCLI();
216
- console.log(` ✓ Removed agent '${target}'.` + (restarted ? ' Service restarted.' : ' Run `clhost restart` to apply.'));
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 { restarted } = await gracefulServiceRestartCLI();
274
- console.log(`\n ✓ Updated token for ${agent.agent_id} (…${token.slice(-4)}).` +
275
- (restarted ? ' Service restarted.' : ' Run `clhost restart` to apply.') + '\n');
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') { execSafe('schtasks /End /TN ClawLinkHost'); return execSafe('schtasks /Run /TN ClawLinkHost') !== null; }
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 { restarted } = await gracefulServiceRestartCLI({ force, timeoutMs });
312
- console.log(restarted ? ' ✓ Service restarted.' : ' ⚠ Could not restart the service (is it installed? run `clhost install`).');
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
- // If the operator Ctrl-C's (or the CLI is killed) mid-wait, clear the flag so the still-running
551
- // worker resumes claiming instead of sitting drained forever. Without this, an aborted update/
552
- // restart would silently wedge the worker.
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
- const startedAt = Date.now();
558
- let waited = false;
559
- while (Date.now() - startedAt < timeoutMs) {
560
- const s = control.liveStatus();
561
- if (!s) return { hadWorker: true, drained: true, workerGone: true }; // worker exited safe
562
- // Safe to restart only once the worker has ACKED the drain (won't claim more) AND is idle.
563
- if (s.drain && !s.busy) return { hadWorker: true, drained: true, waited };
564
- if (s.busy && typeof onWait === 'function') { onWait(s); waited = true; }
565
- await sleep(1500);
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
- // Drain the running worker to idle (unless forced), then stage the new code + restart the service.
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
- const drain = await drainToIdle(opts);
579
- const restarted = installService(); // syncAppDir (stage new code) + bootstrap/restart the service
580
- if (!restarted) control.clearDrain();
581
- return { ...drain, restarted };
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 in-flight jobs first,
585
- // then a plain restart. Unlike gracefulRestart it does NOT re-stage code the version is unchanged.
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
- const drain = await drainToIdle(opts);
588
- const restarted = serviceControl('restart');
589
- if (!restarted) control.clearDrain();
590
- return { ...drain, restarted };
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.forced) console.log(` ✓ Restarted now (--force). Service runs v${VERSION} from ${appDir()}.`);
641
- else if (r.timedOut) console.log(` ⚠ A job was still running after the wait restarted anyway (it may have been interrupted). Service runs v${VERSION}.`);
642
- else if (r.hadWorker) console.log(` ✓ Finished in-flight work, then refreshed. Service runs v${VERSION} from ${appDir()}.`);
643
- else console.log(` ✓ Service now runs v${VERSION} from ${appDir()}.`);
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
  };
@@ -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
  ],
@@ -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 if missing so the
111
- // spawn never fails with ENOENT (coding workspaces are validated at setup).
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) {
@@ -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
- const opts = ['--json', '--skip-git-repo-check', ...perm];
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]
@@ -7,5 +7,6 @@ module.exports = makeCliAdapter({
7
7
  name: 'cursor',
8
8
  binaries: ['cursor-agent', 'cursor'],
9
9
  promptViaStdin: false,
10
- defaultArgs: (fullPrompt) => ['-p', fullPrompt],
10
+ defaultArgs: (fullPrompt, _resumeId, _stdin, _perm = [], model = '') =>
11
+ [...(model ? ['--model', model] : []), '-p', fullPrompt],
11
12
  });
@@ -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', '-q', fullPrompt,
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
- // Clear any stale flag first; and only ever honor a flag written AFTER this boot, so a leftover
235
- // flag from a crashed update (or a clearDrain that failed) can never wedge a fresh worker.
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
- 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.
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
- 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.
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
- 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.
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.