@claw-link/gateway-host 0.3.18 → 0.4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@claw-link/gateway-host",
3
- "version": "0.3.18",
3
+ "version": "0.4.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": {
@@ -15,6 +15,71 @@ const { spawn } = require('child_process');
15
15
  const logger = require('../logger');
16
16
  const { enrichedEnv } = require('../paths');
17
17
 
18
+ // ── Claude session/usage-limit detection ─────────────────────────────────────
19
+ // When the CLI hits its subscription window it prints something like
20
+ // "You've hit your session limit · resets 1:20pm (Asia/Singapore)" (as output OR
21
+ // stderr) and exits non-zero — or exits 0 with the notice AS the whole answer.
22
+ // The worker turns a detection into a bridge `pause` so the run waits for the
23
+ // reset instead of failing.
24
+ //
25
+ // Precision matters: a false positive parks a legitimate reply for up to an hour.
26
+ // So we require the NOTICE PHRASING ("hit/reached your … limit", "limit reached",
27
+ // "…limit · resets"), not merely the topical words "rate limit" — an agent that
28
+ // mentions rate limits in its answer must not be mistaken for a hit.
29
+ const LIMIT_NOTICE_RE = /\b(?:hit|reached|exceeded)\s+(?:your\s+)?(?:\w+\s+){0,2}(?:session|usage|rate|message|token)\s+limit\b|\b(?:session|usage|rate|message|token)\s+limit\s+(?:reached|exceeded|hit)\b|\blimit\s*[·|]\s*resets?\b/i;
30
+ const RESET_RE = /\bresets?\b/i;
31
+
32
+ /** Parse "resets 1:20pm (Asia/Singapore)" → ISO timestamp of the NEXT such wall-clock
33
+ * time in that timezone (+2 min safety margin). Null when unparseable. */
34
+ function parseResetAt(text) {
35
+ const m = String(text || '').match(/resets?\s+(?:at\s+)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)?(?:\s*\(([^)]+)\))?/i);
36
+ if (!m) return null;
37
+ let hour = parseInt(m[1], 10);
38
+ const minute = m[2] ? parseInt(m[2], 10) : 0;
39
+ const ampm = (m[3] || '').toLowerCase();
40
+ if (ampm === 'pm' && hour < 12) hour += 12;
41
+ if (ampm === 'am' && hour === 12) hour = 0;
42
+ if (!Number.isFinite(hour) || hour > 23 || minute > 59) return null;
43
+ const tz = (m[4] || '').trim();
44
+ try {
45
+ // Current wall-clock time in the target tz → minutes until the reset time.
46
+ const now = new Date();
47
+ const fmt = new Intl.DateTimeFormat('en-GB', {
48
+ ...(tz ? { timeZone: tz } : {}), hour: '2-digit', minute: '2-digit', hour12: false,
49
+ });
50
+ const parts = fmt.formatToParts(now);
51
+ const curH = parseInt(parts.find((p) => p.type === 'hour')?.value ?? '0', 10);
52
+ const curM = parseInt(parts.find((p) => p.type === 'minute')?.value ?? '0', 10);
53
+ let delta = (hour * 60 + minute) - (curH * 60 + curM);
54
+ if (delta <= 0) delta += 24 * 60; // already past today → tomorrow
55
+ return new Date(now.getTime() + (delta + 2) * 60 * 1000).toISOString();
56
+ } catch {
57
+ return null; // unknown/invalid IANA tz string
58
+ }
59
+ }
60
+
61
+ /** Detect a limit NOTICE (not merely the topic) in output/stderr on a failed run;
62
+ * returns {resumeAt, message} or null. `redact` (from logger) scrubs secrets before
63
+ * the message is shipped to the server / shown in the Studio. */
64
+ function detectRateLimit(text, redact) {
65
+ const t = String(text || '');
66
+ if (!LIMIT_NOTICE_RE.test(t)) return null;
67
+ const tail = t.slice(-300).trim();
68
+ return {
69
+ resumeAt: parseResetAt(t) || new Date(Date.now() + 60 * 60 * 1000).toISOString(),
70
+ message: (typeof redact === 'function' ? redact(tail) : tail),
71
+ };
72
+ }
73
+
74
+ /** True when the whole final reply IS the limit notice (exit-0 case): short AND
75
+ * matches the notice phrasing AND names a reset — must never be stored as a result.
76
+ * Requiring the notice phrasing (not just "rate limit") keeps a short benign reply
77
+ * that merely discusses limits from being swallowed. */
78
+ function isLimitMessage(text) {
79
+ const t = String(text || '').trim();
80
+ return t.length > 0 && t.length <= 300 && LIMIT_NOTICE_RE.test(t) && RESET_RE.test(t);
81
+ }
82
+
18
83
  // A small set of flags we refuse to pass through (auto-approve / no-safety modes).
19
84
  const FORBIDDEN_FLAGS = [
20
85
  '--yolo', '--dangerously-skip-permissions', '--no-sandbox', '--allow-all',
@@ -101,8 +166,14 @@ async function* spawnStreaming({ binary, argv, cwd, env, timeoutMs = 600000, std
101
166
  };
102
167
 
103
168
  let stdoutBuf = '';
169
+ // Bounded tail of everything the runtime printed (stdout + stderr), used on failure
170
+ // to recognize a session-limit notice and type the error as rate_limited.
171
+ let tailBuf = '';
172
+ const addTail = (s) => { tailBuf = (tailBuf + s).slice(-4096); };
104
173
  const handleChunk = (buf) => {
105
- stdoutBuf += buf.toString();
174
+ const s = buf.toString();
175
+ addTail(s);
176
+ stdoutBuf += s;
106
177
  let idx;
107
178
  while ((idx = stdoutBuf.indexOf('\n')) >= 0) {
108
179
  const line = stdoutBuf.slice(0, idx);
@@ -113,7 +184,9 @@ async function* spawnStreaming({ binary, argv, cwd, env, timeoutMs = 600000, std
113
184
 
114
185
  child.stdout.on('data', handleChunk);
115
186
  child.stderr.on('data', (b) => {
116
- const text = logger.redact(b.toString()).trim();
187
+ const raw = b.toString();
188
+ addTail(raw);
189
+ const text = logger.redact(raw).trim();
117
190
  if (text) push({ type: 'log', content: text.slice(0, 500) });
118
191
  });
119
192
 
@@ -121,7 +194,16 @@ async function* spawnStreaming({ binary, argv, cwd, env, timeoutMs = 600000, std
121
194
  clearTimeout(timer);
122
195
  if (stdoutBuf.trim()) for (const evt of (parseLine(stdoutBuf) || [])) push(evt);
123
196
  if (killed) exitErr = new Error(`runtime timed out after ${timeoutMs}ms`);
124
- else if (code !== 0) exitErr = new Error(`runtime exited with code ${code}`);
197
+ else if (code !== 0) {
198
+ const limit = detectRateLimit(tailBuf, logger.redact);
199
+ if (limit) {
200
+ exitErr = Object.assign(new Error(limit.message || `runtime exited with code ${code}`), {
201
+ code: 'rate_limited', resumeAt: limit.resumeAt,
202
+ });
203
+ } else {
204
+ exitErr = new Error(`runtime exited with code ${code}`);
205
+ }
206
+ }
125
207
  done = true;
126
208
  if (resolveNext) { resolveNext(); resolveNext = null; }
127
209
  });
@@ -140,4 +222,4 @@ async function* spawnStreaming({ binary, argv, cwd, env, timeoutMs = 600000, std
140
222
  if (exitErr) throw exitErr;
141
223
  }
142
224
 
143
- module.exports = { spawnStreaming, buildArgv, FORBIDDEN_FLAGS };
225
+ module.exports = { spawnStreaming, buildArgv, FORBIDDEN_FLAGS, detectRateLimit, isLimitMessage };
package/src/bridge.js CHANGED
@@ -60,6 +60,9 @@ class Bridge {
60
60
  stream(jobId, seq, type, content) { return this._post('stream', { job_id: jobId, seq, type, content }); }
61
61
  complete(jobId, finalContent, metadata) { return this._post('complete', { job_id: jobId, final_content: finalContent, metadata: metadata || {} }); }
62
62
  fail(jobId, error) { return this._post('fail', { job_id: jobId, error: String(error).slice(0, 1000) }); }
63
+ // Session-limit hit: park the job server-side until resume_at (run stays alive); the same
64
+ // machine re-claims it after the reset and --resume continues the conversation.
65
+ pause(jobId, resumeAt, message) { return this._post('pause', { job_id: jobId, resume_at: resumeAt, message: String(message ?? '').slice(0, 500) }); }
63
66
  // Poll a running job's status so the worker can abort the agent if the run was cancelled.
64
67
  jobStatus(jobId) { return this._post('job_status', { job_id: jobId }); }
65
68
  // AHP data plane: request short-lived signed URLs to sync content-addressed Capsule bytes.
package/src/worker.js CHANGED
@@ -10,6 +10,7 @@ const logger = require('./logger');
10
10
  const config = require('./config');
11
11
  const { Bridge, withRetry } = require('./bridge');
12
12
  const { getAdapter } = require('./adapters');
13
+ const { detectRateLimit, isLimitMessage } = require('./adapters/base');
13
14
  const { detectRuntimes } = require('./detect');
14
15
  const { P2P } = require('./transport/p2p');
15
16
  const control = require('./control');
@@ -100,6 +101,21 @@ async function processJob(bridge, agentCfg, job, p2p) {
100
101
  catch (e) { logger.warn(`stream seq ${seq} failed: ${e.message}`); }
101
102
  }
102
103
  if (ac.signal.aborted) { logger.info(`job ${job.id} cancelled — not completing`); return; }
104
+ // Session-limit notice delivered as a NORMAL reply (exit 0): the whole answer is the
105
+ // limit message. Never store that as the node's result — pause the job instead so the
106
+ // run resumes after the reset (server parks it with not_before; --resume continues).
107
+ if (job.harness && isLimitMessage(finalText)) {
108
+ const limit = detectRateLimit(finalText, logger.redact) || { resumeAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(), message: logger.redact(finalText) };
109
+ logger.warn(`job ${job.id} rate-limited — pausing until ${limit.resumeAt}`);
110
+ try {
111
+ await withRetry(() => bridge.pause(job.id, limit.resumeAt, limit.message));
112
+ return;
113
+ } catch (pe) {
114
+ logger.warn(`pause not supported by server (${pe.message}) — failing instead`);
115
+ try { await bridge.fail(job.id, limit.message); } catch { /* ignore */ }
116
+ return;
117
+ }
118
+ }
103
119
  // H2H: store a large result for P2P fetch by the next node's host (inline copy stays the floor).
104
120
  // We add+ref it before complete(), but ANNOUNCE only AFTER complete() persists the capsule — so
105
121
  // the directory can verify this host is the producer before listing it.
@@ -120,6 +136,17 @@ async function processJob(bridge, agentCfg, job, p2p) {
120
136
  logger.info(`job ${job.id} done (${finalText.length} chars${transport ? `, P2P-offered ${transport.cid.slice(0, 14)}…` : ''})`);
121
137
  } catch (e) {
122
138
  if (ac.signal.aborted) { logger.info(`job ${job.id} cancelled — agent stopped`); return; }
139
+ // Typed rate-limit (nonzero exit + limit notice in the output tail): pause, don't fail.
140
+ // An old server without the pause action degrades to today's fail behavior.
141
+ if (e && e.code === 'rate_limited' && job.harness) {
142
+ logger.warn(`job ${job.id} rate-limited — pausing until ${e.resumeAt}`);
143
+ try {
144
+ await withRetry(() => bridge.pause(job.id, e.resumeAt, e.message));
145
+ return;
146
+ } catch (pe) {
147
+ logger.warn(`pause not supported by server (${pe.message}) — failing instead`);
148
+ }
149
+ }
123
150
  logger.error(`job ${job.id} failed: ${e.message}`);
124
151
  try { await bridge.fail(job.id, e.message); } catch { /* ignore */ }
125
152
  } finally {