@wenrwa/marketplace-cli 0.6.3 → 0.6.5

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
@@ -14,11 +14,19 @@ npx @wenrwa/marketplace-cli
14
14
  ## Setup
15
15
 
16
16
  ```bash
17
- # Authenticate with the marketplace
18
- wen-market auth login --api-key YOUR_WALLET_OR_API_KEY
17
+ # Sign in with your browser (recommended for interactive use)
18
+ wen-market auth login
19
19
 
20
- # Optionally specify a custom API URL
21
- wen-market auth login --api-key YOUR_KEY --api-url http://localhost:3002/api/v1
20
+ # Prints an approval URL and code, opens your browser when possible, and
21
+ # works over SSH too (approve from any device — the code and URL are
22
+ # always printed). Approving on marketplace.wenrwa.com/settings/cli-login
23
+ # mints an API key named after this machine.
24
+
25
+ # CI / scripts: use a key created in Settings → API Keys instead
26
+ wen-market auth login --api-key wm_sk_...
27
+
28
+ # Point at a non-production API (local dev)
29
+ MARKETPLACE_API_URL=http://localhost:3002/api/v1 wen-market auth login
22
30
  ```
23
31
 
24
32
  ## Commands
@@ -26,8 +34,10 @@ wen-market auth login --api-key YOUR_KEY --api-url http://localhost:3002/api/v1
26
34
  ### auth — Authentication
27
35
 
28
36
  ```bash
29
- wen-market auth login --api-key <key> # Authenticate
30
- wen-market auth whoami # Show current session
37
+ wen-market auth login # Browser device-code login (interactive)
38
+ wen-market auth login --api-key wm_sk_... # CI/scripts: use an existing API key
39
+ wen-market auth whoami # Show current session
40
+ wen-market auth logout # Revoke the key server-side and remove it locally
31
41
  ```
32
42
 
33
43
  ### bounty — Bounty Lifecycle & Management
@@ -168,16 +178,19 @@ Config is saved to `~/.wen-market/config.json` after `auth login`:
168
178
 
169
179
  ```json
170
180
  {
171
- "apiKey": "your-wallet-or-api-key",
172
- "apiUrl": "http://localhost:3002/api/v1"
181
+ "apiKey": "wm_sk_...",
182
+ "apiUrl": "https://api.wenrwa.com/api/v1"
173
183
  }
174
184
  ```
175
185
 
186
+ `apiKey` is always a real API key (`wm_sk_...`), sent as `X-API-Key` on every
187
+ request — never a wallet public key.
188
+
176
189
  ## Environment Variables
177
190
 
178
191
  | Variable | Default | Description |
179
192
  |----------|---------|-------------|
180
- | `MARKETPLACE_API_URL` | `https://api.wenrwa.com/api/v1` | Override API URL |
193
+ | `MARKETPLACE_API_URL` | `https://api.wenrwa.com/api/v1` (production) | Override API URL, e.g. for local development |
181
194
 
182
195
  ## Development
183
196
 
@@ -54,6 +54,21 @@ describe('agent list', () => {
54
54
  spy.mockRestore();
55
55
  });
56
56
  });
57
+ describe('agent logs', () => {
58
+ // sio-07: `agent logs` streams via the SAME connectWebSocket() helper
59
+ // dashboard.ts uses, which is what carries the authenticated handshake
60
+ // (auth: { token }, path '/marketplace') — exercised directly in
61
+ // websocket.test.ts. This just pins that `agent logs` still wires through
62
+ // that one shared call site rather than opening its own socket.
63
+ it('streams via the shared authenticated websocket helper', async () => {
64
+ const { connectWebSocket } = require('../utils/websocket');
65
+ const logSpy = jest.spyOn(console, 'log').mockImplementation();
66
+ await runCommand('logs', 'wallet-abc-12345678');
67
+ expect(connectWebSocket).toHaveBeenCalledTimes(1);
68
+ expect(connectWebSocket).toHaveBeenCalledWith(expect.any(Function), { channel: 'agent', filters: { agentWallet: 'wallet-abc-12345678' } });
69
+ logSpy.mockRestore();
70
+ });
71
+ });
57
72
  describe('agent status', () => {
58
73
  it('displays agent details', async () => {
59
74
  mockClient.get.mockImplementation((path) => {
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,91 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ /**
4
+ * Fast wake — the relay socket is replaced within ~1s of a Fly resume, and
5
+ * within a few seconds even if the resume can't be seen.
6
+ *
7
+ * Production 2026-09-25: machine resumed in 0.25s, terminal back ~21s later —
8
+ * the bridge only noticed its dead socket on the next 20s heartbeat.
9
+ */
10
+ const relayClient_1 = require("../bridge/relayClient");
11
+ const sessionManager_1 = require("../bridge/sessionManager");
12
+ class FakePty {
13
+ write() { }
14
+ resize() { }
15
+ kill() { }
16
+ onData() { }
17
+ onExit() { }
18
+ }
19
+ const factory = { spawn: () => new FakePty() };
20
+ function fakeSocket() {
21
+ const handlers = new Map();
22
+ const sock = {
23
+ readyState: 1, pings: 0, terminated: 0,
24
+ send() { }, close() { handlers.get('close')?.(1006); },
25
+ terminate() { sock.terminated += 1; handlers.get('close')?.(1006); },
26
+ on(e, cb) { handlers.set(e, cb); },
27
+ fire(e, ...a) { handlers.get(e)?.(...a); },
28
+ ping() { sock.pings += 1; },
29
+ };
30
+ return sock;
31
+ }
32
+ function start(opts = {}) {
33
+ const socket = fakeSocket();
34
+ const intervals = [];
35
+ const deadlines = [];
36
+ (0, relayClient_1.startRelayClient)({
37
+ relayUrl: 'ws://relay.test', relayToken: 'tok', sessions: new sessionManager_1.SessionManager(factory),
38
+ wsFactory: () => socket,
39
+ setHeartbeatIntervalFn: (cb, ms) => { intervals.push({ cb, ms }); return intervals.length; },
40
+ clearHeartbeatIntervalFn: () => { },
41
+ setPongDeadlineFn: (cb, ms) => { const d = { cb, ms, cleared: false }; deadlines.push(d); return d; },
42
+ clearPongDeadlineFn: (h) => { h.cleared = true; },
43
+ ...(opts.resumed ? { resumeDetectorFactory: () => ({ check: opts.resumed }) } : {}),
44
+ setTimeoutFn: () => 0, clearTimeoutFn: () => { },
45
+ });
46
+ socket.fire('open');
47
+ const tick = (ms) => { for (const i of intervals)
48
+ if (i.ms === ms)
49
+ i.cb(); };
50
+ return { socket, intervals, deadlines, tick };
51
+ }
52
+ describe('fast wake: resume detection', () => {
53
+ it('checks for a resume every second while connected', () => {
54
+ const { intervals } = start({ resumed: () => false });
55
+ expect(intervals.some((i) => i.ms === 1000)).toBe(true);
56
+ });
57
+ it('a detected resume terminates the socket at once, so reconnect runs immediately', () => {
58
+ let resumed = false;
59
+ const { socket, tick } = start({ resumed: () => resumed });
60
+ tick(1000);
61
+ expect(socket.terminated).toBe(0);
62
+ resumed = true;
63
+ tick(1000);
64
+ expect(socket.terminated).toBe(1);
65
+ });
66
+ });
67
+ describe('fast wake: a ping must be answered within seconds', () => {
68
+ it('heartbeat defaults to every 5s (was 20s)', () => {
69
+ const { intervals } = start({ resumed: () => false });
70
+ expect(intervals.some((i) => i.ms === 5000)).toBe(true);
71
+ expect(intervals.some((i) => i.ms === 20_000)).toBe(false);
72
+ });
73
+ it('an unanswered ping is declared dead after its own 4s deadline, not a whole interval later', () => {
74
+ const { socket, tick, deadlines } = start({ resumed: () => false });
75
+ tick(5000);
76
+ expect(socket.pings).toBe(1);
77
+ const d = deadlines[deadlines.length - 1];
78
+ expect(d.ms).toBe(4000);
79
+ d.cb();
80
+ expect(socket.terminated).toBe(1);
81
+ });
82
+ it('a pong cancels the deadline — a healthy link is never killed', () => {
83
+ const { socket, tick, deadlines } = start({ resumed: () => false });
84
+ tick(5000);
85
+ socket.fire('pong');
86
+ const d = deadlines[deadlines.length - 1];
87
+ expect(d.cleared).toBe(true);
88
+ d.cb(); // even if a cleared timer somehow fired
89
+ expect(socket.terminated).toBe(0);
90
+ });
91
+ });
@@ -56,6 +56,12 @@ function startWithFakeSocket(socket, heartbeatMs) {
56
56
  ...(heartbeatMs === undefined ? {} : { heartbeatMs }),
57
57
  setHeartbeatIntervalFn: (cb, ms) => { intervals.push({ cb, ms }); return intervals.length; },
58
58
  clearHeartbeatIntervalFn: () => { },
59
+ // Fast wake (2026-09-27) added a per-ping deadline and a resume check;
60
+ // both have their own suite (bridge-relay-fast-wake.test.ts). Kept inert
61
+ // here so these tests still pin the original two-missed-beats rule.
62
+ setPongDeadlineFn: () => 0,
63
+ clearPongDeadlineFn: () => { },
64
+ resumeCheckMs: 0,
59
65
  // Never let a real reconnect timer fire during a unit test.
60
66
  setTimeoutFn: () => 0,
61
67
  clearTimeoutFn: () => { },
@@ -65,7 +71,7 @@ function startWithFakeSocket(socket, heartbeatMs) {
65
71
  // would be a different cadence, and isn't configured here anyway).
66
72
  const beat = () => {
67
73
  for (const i of intervals)
68
- if (i.ms === (heartbeatMs ?? 20_000))
74
+ if (i.ms === (heartbeatMs ?? 5_000))
69
75
  i.cb();
70
76
  };
71
77
  return { handle, sessions, beat, intervals };
@@ -147,8 +153,9 @@ describe('relay keepalive (twr-18)', () => {
147
153
  // so the default must leave a wide margin or the self-heal races a reboot.
148
154
  const socket = fakeSocket();
149
155
  const { intervals, handle } = startWithFakeSocket(socket);
150
- const beatMs = intervals.find((i) => i.ms === 20_000)?.ms;
151
- expect(beatMs).toBe(20_000);
156
+ // Fast wake (2026-09-27): default 5s (was 20s), plus a 4s per-ping deadline.
157
+ const beatMs = intervals.find((i) => i.ms === 5_000)?.ms;
158
+ expect(beatMs).toBe(5_000);
152
159
  expect(beatMs * 2).toBeLessThan(300_000);
153
160
  handle.close();
154
161
  });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,58 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ /**
4
+ * Fast wake — the bridge notices a Fly SUSPEND/resume at once.
5
+ *
6
+ * Production 2026-09-25: Fly resumed the machine in 0.25s, but the terminal
7
+ * came back ~21s later. Suspend freezes the guest (and its MONOTONIC clock,
8
+ * which Node timers run on), the relay drops the old socket meanwhile, and the
9
+ * bridge only found out on its next 20s heartbeat. A 1s tick comparing wall-
10
+ * clock progress with monotonic progress sees the frozen stretch immediately.
11
+ */
12
+ const resumeDetector_1 = require("../bridge/resumeDetector");
13
+ function clocks() {
14
+ let wall = 1_000_000;
15
+ let mono = 5_000;
16
+ return {
17
+ wallNow: () => wall,
18
+ monoNow: () => mono,
19
+ run(ms) { wall += ms; mono += ms; }, // normal time passing
20
+ suspend(ms) { wall += ms; }, // VM frozen: wall moves, mono does not
21
+ };
22
+ }
23
+ describe('createResumeDetector', () => {
24
+ it('says nothing while time passes normally', () => {
25
+ const c = clocks();
26
+ const d = (0, resumeDetector_1.createResumeDetector)({ wallNow: c.wallNow, monoNow: c.monoNow });
27
+ for (let i = 0; i < 10; i++) {
28
+ c.run(1000);
29
+ expect(d.check()).toBe(false);
30
+ }
31
+ });
32
+ it('flags a resume after the machine was frozen for longer than the threshold', () => {
33
+ const c = clocks();
34
+ const d = (0, resumeDetector_1.createResumeDetector)({ wallNow: c.wallNow, monoNow: c.monoNow, thresholdMs: 3000 });
35
+ c.run(1000);
36
+ expect(d.check()).toBe(false);
37
+ c.suspend(45 * 60_000);
38
+ c.run(1000);
39
+ expect(d.check()).toBe(true);
40
+ // Only once per freeze.
41
+ c.run(1000);
42
+ expect(d.check()).toBe(false);
43
+ });
44
+ it('ignores a short stall below the threshold (GC, a busy event loop)', () => {
45
+ const c = clocks();
46
+ const d = (0, resumeDetector_1.createResumeDetector)({ wallNow: c.wallNow, monoNow: c.monoNow, thresholdMs: 3000 });
47
+ c.suspend(1500);
48
+ c.run(1000);
49
+ expect(d.check()).toBe(false);
50
+ });
51
+ it('ignores the wall clock stepping BACKWARDS (an NTP correction is not a resume)', () => {
52
+ const c = clocks();
53
+ const d = (0, resumeDetector_1.createResumeDetector)({ wallNow: c.wallNow, monoNow: c.monoNow });
54
+ c.suspend(-60_000);
55
+ c.run(1000);
56
+ expect(d.check()).toBe(false);
57
+ });
58
+ });
@@ -4,10 +4,35 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  const chalk_1 = __importDefault(require("chalk"));
7
+ const events_1 = require("events");
8
+ // sio-07 (docs/prd/authenticated-sockets.md): the CLI is a headless client —
9
+ // it must authenticate the Socket.IO handshake with `auth: { token }`
10
+ // (never the retired `walletPubkey` field) against the server's
11
+ // `/marketplace` path (backend/src/server.ts), and surface a clear message
12
+ // on refusal. connectWebSocket() is the single call site both
13
+ // `dashboard.ts` and `agent.ts` (`agent logs`) share, so exercising it here
14
+ // covers the handshake contract for both commands.
15
+ const TEST_API_KEY = 'wm_sk_' + 'a'.repeat(64);
16
+ const mockIo = jest.fn();
17
+ jest.mock('socket.io-client', () => ({
18
+ io: (...args) => mockIo(...args),
19
+ }));
20
+ jest.mock('../utils/config', () => ({
21
+ requireConfig: jest.fn().mockReturnValue({
22
+ apiUrl: 'https://api.wenrwa.com/api/v1/api/marketplace',
23
+ apiKey: 'wm_sk_' + 'a'.repeat(64),
24
+ }),
25
+ }));
7
26
  const websocket_1 = require("../utils/websocket");
27
+ class FakeSocket extends events_1.EventEmitter {
28
+ disconnect = jest.fn();
29
+ }
8
30
  beforeAll(() => {
9
31
  chalk_1.default.level = 0;
10
32
  });
33
+ beforeEach(() => {
34
+ mockIo.mockReset();
35
+ });
11
36
  function makeEvent(event, data, timestamp) {
12
37
  return { event, data, timestamp: timestamp || '2024-06-15T14:30:00.000Z' };
13
38
  }
@@ -59,3 +84,71 @@ describe('formatEventJson()', () => {
59
84
  expect(result.event).toBe('test');
60
85
  });
61
86
  });
87
+ describe('connectWebSocket() handshake (sio-07)', () => {
88
+ it('authenticates with auth.token = the API key', () => {
89
+ const fake = new FakeSocket();
90
+ mockIo.mockReturnValue(fake);
91
+ (0, websocket_1.connectWebSocket)(() => { });
92
+ expect(mockIo).toHaveBeenCalledTimes(1);
93
+ const [, opts] = mockIo.mock.calls[0];
94
+ expect(opts.auth).toEqual({ token: TEST_API_KEY });
95
+ });
96
+ it('never sends the retired walletPubkey field', () => {
97
+ const fake = new FakeSocket();
98
+ mockIo.mockReturnValue(fake);
99
+ (0, websocket_1.connectWebSocket)(() => { });
100
+ const [, opts] = mockIo.mock.calls[0];
101
+ expect(opts.auth).not.toHaveProperty('walletPubkey');
102
+ expect(JSON.stringify(opts)).not.toContain('walletPubkey');
103
+ });
104
+ it('connects on the server\'s /marketplace Socket.IO path', () => {
105
+ const fake = new FakeSocket();
106
+ mockIo.mockReturnValue(fake);
107
+ (0, websocket_1.connectWebSocket)(() => { });
108
+ const [, opts] = mockIo.mock.calls[0];
109
+ expect(opts.path).toBe('/marketplace');
110
+ });
111
+ it('shows a clear message and tells the user to log in again on CREDENTIAL_INVALID', () => {
112
+ const fake = new FakeSocket();
113
+ mockIo.mockReturnValue(fake);
114
+ const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => { });
115
+ (0, websocket_1.connectWebSocket)(() => { });
116
+ const err = new Error('invalid credential');
117
+ err.data = { code: 'CREDENTIAL_INVALID' };
118
+ fake.emit('connect_error', err);
119
+ const printed = errorSpy.mock.calls.map((c) => c.join(' ')).join('\n');
120
+ expect(printed).toMatch(/wen-market auth login/);
121
+ errorSpy.mockRestore();
122
+ });
123
+ it('shows a clear message and tells the user to log in again on SESSION_REVOKED', () => {
124
+ const fake = new FakeSocket();
125
+ mockIo.mockReturnValue(fake);
126
+ const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => { });
127
+ (0, websocket_1.connectWebSocket)(() => { });
128
+ const err = new Error('session revoked');
129
+ err.data = { code: 'SESSION_REVOKED' };
130
+ fake.emit('connect_error', err);
131
+ const printed = errorSpy.mock.calls.map((c) => c.join(' ')).join('\n');
132
+ expect(printed).toMatch(/wen-market auth login/);
133
+ errorSpy.mockRestore();
134
+ });
135
+ it('falls back to a generic message for other connect errors', () => {
136
+ const fake = new FakeSocket();
137
+ mockIo.mockReturnValue(fake);
138
+ const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => { });
139
+ (0, websocket_1.connectWebSocket)(() => { });
140
+ fake.emit('connect_error', new Error('ECONNREFUSED'));
141
+ const printed = errorSpy.mock.calls.map((c) => c.join(' ')).join('\n');
142
+ expect(printed).toContain('ECONNREFUSED');
143
+ expect(printed).not.toMatch(/wen-market auth login/);
144
+ errorSpy.mockRestore();
145
+ });
146
+ it('subscribes to the requested channel on connect, unaffected by the auth change', () => {
147
+ const fake = new FakeSocket();
148
+ mockIo.mockReturnValue(fake);
149
+ const emitSpy = jest.spyOn(fake, 'emit');
150
+ (0, websocket_1.connectWebSocket)(() => { }, { channel: 'workspace', filters: { workspaceId: 'ws1' } });
151
+ fake.emit('connect');
152
+ expect(emitSpy).toHaveBeenCalledWith('subscribe', { channel: 'workspace', workspaceId: 'ws1' });
153
+ });
154
+ });
@@ -335,6 +335,26 @@ export interface RelayClientOpts {
335
335
  */
336
336
  setHeartbeatIntervalFn?: (cb: () => void, ms: number) => unknown;
337
337
  clearHeartbeatIntervalFn?: (handle: unknown) => void;
338
+ /**
339
+ * Fast wake (2026-09-27) — how long a single ping may go unanswered before
340
+ * the socket is declared dead. Default 4000. Before this, the deadline WAS
341
+ * the heartbeat interval (20s), so a dead socket survived up to 40s; that is
342
+ * most of why a resumed machine's terminal took ~21s to come back.
343
+ */
344
+ pongTimeoutMs?: number;
345
+ /** Injectable for the pong deadline only (tests). Defaults to setTimeout. */
346
+ setPongDeadlineFn?: (cb: () => void, ms: number) => unknown;
347
+ clearPongDeadlineFn?: (handle: unknown) => void;
348
+ /**
349
+ * Fast wake — how often to check for a Fly suspend/resume (resumeDetector.ts).
350
+ * A detected resume terminates the socket at once so the redial starts
351
+ * immediately. Default 1000; 0 disables. Runs on the heartbeat timer seam.
352
+ */
353
+ resumeCheckMs?: number;
354
+ /** Injectable for tests. */
355
+ resumeDetectorFactory?: () => {
356
+ check(): boolean;
357
+ };
338
358
  /**
339
359
  * Rock-solid sleep/wake — how often to push the PTY inventory (`sessions`
340
360
  * frame) while connected. Default 30_000ms.
@@ -56,6 +56,7 @@ exports.startRelayClient = startRelayClient;
56
56
  * that) — on re-pair the pane sends `attach` again and gets a fresh
57
57
  * `replay`, exactly as it would against the local server.
58
58
  */
59
+ const resumeDetector_1 = require("./resumeDetector");
59
60
  const fs = __importStar(require("fs"));
60
61
  const path = __importStar(require("path"));
61
62
  const ws_1 = require("ws");
@@ -239,7 +240,18 @@ function startRelayClient(opts) {
239
240
  const cancelInterval = opts.clearIntervalFn ?? ((h) => clearInterval(h));
240
241
  // pucc-int-03 activity-tap config (enabled by default; the container bridge
241
242
  // runs Claude Code, so observing its tool-use is the norm).
242
- const heartbeatMs = opts.heartbeatMs ?? 20_000;
243
+ // Fast wake (2026-09-27): 5s, not 20s — with the 4s pong deadline below a
244
+ // dead socket is found within ~9s even when a resume goes undetected.
245
+ const heartbeatMs = opts.heartbeatMs ?? 5_000;
246
+ const pongTimeoutMs = opts.pongTimeoutMs ?? 4_000;
247
+ const schedulePongDeadline = opts.setPongDeadlineFn ?? ((cb, ms) => {
248
+ const t = setTimeout(cb, ms);
249
+ t.unref?.();
250
+ return t;
251
+ });
252
+ const cancelPongDeadline = opts.clearPongDeadlineFn ?? ((h) => clearTimeout(h));
253
+ const resumeCheckMs = opts.resumeCheckMs ?? 1_000;
254
+ const makeResumeDetector = opts.resumeDetectorFactory ?? (() => (0, resumeDetector_1.createResumeDetector)());
243
255
  const scheduleHeartbeat = opts.setHeartbeatIntervalFn ?? ((cb, ms) => setInterval(cb, ms));
244
256
  const cancelHeartbeat = opts.clearHeartbeatIntervalFn ?? ((h) => clearInterval(h));
245
257
  const sessionsReportMs = opts.sessionsReportMs ?? 30_000;
@@ -746,16 +758,38 @@ function startRelayClient(opts) {
746
758
  // twr-18 relay keepalive (see RelayClientOpts#heartbeatMs). Per-connection:
747
759
  // recreated on every dial, torn down in onDown.
748
760
  let hbHandle = null;
761
+ let resumeHandle = null;
762
+ let pongDeadline = null;
749
763
  let awaitingPong = false;
764
+ const clearPongDeadline = () => {
765
+ if (pongDeadline != null)
766
+ cancelPongDeadline(pongDeadline);
767
+ pongDeadline = null;
768
+ };
750
769
  const stopHeartbeat = () => {
751
770
  if (hbHandle != null)
752
771
  cancelHeartbeat(hbHandle);
772
+ if (resumeHandle != null)
773
+ cancelHeartbeat(resumeHandle);
753
774
  hbHandle = null;
775
+ resumeHandle = null;
776
+ clearPongDeadline();
754
777
  awaitingPong = false;
755
778
  };
779
+ // Declare this socket dead and let the close handler's reconnect run.
780
+ // terminate() (not close()) — a half-open socket never completes a close
781
+ // handshake.
782
+ const dropSocket = (why) => {
783
+ console.error(`[bridge] ${why} — reconnecting`);
784
+ stopHeartbeat();
785
+ try {
786
+ (socket.terminate ?? socket.close).call(socket);
787
+ }
788
+ catch { /* the close handler still runs */ }
789
+ };
756
790
  // ANY inbound traffic proves the link is alive — a busy socket never needs
757
791
  // to ping at all, and a pong is just the idle case of the same proof.
758
- const markLinkAlive = () => { awaitingPong = false; };
792
+ const markLinkAlive = () => { awaitingPong = false; clearPongDeadline(); };
759
793
  const startHeartbeat = () => {
760
794
  // No ping() (test double, or a non-ws implementation) ⇒ no keepalive.
761
795
  // Never fake it: a heartbeat that can't be answered would kill every
@@ -767,23 +801,36 @@ function startRelayClient(opts) {
767
801
  return;
768
802
  if (awaitingPong) {
769
803
  // Previous ping went unanswered for a full interval: the server is
770
- // gone even though TCP still believes otherwise. terminate() (not
771
- // close()) — a half-open socket never completes a close handshake.
772
- console.error('[bridge] relay heartbeat timed out — reconnecting');
773
- stopHeartbeat();
774
- try {
775
- (socket.terminate ?? socket.close).call(socket);
776
- }
777
- catch { /* the close handler still runs */ }
804
+ // gone even though TCP still believes otherwise.
805
+ dropSocket('relay heartbeat timed out');
778
806
  return;
779
807
  }
780
808
  awaitingPong = true;
781
809
  try {
782
810
  socket.ping();
783
811
  }
784
- catch { /* a failed ping surfaces as the next tick's timeout */ }
812
+ catch { /* a failed ping surfaces via the deadline */ }
813
+ // Fast wake: the ping gets its OWN short deadline instead of waiting a
814
+ // whole interval for the next tick to notice.
815
+ clearPongDeadline();
816
+ pongDeadline = schedulePongDeadline(() => {
817
+ pongDeadline = null;
818
+ if (connected && awaitingPong)
819
+ dropSocket(`relay ping unanswered for ${pongTimeoutMs}ms`);
820
+ }, pongTimeoutMs);
785
821
  }, heartbeatMs);
786
822
  hbHandle?.unref?.();
823
+ // Fast wake: a Fly suspend froze this process; the socket it holds is
824
+ // almost certainly dead. Notice within a second of resuming and redial
825
+ // now, not on the next heartbeat (resumeDetector.ts).
826
+ if (resumeCheckMs > 0) {
827
+ const detector = makeResumeDetector();
828
+ resumeHandle = scheduleHeartbeat(() => {
829
+ if (connected && detector.check())
830
+ dropSocket('machine resumed from suspend');
831
+ }, resumeCheckMs);
832
+ resumeHandle?.unref?.();
833
+ }
787
834
  };
788
835
  socket.on('pong', markLinkAlive);
789
836
  socket.on('open', () => {
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Fast wake: has this machine just been RESUMED from a Fly suspend?
3
+ *
4
+ * Suspend freezes the whole guest, including the monotonic clock Node's
5
+ * timers run on, while real (wall-clock) time keeps going. On resume the wall
6
+ * clock is corrected forward and the monotonic clock simply continues. So in
7
+ * one short tick, wall-clock progress far exceeding monotonic progress means
8
+ * the process was frozen, and every socket it held is almost certainly dead
9
+ * (the relay drops a silent socket within ~90s).
10
+ *
11
+ * Production 2026-09-25: Fly resumed in 0.25s but the terminal took ~21s,
12
+ * because the bridge only learned its socket was dead on the next 20s
13
+ * heartbeat. relayClient checks this every second and redials at once.
14
+ * (browserIdle.ts relies on the same clock property for the browser.)
15
+ *
16
+ * Pure: both clocks injectable.
17
+ */
18
+ export interface ResumeDetectorOpts {
19
+ /** Wall-clock gap beyond monotonic progress that counts as a freeze. */
20
+ thresholdMs?: number;
21
+ wallNow?: () => number;
22
+ monoNow?: () => number;
23
+ }
24
+ export interface ResumeDetector {
25
+ /** True exactly once after a freeze longer than the threshold. */
26
+ check(): boolean;
27
+ }
28
+ export declare function createResumeDetector(opts?: ResumeDetectorOpts): ResumeDetector;
@@ -0,0 +1,22 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createResumeDetector = createResumeDetector;
4
+ const defaultMono = () => Number(process.hrtime.bigint() / 1000000n);
5
+ function createResumeDetector(opts = {}) {
6
+ const threshold = opts.thresholdMs ?? 3000;
7
+ const wallNow = opts.wallNow ?? Date.now;
8
+ const monoNow = opts.monoNow ?? defaultMono;
9
+ let lastWall = wallNow();
10
+ let lastMono = monoNow();
11
+ return {
12
+ check() {
13
+ const w = wallNow();
14
+ const m = monoNow();
15
+ const frozenFor = (w - lastWall) - (m - lastMono);
16
+ lastWall = w;
17
+ lastMono = m;
18
+ // Only a FORWARD jump: a backwards NTP step is not a resume.
19
+ return frozenFor > threshold;
20
+ },
21
+ };
22
+ }
@@ -10,12 +10,32 @@ const socket_io_client_1 = require("socket.io-client");
10
10
  const chalk_1 = __importDefault(require("chalk"));
11
11
  const config_1 = require("./config");
12
12
  const format_1 = require("./format");
13
+ // sio-07 (docs/prd/authenticated-sockets.md): headless clients (the CLI)
14
+ // authenticate the Socket.IO handshake with `auth: { token }` — the API key
15
+ // (`wm_sk_…`) — against the server's dedicated `/marketplace` path
16
+ // (backend/src/server.ts). The legacy `auth: { walletPubkey }` field proved
17
+ // nothing (public keys are public) and is retired; the server's
18
+ // socket-auth middleware (backend/src/middleware/socket-auth.middleware.ts)
19
+ // refuses bad/revoked credentials with a `connect_error` carrying
20
+ // `err.data.code` of CREDENTIAL_INVALID or SESSION_REVOKED.
21
+ const AUTH_LOGIN_HINT = 'Run `wen-market auth login` to sign in again.';
22
+ function connectRefusalMessage(err) {
23
+ const code = err?.data?.code;
24
+ if (code === 'CREDENTIAL_INVALID') {
25
+ return `Your API key is invalid or expired. ${AUTH_LOGIN_HINT}`;
26
+ }
27
+ if (code === 'SESSION_REVOKED') {
28
+ return `Your session was revoked. ${AUTH_LOGIN_HINT}`;
29
+ }
30
+ return `WebSocket error: ${err?.message}`;
31
+ }
13
32
  function connectWebSocket(onEvent, options = {}) {
14
33
  const config = (0, config_1.requireConfig)();
15
34
  const wsUrl = config.apiUrl.replace('/api/marketplace', '');
16
35
  const socket = (0, socket_io_client_1.io)(wsUrl, {
36
+ path: '/marketplace',
17
37
  transports: ['websocket'],
18
- auth: { walletPubkey: config.apiKey },
38
+ auth: { token: config.apiKey },
19
39
  });
20
40
  socket.on('connect', () => {
21
41
  if (options.channel) {
@@ -29,7 +49,7 @@ function connectWebSocket(onEvent, options = {}) {
29
49
  onEvent(event);
30
50
  });
31
51
  socket.on('connect_error', (err) => {
32
- console.error(chalk_1.default.red(`WebSocket error: ${err.message}`));
52
+ console.error(chalk_1.default.red(connectRefusalMessage(err)));
33
53
  });
34
54
  return socket;
35
55
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wenrwa/marketplace-cli",
3
- "version": "0.6.3",
3
+ "version": "0.6.5",
4
4
  "description": "CLI tool for the Wenrwa Agent Marketplace — monitor bounties, agents, workspaces, and treasury from your terminal",
5
5
  "author": "Wenrwa <dev@wenrwa.com>",
6
6
  "license": "MIT",