@remits/remits-cli 0.1.162 → 0.1.164

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
@@ -139,6 +139,10 @@ remits-cli install --skills --overwrite true
139
139
  - The service groups sessions by `baseUrl` so one websocket connection can service multiple authenticated accounts on the same Remits environment.
140
140
  - Websocket subscriptions are deduplicated by user topic. If multiple authenticated accounts share the same websocket topic, the listener subscribes once and maps that topic back to all related accounts.
141
141
  - The daemon keeps STOMP heartbeats enabled and runs a maintenance watchdog that recreates unhealthy websocket connections.
142
+ - Terminal server authentication/authorization errors suspend WebSocket retries and show
143
+ `authentication_required`. Reauthenticate to replace the managed client's credentials; the watchdog
144
+ does not revive rejected credentials. Transient failures use jittered exponential backoff up to
145
+ 60 seconds. Test watchers keep HTTP polling; restart local-AI workers after reauthentication.
142
146
  - If the machine sleeps, the network drops, or auth sessions change while the daemon is already running, the service now attempts to reconnect and resubscribe automatically once connectivity returns.
143
147
  - Support tickets are the primary unit of dispatched work. A serving agent launches one fresh worker process per routed ticket.
144
148
  - Agent sessions register themselves with `remits-cli agent register` or `remits-cli agent serve`; `serve` also starts the local supervisor.
package/index.js CHANGED
@@ -4,18 +4,18 @@
4
4
  ## Table of Contents
5
5
 
6
6
  - L22 Runtime Bootstrap And Shared State
7
- - L124 Sessions, Account Resolution, And Production Guards
8
- - L630 Local State, Workspaces, And Verification Evidence
9
- - L2668 Account Repos, Guide Sync, And Platform Repo
10
- - L3508 Component Discovery And HTTP Logging
11
- - L4276 Skill Delivery And TOC Resolution
12
- - L4591 Auth And Component Staging
13
- - L5223 Component Summaries, Status, And Sync Gates
14
- - L7547 Branches, Promotion, Commit, And Test Runs
15
- - L9791 Tokens, Tools, Verification, And Config
16
- - L11308 Service Dashboard And WebSocket Listener
17
- - L13808 Agent And Ticket Workflows
18
- - L18069 Help, Auto Update, And Command Dispatch
7
+ - L125 Sessions, Account Resolution, And Production Guards
8
+ - L631 Local State, Workspaces, And Verification Evidence
9
+ - L2669 Account Repos, Guide Sync, And Platform Repo
10
+ - L3509 Component Discovery And HTTP Logging
11
+ - L4277 Skill Delivery And TOC Resolution
12
+ - L4592 Auth And Component Staging
13
+ - L5224 Component Summaries, Status, And Sync Gates
14
+ - L7548 Branches, Promotion, Commit, And Test Runs
15
+ - L9802 Tokens, Tools, Verification, And Config
16
+ - L11319 Service Dashboard And WebSocket Listener
17
+ - L13829 Agent And Ticket Workflows
18
+ - L18090 Help, Auto Update, And Command Dispatch
19
19
  */
20
20
 
21
21
  /*
@@ -32,6 +32,7 @@ const axios = require('axios');
32
32
  const openModule = require('open');
33
33
  const { Client } = require('@stomp/stompjs');
34
34
  const WebSocket = require('ws');
35
+ const { installWebsocketRetry } = require('./lib/websocket-retry');
35
36
  const YAML = require('yaml');
36
37
 
37
38
  Object.assign(global, { WebSocket });
@@ -4160,7 +4161,7 @@ const WORKLOAD_BUSY_MAX_RETRIES = 6;
4160
4161
  * How long to wait before retrying a request the platform refused as `WORKLOAD_BUSY`, or null when the
4161
4162
  * failure is anything else (or the retries are spent).
4162
4163
  *
4163
- * The platform admits only a few heavyweight requests per instance and answers the rest with HTTP 429
4164
+ * The platform admits only a few heavyweight requests at once and answers the rest with HTTP 429
4164
4165
  * BEFORE doing any work, so repeating the identical request is safe — which is not true of any other
4165
4166
  * failure, and is why this matches on the code rather than on the status alone. The wait starts at the
4166
4167
  * server's Retry-After and grows, with jitter so several refused agents do not come back in step.
@@ -4186,7 +4187,7 @@ async function postRidingOutBusy(api, endpoint, payload) {
4186
4187
  } catch (err) {
4187
4188
  const delayMs = workloadBusyRetryDelayMs(err, attempt, maxRetries, Math.random);
4188
4189
  if (delayMs == null) throw err;
4189
- console.error('Platform instance is busy with ' + (err.response.data.workload || 'other work') +
4190
+ console.error('Platform is busy with ' + (err.response.data.workload || 'other work') +
4190
4191
  '; retrying in ' + Math.round(delayMs / 1000) + 's (' + (attempt + 1) + '/' + maxRetries + ').');
4191
4192
  await new Promise((resolve) => setTimeout(resolve, delayMs));
4192
4193
  }
@@ -9144,7 +9145,16 @@ function watchWebsocket(baseUrl, topicId, taskId, options = {}) {
9144
9145
  const emit = options.stderr ? console.error : console.log;
9145
9146
  const client = new Client({
9146
9147
  brokerURL: webSocketUrl(baseUrl),
9147
- connectHeaders: { 'X-Auth-Token': options.token || '' },
9148
+ connectHeaders: {
9149
+ 'X-Auth-Token': options.token || '',
9150
+ 'x-remits-ws-label': 'cli-test',
9151
+ 'x-remits-client-kind': 'cli',
9152
+ 'x-remits-tab-id': 'cli:' + process.pid + ':test:' + crypto.randomUUID(),
9153
+ 'x-remits-origin': 'remits-cli://test',
9154
+ 'x-remits-path': 'test/watch',
9155
+ 'x-remits-location': normalizeBaseUrl(baseUrl),
9156
+ 'x-remits-broker': webSocketUrl(baseUrl)
9157
+ },
9148
9158
  reconnectDelay: 3000,
9149
9159
  heartbeatIncoming: 10000,
9150
9160
  heartbeatOutgoing: 10000,
@@ -9176,6 +9186,7 @@ function watchWebsocket(baseUrl, topicId, taskId, options = {}) {
9176
9186
  });
9177
9187
  };
9178
9188
 
9189
+ installWebsocketRetry(client, { onBlocked: code => emit('[ws] Authentication required (' + code + '); HTTP polling continues.') });
9179
9190
  client.activate();
9180
9191
  return () => {
9181
9192
  client.deactivate();
@@ -9228,7 +9239,7 @@ function testRunOptionsFromFlags(flags = {}) {
9228
9239
  // How long a later shard of a parallel run waits for a free suite slot before the launch gives up.
9229
9240
  const SHARD_ADMISSION_WAIT_MS = 30 * 60 * 1000;
9230
9241
 
9231
- // A start the platform refused because every suite slot on the instance is taken (safe to repeat: no work began).
9242
+ // A start the platform refused because every suite slot is taken (safe to repeat: no work began).
9232
9243
  function shardStartBusy(err) {
9233
9244
  const response = err && err.response;
9234
9245
  return !!(response && response.status === 429 && response.data && response.data.code === 'WORKLOAD_BUSY');
@@ -9340,7 +9351,7 @@ async function testCommand(flags) {
9340
9351
  };
9341
9352
  // Shards are started one after another (each start returns at once) and then run side by side on the platform.
9342
9353
  const shardStarts = [];
9343
- // The platform admits only a few suites per instance at once and refuses the rest as WORKLOAD_BUSY. Once one
9354
+ // The platform admits only a few suites at once and refuses the rest as WORKLOAD_BUSY. Once one
9344
9355
  // of our shards is running it holds a slot it will give back, so a later shard keeps waiting for one (bounded)
9345
9356
  // instead of giving up after the usual retries and leaving a group that can never finish.
9346
9357
  const shardWaitDeadline = Date.now() + SHARD_ADMISSION_WAIT_MS;
@@ -9354,7 +9365,7 @@ async function testCommand(flags) {
9354
9365
  const data = err && err.response && err.response.data;
9355
9366
  if (shardStarts.length && shardStartBusy(err) && Date.now() < shardWaitDeadline) {
9356
9367
  console.error('Shard ' + (shardStarts.length + 1) + ' of ' + runOptions.parallel + ' is waiting for a free suite slot ' +
9357
- '(this platform instance runs a limited number of suites at once; the shards already running free them).');
9368
+ '(the platform runs a limited number of suites at once; the shards already running free them).');
9358
9369
  await new Promise((resolve) => setTimeout(resolve, 10000));
9359
9370
  continue;
9360
9371
  }
@@ -13538,6 +13549,7 @@ function createManagedWebsocketClient(baseUrl, urlSessions) {
13538
13549
  discardWebsocketOnCommFailure: true,
13539
13550
  connectHeaders: {
13540
13551
  'x-remits-ws-label': 'remits-cli',
13552
+ 'x-remits-client-kind': 'cli',
13541
13553
  'x-remits-ws-scope': headerScope,
13542
13554
  'x-remits-user-id': headerUserIds.length === 1 ? headerUserIds[0] : '',
13543
13555
  'x-remits-account-id': headerAccountIds.length === 1 ? headerAccountIds[0] : '',
@@ -13643,7 +13655,7 @@ function createManagedWebsocketClient(baseUrl, urlSessions) {
13643
13655
  ensureRuntimeWebsocketEntry(normalizedBaseUrl);
13644
13656
  runtimeState.websocket[normalizedBaseUrl] = {
13645
13657
  ...runtimeState.websocket[normalizedBaseUrl],
13646
- status: 'disconnected',
13658
+ status: client.remitsAuthBlocked ? 'authentication_required' : 'disconnected',
13647
13659
  disconnectedAt: new Date().toISOString()
13648
13660
  };
13649
13661
  console.log('[listener] Disconnected from', normalizedBaseUrl, '- will reconnect');
@@ -13673,7 +13685,7 @@ function createManagedWebsocketClient(baseUrl, urlSessions) {
13673
13685
  ensureRuntimeWebsocketEntry(normalizedBaseUrl);
13674
13686
  runtimeState.websocket[normalizedBaseUrl] = {
13675
13687
  ...runtimeState.websocket[normalizedBaseUrl],
13676
- status: 'disconnected',
13688
+ status: client.remitsAuthBlocked ? 'authentication_required' : 'disconnected',
13677
13689
  disconnectedAt: new Date().toISOString(),
13678
13690
  lastError: detail
13679
13691
  };
@@ -13700,6 +13712,12 @@ function createManagedWebsocketClient(baseUrl, urlSessions) {
13700
13712
  };
13701
13713
 
13702
13714
  managedClient.client = client;
13715
+ installWebsocketRetry(client, { onBlocked: code => {
13716
+ runtimeState.websocket[normalizedBaseUrl] = {
13717
+ ...runtimeState.websocket[normalizedBaseUrl], status: 'authentication_required', lastError: code
13718
+ };
13719
+ updateRuntimeServiceState();
13720
+ } });
13703
13721
  return managedClient;
13704
13722
  }
13705
13723
 
@@ -13745,6 +13763,9 @@ async function runManagedWebsocketWatchdog() {
13745
13763
  if (!managedClient || !managedClient.client) {
13746
13764
  continue;
13747
13765
  }
13766
+ // A session-file change replaces the client. The watchdog must not revive rejected credentials
13767
+ // or defeat the normal transport backoff while it is waiting to reconnect.
13768
+ if (managedClient.client.remitsAuthBlocked || managedClient.client.remitsRetryPending) continue;
13748
13769
  const state = ensureRuntimeWebsocketEntry(managedClient.baseUrl);
13749
13770
  const socket = managedClient.client.webSocket;
13750
13771
  const readyState = socket && typeof socket.readyState === 'number' ? socket.readyState : null;
package/lib/local-ai.js CHANGED
@@ -6,6 +6,7 @@ const fs = require('node:fs');
6
6
  const path = require('node:path');
7
7
  const crypto = require('node:crypto');
8
8
  const { spawn } = require('node:child_process');
9
+ const { installWebsocketRetry } = require('./websocket-retry');
9
10
 
10
11
  const MAX_BYTES = 8 * 1024 * 1024;
11
12
  const AGENTS = ['manual', 'codex', 'claude', 'gemini'];
@@ -354,6 +355,13 @@ function describe(state) {
354
355
  return parts.length ? ` (${parts.join('; ')})` : '';
355
356
  }
356
357
 
358
+ // HTTP polling recovers what a WebSocket notification missed, renews worker presence (it expires after 90
359
+ // seconds) and notices cancelled requests. With a live socket and nothing in flight, notifications carry
360
+ // delivery and a slow poll only renews presence; otherwise poll fast. Idle `ai serve` processes had been
361
+ // the largest share of all requests on the CLI node (2026-10-23: one request every 2s each, around the clock).
362
+ const FAST_POLL_MS = 2000;
363
+ const IDLE_POLL_MS = 30000;
364
+
357
365
  class Supervisor {
358
366
  constructor({ api, dir, worker, agent, model, log = console.error, run = runNative, clock = Date.now, onStop }) {
359
367
  Object.assign(this, { api, dir, worker, agent, model, log, run, clock, onStop });
@@ -361,14 +369,23 @@ class Supervisor {
361
369
  this.busySessions = new Set();
362
370
  this.stopping = false;
363
371
  this.polling = false;
372
+ this.socketReady = false;
364
373
  this.lastContact = clock();
365
374
  }
366
375
 
376
+ nextPollDelayMs() {
377
+ return this.socketReady && this.active.size === 0 ? IDLE_POLL_MS : FAST_POLL_MS;
378
+ }
379
+
367
380
  async register() { return this.api('register', { ...this.worker, agent: this.agent }); }
368
381
 
369
382
  async tick() {
370
- if (this.polling || this.stopping) return;
383
+ if (this.stopping) return;
384
+ // A wakeup during a poll gets ONE follow-up poll: that poll's answer may predate the notification,
385
+ // and the next scheduled poll can be 30 seconds away.
386
+ if (this.polling) { this.pollAgain = true; return; }
371
387
  this.polling = true;
388
+ this.pollAgain = false;
372
389
  try {
373
390
  const work = await this.api('work');
374
391
  this.lastContact = this.clock();
@@ -402,6 +419,7 @@ class Supervisor {
402
419
  this.onStop?.();
403
420
  }
404
421
  } finally { this.polling = false; }
422
+ if (this.pollAgain && !this.stopping) await this.tick();
405
423
  }
406
424
 
407
425
  async handle(summary, task) {
@@ -565,7 +583,15 @@ async function command({ subcommand, flags, context, root, axios, Client, WebSoc
565
583
  console.error(`Local AI serving ${baseUrl} account ${accountId} ${dataMode}: ${agent}, worker ${worker.workerId}, capacity ${maxConcurrent}. Ctrl-C to stop.`);
566
584
  process.once('SIGINT', stop);
567
585
  process.once('SIGTERM', stop);
568
- timer = setInterval(() => supervisor.tick(), 2000);
586
+ const schedule = () => {
587
+ if (supervisor.stopping) return;
588
+ timer = setTimeout(async () => {
589
+ try { await supervisor.tick(); } finally { schedule(); }
590
+ }, supervisor.nextPollDelayMs());
591
+ };
592
+ // A socket change re-paces at once, so losing the socket never leaves a 30-second gap.
593
+ const repace = () => { clearTimeout(timer); schedule(); };
594
+ schedule();
569
595
  const topic = session.websocketTopic || String(session.user.uuid || '').replace(/-/g, '');
570
596
  if (topic) {
571
597
  socket = new Client({
@@ -577,23 +603,37 @@ async function command({ subcommand, flags, context, root, axios, Client, WebSoc
577
603
  connectHeaders: { 'X-Auth-Token': session.token,
578
604
  'x-remits-ws-label': 'remits-cli-ai', 'x-remits-user-id': String(session.user.id) },
579
605
  onConnect: () => {
606
+ supervisor.socketReady = true;
607
+ repace();
580
608
  socket.subscribe('/topic/messages/' + topic, (message) => {
581
609
  try {
582
610
  const event = JSON.parse(message.body);
583
611
  if (event.type === 'cli_ai_request' && event.payload?.workerId === worker.workerId &&
584
- String(event.payload.accountId) === String(accountId) && event.payload.dataMode === dataMode) supervisor.tick();
612
+ String(event.payload.accountId) === String(accountId) && event.payload.dataMode === dataMode) {
613
+ // A request that starts here needs the fast poll at once, to notice a cancellation.
614
+ supervisor.tick().finally(repace);
615
+ }
585
616
  } catch (_) { /* HTTP polling is authoritative. */ }
586
617
  });
587
618
  supervisor.tick();
588
619
  },
589
- onStompError: () => console.error('Local AI WebSocket unavailable; authenticated polling continues.')
620
+ onWebSocketClose: () => { supervisor.socketReady = false; repace(); },
621
+ onStompError: () => {
622
+ supervisor.socketReady = false;
623
+ repace();
624
+ console.error('Local AI WebSocket unavailable; authenticated polling continues.');
625
+ }
590
626
  });
627
+ installWebsocketRetry(socket, { onBlocked: code => {
628
+ console.error(`Local AI WebSocket authentication required (${code}); reauthenticate and restart the worker.`);
629
+ } });
591
630
  socket.activate();
592
631
  }
593
632
  await supervisor.tick();
594
633
  await stopped;
595
634
  } finally {
596
- clearInterval(timer);
635
+ if (supervisor) supervisor.stopping = true;
636
+ clearTimeout(timer);
597
637
  process.removeListener('SIGINT', stop);
598
638
  process.removeListener('SIGTERM', stop);
599
639
  if (socket) await socket.deactivate({ force: true });
@@ -602,5 +642,5 @@ async function command({ subcommand, flags, context, root, axios, Client, WebSoc
602
642
  }
603
643
  }
604
644
 
605
- module.exports = { command, help, Supervisor, runNative, nativeCommand, consumeEvent, promptFor, parseDecision,
645
+ module.exports = { command, help, Supervisor, FAST_POLL_MS, IDLE_POLL_MS, runNative, nativeCommand, consumeEvent, promptFor, parseDecision,
606
646
  validateDecision, decisionSchema, offeredTools, continuation, assertTextRequest, stateDirectory, acquireLock, read, write, identifier };
@@ -0,0 +1,67 @@
1
+ 'use strict';
2
+
3
+ // The server sends stable, credential-free codes. A transport close code alone (including 1002)
4
+ // cannot distinguish expired credentials from a transient broker failure.
5
+ const TERMINAL_CODES = new Set(['credentials_missing', 'credentials_rejected', 'credentials_malformed',
6
+ 'session_required', 'topic_denied', 'destination_denied', 'recipient_denied']);
7
+
8
+ function terminalCode(frame) {
9
+ const headers = frame?.headers || {};
10
+ const code = headers['x-remits-error-code'];
11
+ return TERMINAL_CODES.has(code) ? code : null;
12
+ }
13
+
14
+ function installWebsocketRetry(client, { onBlocked = () => {}, random = Math.random,
15
+ setTimer = setTimeout, clearTimer = clearTimeout } = {}) {
16
+ const base = Math.max(1, Number(client.reconnectDelay) || 5000);
17
+ let failures = 0, wait = 0, timer, release;
18
+ const cancelWait = () => {
19
+ if (timer) clearTimer(timer);
20
+ timer = null;
21
+ if (release) release();
22
+ release = null;
23
+ client.remitsRetryPending = false;
24
+ };
25
+ const before = client.beforeConnect;
26
+ client.beforeConnect = async () => {
27
+ if (client.remitsAuthBlocked) { await client.deactivate(); return; }
28
+ if (wait > 0) {
29
+ client.remitsRetryPending = true;
30
+ await new Promise(resolve => { release = resolve; timer = setTimer(cancelWait, wait); });
31
+ // A stop/replacement may have resolved the wait; never read or send credentials afterwards.
32
+ if (!client.active || client.remitsAuthBlocked) return;
33
+ }
34
+ client.remitsRetryPending = false;
35
+ if (before) await before();
36
+ };
37
+ const connected = client.onConnect;
38
+ client.onConnect = frame => {
39
+ failures = 0; wait = 0; client.remitsRetryPending = false;
40
+ if (connected) connected(frame);
41
+ };
42
+ const closed = client.onWebSocketClose;
43
+ client.onWebSocketClose = event => {
44
+ const ceiling = Math.min(60000, base * Math.pow(2, Math.min(++failures, 5)));
45
+ // STOMP already waits base ms. Add jitter/backoff before its next CONNECT using public APIs.
46
+ wait = Math.max(0, Math.floor(ceiling / 2 + random() * ceiling / 2) - base);
47
+ client.remitsRetryPending = !client.remitsAuthBlocked;
48
+ if (closed) closed(event);
49
+ };
50
+ const error = client.onStompError;
51
+ client.onStompError = frame => {
52
+ const code = terminalCode(frame);
53
+ if (code) {
54
+ client.remitsAuthBlocked = code;
55
+ cancelWait();
56
+ // deactivate changes active synchronously, before STOMP schedules the close reconnect.
57
+ Promise.resolve(client.deactivate({ force: true })).catch(() => {});
58
+ }
59
+ if (error) error(frame);
60
+ if (code) onBlocked(code);
61
+ };
62
+ const deactivate = client.deactivate.bind(client);
63
+ client.deactivate = options => { cancelWait(); return deactivate(options); };
64
+ return client;
65
+ }
66
+
67
+ module.exports = { installWebsocketRetry, terminalCode };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remits/remits-cli",
3
- "version": "0.1.162",
3
+ "version": "0.1.164",
4
4
  "description": "Local CLI for auth, component sync, and live test execution against Remits",
5
5
  "license": "MIT",
6
6
  "private": false,
@@ -13,6 +13,7 @@
13
13
  "index.js",
14
14
  "lib/sync-operation.js",
15
15
  "lib/local-ai.js",
16
+ "lib/websocket-retry.js",
16
17
  "README.md",
17
18
  "scripts/prepare-index-toc.js",
18
19
  "skills/remits-cli/SKILL.md",