@wenrwa/marketplace-cli 0.7.5 → 0.7.7

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.
@@ -122,3 +122,50 @@ describe('MCP auto-registration', () => {
122
122
  expect((0, mcpRegister_1.redactToken)(cmd)).toContain('wm_ct_…<redacted>');
123
123
  });
124
124
  });
125
+ describe('MCP registration — installed server (fast start)', () => {
126
+ const token = 'wm_ct_' + 'c'.repeat(64);
127
+ const server = { command: '/usr/local/bin/node', args: ['/Users/u/.wen-market/mcp/node_modules/@wenrwa/marketplace-mcp/dist/index.js'] };
128
+ const runner = (listOutput) => {
129
+ const calls = [];
130
+ const run = async (cmd, args) => {
131
+ calls.push([cmd, ...args]);
132
+ return { stdout: args.includes('list') ? listOutput : '', code: 0 };
133
+ };
134
+ return { calls, run };
135
+ };
136
+ it('registers the absolute installed command, never npx', async () => {
137
+ const { calls, run } = runner('');
138
+ const result = await (0, mcpRegister_1.registerMcpServer)({ connectToken: token, apiUrl: 'https://api.x', run, server });
139
+ expect(result.action).toBe('registered');
140
+ const add = calls.find((c) => c.includes('add'));
141
+ expect(add.slice(add.indexOf('--') + 1)).toEqual([server.command, ...server.args]);
142
+ expect(add).not.toContain('npx');
143
+ });
144
+ it('migrates an OLD `npx @wenrwa/marketplace-mcp` registration: remove then add the installed one', async () => {
145
+ const { calls, run } = runner('wenrwa-marketplace: npx @wenrwa/marketplace-mcp - ✓ Connected\n');
146
+ const result = await (0, mcpRegister_1.registerMcpServer)({ connectToken: token, apiUrl: 'https://api.x', run, server });
147
+ expect(result.action).toBe('migrated');
148
+ const removeIdx = calls.findIndex((c) => c.includes('remove'));
149
+ const addIdx = calls.findIndex((c) => c.includes('add'));
150
+ expect(removeIdx).toBeGreaterThan(-1);
151
+ expect(addIdx).toBeGreaterThan(removeIdx);
152
+ expect(calls[addIdx]).toContain(server.args[0]);
153
+ });
154
+ it('skips when the installed command is already registered', async () => {
155
+ const { calls, run } = runner(`wenrwa-marketplace: ${server.command} ${server.args[0]} - ✓ Connected\n`);
156
+ const result = await (0, mcpRegister_1.registerMcpServer)({ connectToken: token, apiUrl: 'https://api.x', run, server });
157
+ expect(result.action).toBe('already-registered');
158
+ expect(calls.some((c) => c.includes('add') || c.includes('remove'))).toBe(false);
159
+ });
160
+ it('re-points a registration whose node binary moved', async () => {
161
+ const { run } = runner(`wenrwa-marketplace: /old/node ${server.args[0]} - ✓ Connected\n`);
162
+ const result = await (0, mcpRegister_1.registerMcpServer)({ connectToken: token, apiUrl: 'https://api.x', run, server });
163
+ expect(result.action).toBe('migrated');
164
+ });
165
+ it('fallback (no installed server): an existing registration is left alone', async () => {
166
+ const { calls, run } = runner(`wenrwa-marketplace: ${server.command} ${server.args[0]} - ✓ Connected\n`);
167
+ const result = await (0, mcpRegister_1.registerMcpServer)({ connectToken: token, apiUrl: 'https://api.x', run });
168
+ expect(result.action).toBe('already-registered');
169
+ expect(calls.some((c) => c.includes('add'))).toBe(false);
170
+ });
171
+ });
@@ -114,3 +114,23 @@ describe('resolveHooksDir', () => {
114
114
  expect((0, hiveopSettings_1.resolveHooksDir)()).toBe(path.join(__dirname, '..', '..', 'hooks'));
115
115
  });
116
116
  });
117
+ describe('SessionStart loads .hiveop/context.md (2026-10-04: the local context was written but never read)', () => {
118
+ it('registers the session-start hook alongside the activity hooks', () => {
119
+ const json = JSON.parse((0, hiveopSettings_1.buildHiveOpSettingsContent)('/opt/x/hooks'));
120
+ const cmds = (json.hooks.SessionStart ?? []).flatMap((m) => m.hooks.map(h => h.command));
121
+ expect(cmds).toEqual(['node "/opt/x/hooks/session-start.js"']);
122
+ });
123
+ it('the hook prints the context file for the session cwd, and nothing when absent', () => {
124
+ const fs = require('fs');
125
+ const os = require('os');
126
+ const path = require('path');
127
+ const cp = require('child_process');
128
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hiveop-ss-'));
129
+ const hook = path.join(__dirname, '..', '..', 'hooks', 'session-start.js');
130
+ const run = () => cp.execFileSync('node', [hook], { input: JSON.stringify({ cwd: dir }) }).toString();
131
+ expect(run()).toBe('');
132
+ fs.mkdirSync(path.join(dir, '.hiveop'));
133
+ fs.writeFileSync(path.join(dir, '.hiveop', 'context.md'), '## Browser\ncall show_in_browser');
134
+ expect(run()).toContain('call show_in_browser');
135
+ });
136
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,218 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const launchGate_1 = require("../bridge/launchGate");
4
+ const sessionManager_1 = require("../bridge/sessionManager");
5
+ class FakePty {
6
+ spawnOpts;
7
+ written = [];
8
+ resizes = [];
9
+ killed = false;
10
+ dataCbs = [];
11
+ exitCbs = [];
12
+ constructor(spawnOpts) {
13
+ this.spawnOpts = spawnOpts;
14
+ }
15
+ write(d) { this.written.push(d); }
16
+ resize(c, r) { this.resizes.push([c, r]); }
17
+ kill() { this.killed = true; }
18
+ onData(cb) { this.dataCbs.push(cb); }
19
+ onExit(cb) { this.exitCbs.push(cb); }
20
+ emitData(d) { for (const cb of this.dataCbs)
21
+ cb(d); }
22
+ emitExit(c) { for (const cb of this.exitCbs)
23
+ cb(c); }
24
+ }
25
+ /** Fake clock: timers fire only when advanced. */
26
+ const fakeClock = () => {
27
+ let t = 0;
28
+ let nextId = 1;
29
+ const timers = new Map();
30
+ return {
31
+ scheduleTimeout: (cb, ms) => { const id = nextId++; timers.set(id, { at: t + ms, cb }); return id; },
32
+ cancelTimeout: (h) => { timers.delete(h); },
33
+ advance(ms) {
34
+ t += ms;
35
+ for (const [id, timer] of [...timers]) {
36
+ if (timer.at <= t && timers.has(id)) {
37
+ timers.delete(id);
38
+ timer.cb();
39
+ }
40
+ }
41
+ },
42
+ };
43
+ };
44
+ const spawner = () => {
45
+ const ptys = [];
46
+ const spawn = (size) => { const p = new FakePty(size); ptys.push(p); return p; };
47
+ return { ptys, spawn };
48
+ };
49
+ const collect = (pty) => {
50
+ const out = [];
51
+ pty.onData((d) => out.push(d));
52
+ return out;
53
+ };
54
+ describe('LaunchGate', () => {
55
+ it('starts two at once; the third queues with "Starting… (2 ahead)" visible immediately', () => {
56
+ const clock = fakeClock();
57
+ const gate = new launchGate_1.LaunchGate({ ...clock, log: () => { } });
58
+ const { ptys, spawn } = spawner();
59
+ gate.launch(spawn, { cols: 80, rows: 24 });
60
+ gate.launch(spawn, { cols: 80, rows: 24 });
61
+ const third = gate.launch(spawn, { cols: 80, rows: 24 });
62
+ const out = collect(third); // subscribed AFTER launch, as SessionManager does
63
+ expect(ptys).toHaveLength(2);
64
+ expect(gate.queuedCount()).toBe(1);
65
+ expect(out.join('')).toContain('Starting… (2 ahead)');
66
+ });
67
+ it('a starting launch frees its slot on FIRST OUTPUT, and the queue drains FIFO', () => {
68
+ const clock = fakeClock();
69
+ const gate = new launchGate_1.LaunchGate({ ...clock, log: () => { } });
70
+ const { ptys, spawn } = spawner();
71
+ const a = gate.launch(spawn, { cols: 80, rows: 24 });
72
+ gate.launch(spawn, { cols: 80, rows: 24 });
73
+ const c = gate.launch(spawn, { cols: 100, rows: 30 });
74
+ const d = gate.launch(spawn, { cols: 80, rows: 24 });
75
+ const outA = collect(a);
76
+ const outC = collect(c);
77
+ const outD = collect(d);
78
+ expect(outD.join('')).toContain('Starting… (3 ahead)');
79
+ ptys[0].emitData('hello');
80
+ expect(outA).toEqual(['hello']);
81
+ expect(ptys).toHaveLength(3);
82
+ expect(ptys[2].spawnOpts).toMatchObject({ cols: 100, rows: 30 }); // c went first
83
+ expect(outD[outD.length - 1]).toBe((0, launchGate_1.queuedLine)(2)); // renumbered in place
84
+ ptys[2].emitData('claude ui');
85
+ expect(outC[outC.length - 1]).toBe('claude ui');
86
+ expect(ptys).toHaveLength(4);
87
+ });
88
+ it('a launch that prints nothing frees its slot after 8s', () => {
89
+ const clock = fakeClock();
90
+ const gate = new launchGate_1.LaunchGate({ ...clock, log: () => { } });
91
+ const { ptys, spawn } = spawner();
92
+ for (let i = 0; i < 3; i++)
93
+ gate.launch(spawn, { cols: 80, rows: 24 });
94
+ clock.advance(7_999);
95
+ expect(ptys).toHaveLength(2);
96
+ clock.advance(1);
97
+ expect(ptys).toHaveLength(3);
98
+ });
99
+ it('an exit frees the slot too', () => {
100
+ const clock = fakeClock();
101
+ const gate = new launchGate_1.LaunchGate({ ...clock, log: () => { } });
102
+ const { ptys, spawn } = spawner();
103
+ for (let i = 0; i < 3; i++)
104
+ gate.launch(spawn, { cols: 80, rows: 24 });
105
+ ptys[1].emitExit(1);
106
+ expect(ptys).toHaveLength(3);
107
+ });
108
+ it('killing a queued launch removes it without spawning; the ones behind move up', () => {
109
+ const clock = fakeClock();
110
+ const gate = new launchGate_1.LaunchGate({ ...clock, log: () => { } });
111
+ const { ptys, spawn } = spawner();
112
+ gate.launch(spawn, { cols: 80, rows: 24 });
113
+ gate.launch(spawn, { cols: 80, rows: 24 });
114
+ const q1 = gate.launch(spawn, { cols: 80, rows: 24 });
115
+ const q2 = gate.launch(spawn, { cols: 80, rows: 24 });
116
+ const out2 = collect(q2);
117
+ q1.kill();
118
+ expect(gate.queuedCount()).toBe(1);
119
+ expect(out2[out2.length - 1]).toBe((0, launchGate_1.queuedLine)(2));
120
+ ptys[0].emitData('x');
121
+ expect(ptys).toHaveLength(3);
122
+ clock.advance(10_000);
123
+ expect(ptys).toHaveLength(3); // the killed one never spawns
124
+ });
125
+ it('input typed while queued is delivered once the process starts; resize is applied', () => {
126
+ const clock = fakeClock();
127
+ const gate = new launchGate_1.LaunchGate({ ...clock, log: () => { } });
128
+ const { ptys, spawn } = spawner();
129
+ gate.launch(spawn, { cols: 80, rows: 24 });
130
+ gate.launch(spawn, { cols: 80, rows: 24 });
131
+ const q = gate.launch(spawn, { cols: 80, rows: 24 });
132
+ q.write('hi');
133
+ q.resize(120, 40);
134
+ ptys[0].emitData('x');
135
+ expect(ptys[2].spawnOpts).toMatchObject({ cols: 120, rows: 40 });
136
+ expect(ptys[2].written).toEqual(['hi']);
137
+ });
138
+ it('a queued spawn that throws prints a line and exits instead of hanging the pane', () => {
139
+ const clock = fakeClock();
140
+ const gate = new launchGate_1.LaunchGate({ ...clock, log: () => { } });
141
+ const { ptys, spawn } = spawner();
142
+ gate.launch(spawn, { cols: 80, rows: 24 });
143
+ gate.launch(spawn, { cols: 80, rows: 24 });
144
+ const q = gate.launch(() => { throw new Error('EAGAIN'); }, { cols: 80, rows: 24 });
145
+ const out = collect(q);
146
+ const exits = [];
147
+ q.onExit((c) => exits.push(c));
148
+ ptys[0].emitData('x');
149
+ expect(out.join('')).toContain("Claude couldn't start: EAGAIN");
150
+ expect(exits).toEqual([1]);
151
+ expect(gate.startingCount()).toBe(1);
152
+ });
153
+ it('an immediate spawn that throws propagates and frees the slot', () => {
154
+ const gate = new launchGate_1.LaunchGate({ ...fakeClock(), log: () => { } });
155
+ expect(() => gate.launch(() => { throw new Error('ENOMEM'); }, { cols: 80, rows: 24 })).toThrow('ENOMEM');
156
+ expect(gate.startingCount()).toBe(0);
157
+ });
158
+ });
159
+ describe('SessionManager staggers agent launches, never shells', () => {
160
+ const snapshot = () => ({
161
+ write: () => Promise.resolve(),
162
+ resize: () => { },
163
+ serialize: () => Promise.resolve(''),
164
+ dispose: () => { },
165
+ });
166
+ const setup = (everyOpenIsAgentLaunch = false) => {
167
+ const clock = fakeClock();
168
+ const ptys = [];
169
+ const factory = { spawn: (o) => { const p = new FakePty(o); ptys.push(p); return p; } };
170
+ const sm = new sessionManager_1.SessionManager(factory, {
171
+ snapshotFactory: snapshot,
172
+ launchGate: { ...clock, log: () => { } },
173
+ launchSupervisor: { settle: (fn) => fn(), log: () => { } },
174
+ everyOpenIsAgentLaunch,
175
+ });
176
+ return { sm, ptys, clock };
177
+ };
178
+ it('seven claude opens → two processes now, all seven sessions registered, the rest follow as output arrives', () => {
179
+ const { sm, ptys } = setup();
180
+ const ids = Array.from({ length: 7 }, () => sm.open({ cols: 80, rows: 24, command: 'claude', args: [] }));
181
+ expect(sm.list()).toEqual(ids);
182
+ expect(ptys).toHaveLength(2);
183
+ ptys[0].emitData('ready');
184
+ ptys[1].emitData('ready');
185
+ expect(ptys).toHaveLength(4);
186
+ });
187
+ it('a queued pane is never blank: its snapshot (what an attach replays) holds the Starting line', () => {
188
+ const writes = [];
189
+ const recording = () => {
190
+ const mine = [];
191
+ writes.push(mine);
192
+ return { write: (d) => { mine.push(d); return Promise.resolve(); }, resize: () => { }, serialize: () => Promise.resolve(mine.join('')), dispose: () => { } };
193
+ };
194
+ const factory = { spawn: (o) => new FakePty(o) };
195
+ const sm = new sessionManager_1.SessionManager(factory, { snapshotFactory: recording, launchGate: { ...fakeClock(), log: () => { } } });
196
+ for (let i = 0; i < 3; i++)
197
+ sm.open({ cols: 80, rows: 24, command: 'claude', args: [] });
198
+ expect(writes[2].join('')).toContain('Starting… (2 ahead)');
199
+ expect(writes[0].join('')).toBe('');
200
+ });
201
+ it('plain shells are never delayed, even with claude launches queued', () => {
202
+ const { sm, ptys } = setup();
203
+ for (let i = 0; i < 4; i++)
204
+ sm.open({ cols: 80, rows: 24, command: 'claude', args: [] });
205
+ expect(ptys).toHaveLength(2);
206
+ sm.open({ cols: 80, rows: 24 });
207
+ expect(ptys).toHaveLength(3);
208
+ expect(ptys[2].spawnOpts.command).toBeUndefined();
209
+ });
210
+ it('hosted lane (everyOpenIsAgentLaunch): shell opens are gated, since autolaunch turns each into claude', () => {
211
+ const { sm, ptys, clock } = setup(true);
212
+ for (let i = 0; i < 3; i++)
213
+ sm.open({ cols: 80, rows: 24 });
214
+ expect(ptys).toHaveLength(2);
215
+ clock.advance(8_000);
216
+ expect(ptys).toHaveLength(3);
217
+ });
218
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,100 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ const path = __importStar(require("path"));
37
+ const mcpInstall_1 = require("../bridge/mcpInstall");
38
+ const BASE = '/home/u/.wen-market/mcp';
39
+ const PKG = path.join(BASE, 'node_modules', '@wenrwa', 'marketplace-mcp', 'package.json');
40
+ const ENTRY = (0, mcpInstall_1.mcpEntryPath)(BASE);
41
+ /** In-memory fs; `npmInstalls` (if given) is what a successful npm run leaves behind. */
42
+ const fakeDeps = (opts = {}) => {
43
+ const files = { ...(opts.files ?? {}) };
44
+ const npmCalls = [];
45
+ const deps = {
46
+ runNpm: async (args) => {
47
+ npmCalls.push(args);
48
+ if (opts.npm === 'throw')
49
+ throw new Error('ENOTFOUND registry.npmjs.org');
50
+ if (opts.npm === 'fail')
51
+ return { code: 1 };
52
+ files[PKG] = JSON.stringify({ version: opts.installs ?? mcpInstall_1.MCP_PINNED_VERSION });
53
+ files[ENTRY] = '// server';
54
+ return { code: 0 };
55
+ },
56
+ exists: (p) => p in files,
57
+ readFile: (p) => { if (!(p in files))
58
+ throw new Error('ENOENT'); return files[p]; },
59
+ writeFile: (p, c) => { files[p] = c; },
60
+ mkdirp: () => { },
61
+ now: () => opts.now ?? 1_000_000_000,
62
+ nodePath: '/usr/local/bin/node',
63
+ };
64
+ return { deps, files, npmCalls };
65
+ };
66
+ const installed = (version) => ({ [PKG]: JSON.stringify({ version }), [ENTRY]: '// server' });
67
+ describe('ensureMcpInstalled', () => {
68
+ it('already at the pinned version → no npm run, absolute node + entry', async () => {
69
+ const { deps, npmCalls } = fakeDeps({ files: installed(mcpInstall_1.MCP_PINNED_VERSION) });
70
+ const r = await (0, mcpInstall_1.ensureMcpInstalled)({ baseDir: BASE }, deps);
71
+ expect(npmCalls).toHaveLength(0);
72
+ expect(r).toMatchObject({ ok: true, action: 'present', command: '/usr/local/bin/node', args: [ENTRY] });
73
+ });
74
+ it('missing → npm install --prefix <baseDir> of the pinned version', async () => {
75
+ const { deps, npmCalls, files } = fakeDeps();
76
+ const r = await (0, mcpInstall_1.ensureMcpInstalled)({ baseDir: BASE }, deps);
77
+ expect(r).toMatchObject({ ok: true, action: 'installed', version: mcpInstall_1.MCP_PINNED_VERSION });
78
+ expect(npmCalls[0]).toEqual(expect.arrayContaining(['install', '--prefix', BASE, `@wenrwa/marketplace-mcp@${mcpInstall_1.MCP_PINNED_VERSION}`]));
79
+ expect(files[path.join(BASE, 'package.json')]).toBeDefined();
80
+ });
81
+ it('offline with nothing installed → not ok (caller falls back to npx), and records the failure', async () => {
82
+ const { deps, files } = fakeDeps({ npm: 'throw' });
83
+ const r = await (0, mcpInstall_1.ensureMcpInstalled)({ baseDir: BASE }, deps);
84
+ expect(r).toMatchObject({ ok: false, action: 'failed' });
85
+ expect(files[path.join(BASE, '.last-failed-install')]).toBe('1000000000');
86
+ });
87
+ it('a recent failure is not retried for INSTALL_RETRY_MS; after that it is', async () => {
88
+ const stamp = { [path.join(BASE, '.last-failed-install')]: '1000000000' };
89
+ const soon = fakeDeps({ files: stamp, now: 1_000_000_000 + mcpInstall_1.INSTALL_RETRY_MS - 1 });
90
+ expect(await (0, mcpInstall_1.ensureMcpInstalled)({ baseDir: BASE }, soon.deps)).toMatchObject({ ok: false, action: 'skipped-recent-failure' });
91
+ expect(soon.npmCalls).toHaveLength(0);
92
+ const later = fakeDeps({ files: stamp, now: 1_000_000_000 + mcpInstall_1.INSTALL_RETRY_MS });
93
+ expect(await (0, mcpInstall_1.ensureMcpInstalled)({ baseDir: BASE }, later.deps)).toMatchObject({ ok: true, action: 'installed' });
94
+ });
95
+ it('upgrade fails but an older copy is installed → keep using it', async () => {
96
+ const { deps } = fakeDeps({ files: installed('0.8.0'), npm: 'fail' });
97
+ const r = await (0, mcpInstall_1.ensureMcpInstalled)({ baseDir: BASE }, deps);
98
+ expect(r).toMatchObject({ ok: true, action: 'stale-copy', version: '0.8.0', args: [ENTRY] });
99
+ });
100
+ });
@@ -88,6 +88,9 @@ function buildHiveOpSettingsContent(hooksDir) {
88
88
  },
89
89
  ],
90
90
  Stop: [{ hooks: [{ type: 'command', command: hookCommand(hooksDir, 'stop.js') }] }],
91
+ // Loads `.hiveop/context.md` (the bridge's managed context) into every
92
+ // local session — without it that file was written but never read.
93
+ SessionStart: [{ hooks: [{ type: 'command', command: hookCommand(hooksDir, 'session-start.js') }] }],
91
94
  },
92
95
  };
93
96
  return JSON.stringify(obj, null, 2) + '\n';
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Launch gate — staggers `claude` start-ups so N agents opened at once don't
3
+ * all boot in the same second.
4
+ *
5
+ * Observed 2026-10-04: seven agents added together spawned seven `claude`
6
+ * processes, each spawning its MCP server, and the machine's load average hit
7
+ * 82 — every pane sat blank for one to two minutes. Booting them a couple at
8
+ * a time finishes sooner for all of them.
9
+ *
10
+ * Rules:
11
+ * - at most `maxStarting` launches are "starting" at once (default 2);
12
+ * - a launch stops "starting" at its FIRST OUTPUT, or after `releaseAfterMs`
13
+ * (default 8s), or when it exits/is killed — whichever comes first;
14
+ * - further launches queue FIFO; a queued pane immediately shows
15
+ * "Starting… (N ahead)" so it is never blank, and the count updates as the
16
+ * queue moves.
17
+ *
18
+ * Only agent launches go through here (SessionManager decides which); plain
19
+ * shells are never delayed.
20
+ *
21
+ * `gate.launch(spawn)` returns a PtyLike synchronously, so SessionManager can
22
+ * register the session and a pane can attach before the process exists.
23
+ * Writes typed while queued are held (capped) and delivered after spawn;
24
+ * resizes are remembered and applied.
25
+ */
26
+ import type { PtyLike } from './sessionManager';
27
+ export declare const DEFAULT_MAX_STARTING = 2;
28
+ export declare const DEFAULT_RELEASE_AFTER_MS = 8000;
29
+ export interface LaunchGateOptions {
30
+ maxStarting?: number;
31
+ releaseAfterMs?: number;
32
+ scheduleTimeout?: (cb: () => void, ms: number) => unknown;
33
+ cancelTimeout?: (handle: unknown) => void;
34
+ log?: (msg: string) => void;
35
+ }
36
+ /** The line a queued pane shows. `\r\x1b[2K` rewrites it in place as the count moves. */
37
+ export declare function queuedLine(ahead: number): string;
38
+ export declare class LaunchGate {
39
+ private maxStarting;
40
+ private releaseAfterMs;
41
+ private scheduleTimeout;
42
+ private cancelTimeout;
43
+ private log;
44
+ private starting;
45
+ private queue;
46
+ constructor(opts?: LaunchGateOptions);
47
+ /** Launches currently in their start-up window. */
48
+ startingCount(): number;
49
+ /** Launches waiting for a slot. */
50
+ queuedCount(): number;
51
+ /**
52
+ * Gate one launch. `spawn` receives the latest geometry. If a slot is free
53
+ * it runs synchronously (a throw propagates, exactly like an ungated
54
+ * spawn); otherwise the launch queues and the returned PtyLike stands in.
55
+ */
56
+ launch(spawn: (size: {
57
+ cols: number;
58
+ rows: number;
59
+ }) => PtyLike, size: {
60
+ cols: number;
61
+ rows: number;
62
+ }): PtyLike;
63
+ /** Start queued launches while slots are free. */
64
+ private drain;
65
+ /** Tell every queued launch how many are ahead of it now. */
66
+ private renumber;
67
+ }
@@ -0,0 +1,187 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.LaunchGate = exports.DEFAULT_RELEASE_AFTER_MS = exports.DEFAULT_MAX_STARTING = void 0;
4
+ exports.queuedLine = queuedLine;
5
+ exports.DEFAULT_MAX_STARTING = 2;
6
+ exports.DEFAULT_RELEASE_AFTER_MS = 8_000;
7
+ /** Cap on keystrokes held for a queued launch. */
8
+ const MAX_HELD_INPUT = 4 * 1024;
9
+ /** The line a queued pane shows. `\r\x1b[2K` rewrites it in place as the count moves. */
10
+ function queuedLine(ahead) {
11
+ return `\r\x1b[2K\x1b[2mStarting… (${ahead} ahead)\x1b[0m`;
12
+ }
13
+ /** Wipes the queued line just before the real process starts drawing. */
14
+ const CLEAR_QUEUED_LINE = '\r\x1b[2K';
15
+ class LaunchGate {
16
+ maxStarting;
17
+ releaseAfterMs;
18
+ scheduleTimeout;
19
+ cancelTimeout;
20
+ log;
21
+ starting = 0;
22
+ queue = [];
23
+ constructor(opts = {}) {
24
+ this.maxStarting = Math.max(1, opts.maxStarting ?? exports.DEFAULT_MAX_STARTING);
25
+ this.releaseAfterMs = opts.releaseAfterMs ?? exports.DEFAULT_RELEASE_AFTER_MS;
26
+ this.scheduleTimeout = opts.scheduleTimeout ?? ((cb, ms) => {
27
+ const h = setTimeout(cb, ms);
28
+ h.unref?.(); // a pending release must never keep the process alive
29
+ return h;
30
+ });
31
+ this.cancelTimeout = opts.cancelTimeout ?? ((h) => clearTimeout(h));
32
+ this.log = opts.log ?? ((m) => console.error(m));
33
+ }
34
+ /** Launches currently in their start-up window. */
35
+ startingCount() {
36
+ return this.starting;
37
+ }
38
+ /** Launches waiting for a slot. */
39
+ queuedCount() {
40
+ return this.queue.length;
41
+ }
42
+ /**
43
+ * Gate one launch. `spawn` receives the latest geometry. If a slot is free
44
+ * it runs synchronously (a throw propagates, exactly like an ungated
45
+ * spawn); otherwise the launch queues and the returned PtyLike stands in.
46
+ */
47
+ launch(spawn, size) {
48
+ const dataListeners = [];
49
+ const exitListeners = [];
50
+ /** Output emitted before SessionManager subscribed (the queued line). */
51
+ let unheard = [];
52
+ const emitData = (d) => {
53
+ if (dataListeners.length === 0) {
54
+ unheard.push(d);
55
+ return;
56
+ }
57
+ for (const cb of dataListeners)
58
+ cb(d);
59
+ };
60
+ const emitExit = (code) => { for (const cb of exitListeners)
61
+ cb(code); };
62
+ let cols = size.cols;
63
+ let rows = size.rows;
64
+ let real = null;
65
+ let killed = false;
66
+ let heldInput = '';
67
+ let queuedEntry = null;
68
+ // Slot bookkeeping for THIS launch: released exactly once.
69
+ let holdsSlot = false;
70
+ let releaseTimer = null;
71
+ const release = () => {
72
+ if (!holdsSlot)
73
+ return;
74
+ holdsSlot = false;
75
+ if (releaseTimer !== null)
76
+ this.cancelTimeout(releaseTimer);
77
+ releaseTimer = null;
78
+ this.starting -= 1;
79
+ this.drain();
80
+ };
81
+ const startReal = (fromQueue) => {
82
+ holdsSlot = true;
83
+ this.starting += 1;
84
+ let pty;
85
+ try {
86
+ pty = spawn({ cols, rows });
87
+ }
88
+ catch (err) {
89
+ release();
90
+ if (!fromQueue)
91
+ throw err;
92
+ const msg = err instanceof Error ? err.message : String(err);
93
+ this.log(`[bridge] queued claude launch failed to spawn: ${msg}`);
94
+ emitData(`${CLEAR_QUEUED_LINE}\x1b[33mClaude couldn't start: ${msg}. Reopen the pane to try again.\x1b[0m\r\n`);
95
+ emitExit(1);
96
+ return;
97
+ }
98
+ real = pty;
99
+ releaseTimer = this.scheduleTimeout(release, this.releaseAfterMs);
100
+ if (fromQueue)
101
+ emitData(CLEAR_QUEUED_LINE);
102
+ pty.onData((d) => {
103
+ release();
104
+ emitData(d);
105
+ });
106
+ pty.onExit((code) => {
107
+ release();
108
+ emitExit(code);
109
+ });
110
+ if (fromQueue && heldInput) {
111
+ const input = heldInput;
112
+ heldInput = '';
113
+ pty.write(input);
114
+ }
115
+ };
116
+ if (this.starting < this.maxStarting && this.queue.length === 0) {
117
+ startReal(false);
118
+ }
119
+ else {
120
+ queuedEntry = {
121
+ start: () => { queuedEntry = null; if (!killed)
122
+ startReal(true); },
123
+ notify: (ahead) => emitData(queuedLine(ahead)),
124
+ };
125
+ this.queue.push(queuedEntry);
126
+ emitData(queuedLine(this.queue.length - 1 + this.starting));
127
+ }
128
+ return {
129
+ write: (d) => {
130
+ if (real) {
131
+ real.write(d);
132
+ return;
133
+ }
134
+ if (heldInput.length + d.length <= MAX_HELD_INPUT)
135
+ heldInput += d;
136
+ },
137
+ resize: (c, r) => {
138
+ cols = c;
139
+ rows = r;
140
+ real?.resize(c, r);
141
+ },
142
+ kill: () => {
143
+ killed = true;
144
+ if (real) {
145
+ real.kill();
146
+ return;
147
+ }
148
+ // Never started: just leave the queue. No exit is emitted — the caller
149
+ // killed it and is already tearing the session down, exactly as for a
150
+ // session closed before its process printed anything.
151
+ if (queuedEntry) {
152
+ const i = this.queue.indexOf(queuedEntry);
153
+ if (i >= 0)
154
+ this.queue.splice(i, 1);
155
+ queuedEntry = null;
156
+ this.renumber();
157
+ }
158
+ },
159
+ onData: (cb) => {
160
+ dataListeners.push(cb);
161
+ if (unheard.length) {
162
+ const pending = unheard;
163
+ unheard = [];
164
+ for (const d of pending)
165
+ cb(d);
166
+ }
167
+ },
168
+ onExit: (cb) => { exitListeners.push(cb); },
169
+ };
170
+ }
171
+ /** Start queued launches while slots are free. */
172
+ drain() {
173
+ let moved = false;
174
+ while (this.starting < this.maxStarting && this.queue.length > 0) {
175
+ const next = this.queue.shift();
176
+ moved = true;
177
+ next.start();
178
+ }
179
+ if (moved)
180
+ this.renumber();
181
+ }
182
+ /** Tell every queued launch how many are ahead of it now. */
183
+ renumber() {
184
+ this.queue.forEach((entry, i) => entry.notify(i + this.starting));
185
+ }
186
+ }
187
+ exports.LaunchGate = LaunchGate;
@@ -0,0 +1,39 @@
1
+ export declare const MCP_PACKAGE = "@wenrwa/marketplace-mcp";
2
+ /** Bump on each CLI release that should move to a newer MCP server. */
3
+ export declare const MCP_PINNED_VERSION = "0.9.0";
4
+ /** After a failed install, wait this long before trying again. */
5
+ export declare const INSTALL_RETRY_MS: number;
6
+ export interface McpInstallDeps {
7
+ /** Run npm. Rejects (or returns non-zero) on failure. */
8
+ runNpm: (args: string[]) => Promise<{
9
+ code: number;
10
+ }>;
11
+ exists: (p: string) => boolean;
12
+ readFile: (p: string) => string;
13
+ writeFile: (p: string, content: string) => void;
14
+ mkdirp: (dir: string) => void;
15
+ now: () => number;
16
+ /** Absolute node binary that runs the server (the bridge's own). */
17
+ nodePath: string;
18
+ }
19
+ export interface McpInstallOpts {
20
+ /** e.g. ~/.wen-market/mcp */
21
+ baseDir: string;
22
+ version?: string;
23
+ }
24
+ export type McpInstallResult = {
25
+ ok: true;
26
+ action: 'present' | 'installed' | 'stale-copy';
27
+ command: string;
28
+ args: string[];
29
+ entry: string;
30
+ version: string;
31
+ error?: string;
32
+ } | {
33
+ ok: false;
34
+ action: 'failed' | 'skipped-recent-failure';
35
+ error: string;
36
+ };
37
+ /** Where the installed server's entry point lives under `baseDir`. */
38
+ export declare function mcpEntryPath(baseDir: string): string;
39
+ export declare function ensureMcpInstalled(opts: McpInstallOpts, deps: McpInstallDeps): Promise<McpInstallResult>;
@@ -0,0 +1,128 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.INSTALL_RETRY_MS = exports.MCP_PINNED_VERSION = exports.MCP_PACKAGE = void 0;
37
+ exports.mcpEntryPath = mcpEntryPath;
38
+ exports.ensureMcpInstalled = ensureMcpInstalled;
39
+ /**
40
+ * A pinned, installed copy of @wenrwa/marketplace-mcp for the LOCAL lane.
41
+ *
42
+ * Registering the MCP server as `npx @wenrwa/marketplace-mcp` made every
43
+ * `claude` launch run `npm exec` — a registry round-trip plus package
44
+ * resolution per agent. Seven agents opened together meant seven of those at
45
+ * once (load average 82 on 2026-10-04, panes blank for 1–2 min).
46
+ *
47
+ * Instead the bridge installs the server ONCE under ~/.wen-market/mcp and the
48
+ * registration points at it with an absolute command (`<node> <entry>`), so a
49
+ * launch just starts node on a file already on disk.
50
+ *
51
+ * - Version: MCP_PINNED_VERSION — what this CLI release was built against,
52
+ * bumped with each release (the hosted image pins the same in its
53
+ * Dockerfile).
54
+ * - Already installed at that version → nothing runs.
55
+ * - Install fails (offline, registry down) → an older installed copy is still
56
+ * used if one exists; otherwise the caller falls back to npx. A failure is
57
+ * not retried for INSTALL_RETRY_MS, so an offline laptop does not pay an
58
+ * npm timeout on every bridge start.
59
+ *
60
+ * The hosted container never comes through here: its image installs the
61
+ * server globally and entrypoint.sh registers it.
62
+ */
63
+ const path = __importStar(require("path"));
64
+ exports.MCP_PACKAGE = '@wenrwa/marketplace-mcp';
65
+ /** Bump on each CLI release that should move to a newer MCP server. */
66
+ exports.MCP_PINNED_VERSION = '0.9.0';
67
+ /** After a failed install, wait this long before trying again. */
68
+ exports.INSTALL_RETRY_MS = 6 * 60 * 60 * 1000;
69
+ /** Where the installed server's entry point lives under `baseDir`. */
70
+ function mcpEntryPath(baseDir) {
71
+ return path.join(baseDir, 'node_modules', '@wenrwa', 'marketplace-mcp', 'dist', 'index.js');
72
+ }
73
+ function installedVersion(baseDir, deps) {
74
+ const pkg = path.join(baseDir, 'node_modules', '@wenrwa', 'marketplace-mcp', 'package.json');
75
+ if (!deps.exists(pkg) || !deps.exists(mcpEntryPath(baseDir)))
76
+ return null;
77
+ try {
78
+ const v = JSON.parse(deps.readFile(pkg)).version;
79
+ return typeof v === 'string' ? v : null;
80
+ }
81
+ catch {
82
+ return null;
83
+ }
84
+ }
85
+ async function ensureMcpInstalled(opts, deps) {
86
+ const version = opts.version ?? exports.MCP_PINNED_VERSION;
87
+ const entry = mcpEntryPath(opts.baseDir);
88
+ const ok = (action, v, error) => ({
89
+ ok: true, action, command: deps.nodePath, args: [entry], entry, version: v, ...(error ? { error } : {}),
90
+ });
91
+ const have = installedVersion(opts.baseDir, deps);
92
+ if (have === version)
93
+ return ok('present', have);
94
+ const stampPath = path.join(opts.baseDir, '.last-failed-install');
95
+ const fallback = (action, error) => have ? ok('stale-copy', have, error) : { ok: false, action, error };
96
+ try {
97
+ const last = deps.exists(stampPath) ? Number(deps.readFile(stampPath).trim()) : NaN;
98
+ if (Number.isFinite(last) && deps.now() - last < exports.INSTALL_RETRY_MS) {
99
+ return fallback('skipped-recent-failure', 'a recent install attempt failed; retrying later');
100
+ }
101
+ }
102
+ catch { /* unreadable stamp → just try */ }
103
+ let error;
104
+ try {
105
+ deps.mkdirp(opts.baseDir);
106
+ const pkgJson = path.join(opts.baseDir, 'package.json');
107
+ // Own package.json so npm never walks up into some other project.
108
+ if (!deps.exists(pkgJson))
109
+ deps.writeFile(pkgJson, JSON.stringify({ name: 'wen-market-mcp', private: true }, null, 2) + '\n');
110
+ const res = await deps.runNpm([
111
+ 'install', '--prefix', opts.baseDir,
112
+ '--no-audit', '--no-fund', '--omit=dev', '--no-package-lock',
113
+ `${exports.MCP_PACKAGE}@${version}`,
114
+ ]);
115
+ const now = installedVersion(opts.baseDir, deps);
116
+ if (res.code === 0 && now === version)
117
+ return ok('installed', now);
118
+ error = `npm install exited ${res.code}${now ? ` (found ${now})` : ''}`;
119
+ }
120
+ catch (err) {
121
+ error = err instanceof Error ? err.message : String(err);
122
+ }
123
+ try {
124
+ deps.writeFile(stampPath, String(deps.now()));
125
+ }
126
+ catch { /* best effort */ }
127
+ return fallback('failed', error);
128
+ }
@@ -17,12 +17,24 @@ export interface McpRegisterOpts {
17
17
  /** Set when a fresh token was just provided: an existing registration
18
18
  * carries the OLD token env and must be replaced, not skipped. */
19
19
  forceUpdate?: boolean;
20
+ /** The installed server to register (mcpInstall.ts): an absolute command,
21
+ * e.g. `<node> ~/.wen-market/mcp/.../dist/index.js`. Absent → the legacy
22
+ * `npx @wenrwa/marketplace-mcp` (install failed / offline). */
23
+ server?: {
24
+ command: string;
25
+ args: string[];
26
+ };
20
27
  }
21
28
  export interface McpRegisterResult {
22
- action: 'registered' | 're-registered' | 'already-registered' | 'claude-not-found';
29
+ /** `migrated`: an existing registration used another command (the old
30
+ * per-launch npx form) and was replaced with `server`. */
31
+ action: 'registered' | 're-registered' | 'migrated' | 'already-registered' | 'claude-not-found';
23
32
  command?: string;
24
33
  }
25
34
  export declare const MCP_SERVER_NAME = "wenrwa-marketplace";
26
35
  /** Connect Tokens must never appear in output — redact before printing. */
27
36
  export declare function redactToken(text: string): string;
28
37
  export declare function registerMcpServer(opts: McpRegisterOpts): Promise<McpRegisterResult>;
38
+ /** The `claude mcp list` line for our server, e.g.
39
+ * `wenrwa-marketplace: npx @wenrwa/marketplace-mcp - ✓ Connected`. */
40
+ export declare function registrationLine(listOutput: string): string | null;
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.MCP_SERVER_NAME = void 0;
4
4
  exports.redactToken = redactToken;
5
5
  exports.registerMcpServer = registerMcpServer;
6
+ exports.registrationLine = registrationLine;
6
7
  exports.MCP_SERVER_NAME = 'wenrwa-marketplace';
7
8
  /** Connect Tokens must never appear in output — redact before printing. */
8
9
  function redactToken(text) {
@@ -18,20 +19,39 @@ async function registerMcpServer(opts) {
18
19
  return { action: 'claude-not-found' };
19
20
  }
20
21
  const exists = listOutput.includes(exports.MCP_SERVER_NAME);
21
- if (exists && !opts.forceUpdate) {
22
+ // With an installed server to point at, a registration whose line does not
23
+ // run exactly that command (the old `npx @wenrwa/marketplace-mcp`, or a
24
+ // node binary that has since moved) is replaced. Without one (fallback), any existing
25
+ // registration is kept — never downgrade a working absolute one to npx.
26
+ const line = registrationLine(listOutput);
27
+ const wanted = opts.server ? [opts.server.command, ...opts.server.args].join(' ') : null;
28
+ const stale = wanted !== null && exists && !(line ?? '').includes(wanted);
29
+ if (exists && !opts.forceUpdate && !stale) {
22
30
  return { action: 'already-registered' };
23
31
  }
24
- if (exists && opts.forceUpdate) {
32
+ if (exists) {
25
33
  await opts.run('claude', ['mcp', 'remove', '--scope', 'user', exports.MCP_SERVER_NAME]).catch(() => { });
26
34
  // Also clear a stale default(local)-scope entry from older bridge versions.
27
35
  await opts.run('claude', ['mcp', 'remove', exports.MCP_SERVER_NAME]).catch(() => { });
28
36
  }
37
+ const server = opts.server ? [opts.server.command, ...opts.server.args] : ['npx', '@wenrwa/marketplace-mcp'];
29
38
  const args = [
30
39
  'mcp', 'add', '--scope', 'user', exports.MCP_SERVER_NAME,
31
40
  '-e', `WENRWA_CONNECT_TOKEN=${opts.connectToken}`,
32
41
  '-e', `MARKETPLACE_API_URL=${opts.apiUrl}`,
33
- '--', 'npx', '@wenrwa/marketplace-mcp',
42
+ '--', ...server,
34
43
  ];
35
44
  await opts.run('claude', args);
36
- return { action: exists ? 're-registered' : 'registered', command: `claude ${args.join(' ')}` };
45
+ const action = !exists ? 'registered' : opts.forceUpdate ? 're-registered' : 'migrated';
46
+ return { action, command: `claude ${args.join(' ')}` };
47
+ }
48
+ /** The `claude mcp list` line for our server, e.g.
49
+ * `wenrwa-marketplace: npx @wenrwa/marketplace-mcp - ✓ Connected`. */
50
+ function registrationLine(listOutput) {
51
+ for (const raw of listOutput.split(/\r?\n/)) {
52
+ const line = raw.trim();
53
+ if (line.startsWith(`${exports.MCP_SERVER_NAME}:`))
54
+ return line;
55
+ }
56
+ return null;
37
57
  }
@@ -1,5 +1,6 @@
1
1
  import { TermSnapshot } from './termSnapshot';
2
2
  import { LaunchSupervisorOptions } from './launchSupervisor';
3
+ import { LaunchGateOptions } from './launchGate';
3
4
  export interface PtyLike {
4
5
  write(data: string): void;
5
6
  resize(cols: number, rows: number): void;
@@ -88,6 +89,15 @@ export interface SessionManagerOptions {
88
89
  * self-healing retry for a session-id/resume mismatch, else a plain
89
90
  * "Claude couldn't start" line. Injectable clock/settle for tests. */
90
91
  launchSupervisor?: LaunchSupervisorOptions;
92
+ /** Staggers agent start-ups (launchGate.ts): at most 2 starting at once,
93
+ * the rest queue with a "Starting… (N ahead)" line. Options for the
94
+ * default gate, or `false` to spawn every launch immediately. */
95
+ launchGate?: LaunchGateOptions | false;
96
+ /** Hosted container lane: every PTY is a $SHELL that autolaunch.sh turns
97
+ * into `claude`, so every open is an agent launch and goes through the
98
+ * gate. Off (laptop) → only an explicit `command: 'claude'` is gated and
99
+ * plain shells are never delayed. */
100
+ everyOpenIsAgentLaunch?: boolean;
91
101
  }
92
102
  /**
93
103
  * lws-07 (ADR-0033 port): validates a client-supplied size pair for the
@@ -112,6 +122,8 @@ export declare class SessionManager {
112
122
  private cancelTimeout;
113
123
  private snapshotFactory;
114
124
  private launchSupervisor;
125
+ private launchGate;
126
+ private everyOpenIsAgentLaunch;
115
127
  /** twr-16: cumulative count of attach-pipeline wedges (any stage timeout,
116
128
  * or a serialize-wedge whose recovery retry ALSO timed out) since this
117
129
  * SessionManager was created. Surfaced via attachFailures() into the
@@ -22,6 +22,7 @@ exports.validSize = validSize;
22
22
  const crypto_1 = __importDefault(require("crypto"));
23
23
  const termSnapshot_1 = require("./termSnapshot");
24
24
  const launchSupervisor_1 = require("./launchSupervisor");
25
+ const launchGate_1 = require("./launchGate");
25
26
  /** Default settle window (lws-07): after a resize-on-attach, how long we give
26
27
  * the PTY's SIGWINCH-triggered repaint to land in the snapshot before
27
28
  * serializing. Small — this only needs to cover one render tick, not a
@@ -75,6 +76,8 @@ class SessionManager {
75
76
  cancelTimeout;
76
77
  snapshotFactory;
77
78
  launchSupervisor;
79
+ launchGate;
80
+ everyOpenIsAgentLaunch;
78
81
  /** twr-16: cumulative count of attach-pipeline wedges (any stage timeout,
79
82
  * or a serialize-wedge whose recovery retry ALSO timed out) since this
80
83
  * SessionManager was created. Surfaced via attachFailures() into the
@@ -92,6 +95,8 @@ class SessionManager {
92
95
  this.cancelTimeout = opts.cancelTimeout ?? ((h) => clearTimeout(h));
93
96
  this.snapshotFactory = opts.snapshotFactory ?? termSnapshot_1.createTermSnapshot;
94
97
  this.launchSupervisor = opts.launchSupervisor ?? {};
98
+ this.launchGate = opts.launchGate === false ? null : new launchGate_1.LaunchGate(opts.launchGate ?? {});
99
+ this.everyOpenIsAgentLaunch = opts.everyOpenIsAgentLaunch ?? false;
95
100
  }
96
101
  /** twr-16: see attachFailureCount. */
97
102
  attachFailures() {
@@ -129,9 +134,16 @@ class SessionManager {
129
134
  // A `claude` launch (agent panes, crew members) is supervised through its
130
135
  // startup window so a fast failure heals or explains itself; a shell is
131
136
  // spawned exactly as before.
132
- const pty = opts.command === 'claude'
133
- ? (0, launchSupervisor_1.superviseLaunch)(this.factory, opts, this.launchSupervisor)
134
- : this.factory.spawn(opts);
137
+ // Agent launches are also staggered (launchGate.ts) so a burst of opens
138
+ // doesn't boot every `claude` in the same second; a shell never waits.
139
+ const isClaude = opts.command === 'claude';
140
+ const spawnOne = (size) => isClaude
141
+ ? (0, launchSupervisor_1.superviseLaunch)(this.factory, { ...opts, ...size }, this.launchSupervisor)
142
+ : this.factory.spawn({ ...opts, ...size });
143
+ const gated = this.launchGate && (isClaude || this.everyOpenIsAgentLaunch);
144
+ const pty = gated
145
+ ? this.launchGate.launch(spawnOne, { cols: opts.cols, rows: opts.rows })
146
+ : spawnOne({ cols: opts.cols, rows: opts.rows });
135
147
  const snapshot = this.snapshotFactory(opts.cols, opts.rows, { scrollback: this.snapshotScrollback });
136
148
  const session = {
137
149
  pty,
@@ -69,6 +69,7 @@ const loginStatus_1 = require("../bridge/loginStatus");
69
69
  const realPty_1 = require("../bridge/realPty");
70
70
  const config_1 = require("../bridge/config");
71
71
  const mcpRegister_1 = require("../bridge/mcpRegister");
72
+ const mcpInstall_1 = require("../bridge/mcpInstall");
72
73
  const autostart_1 = require("../bridge/autostart");
73
74
  const repoCheckout_1 = require("../bridge/repoCheckout");
74
75
  const bundleInstaller_1 = require("../bridge/bundleInstaller");
@@ -480,7 +481,9 @@ exports.bridgeCommand = new commander_1.Command('bridge')
480
481
  return;
481
482
  }
482
483
  if (relayMode.mode === 'relay') {
483
- const sessions = new sessionManager_1.SessionManager((0, realPty_1.realPtyFactory)());
484
+ // Every container PTY is a $SHELL that autolaunch.sh turns into
485
+ // `claude`, so every open is an agent launch and is staggered.
486
+ const sessions = new sessionManager_1.SessionManager((0, realPty_1.realPtyFactory)(), { everyOpenIsAgentLaunch: true });
484
487
  // pucc-03 (ADR-0031 model B): one container, many workspaces. Each
485
488
  // workspace's repo lives under /data/repos/<workspaceId> and is cloned
486
489
  // on demand the first time a terminal for it opens. The entrypoint can't
@@ -966,6 +969,29 @@ exports.bridgeCommand = new commander_1.Command('bridge')
966
969
  console.log(chalk_1.default.green(`✓ Connected this machine as device "${deviceResult.deviceName}" (~/.wen-market/bridge-config.json)`));
967
970
  }
968
971
  }
972
+ // 1.9. A pinned, installed MCP server (mcpInstall.ts) so each `claude`
973
+ // launch starts node on a file on disk instead of running `npx` — seven
974
+ // agents opened together used to mean seven concurrent `npm exec`s.
975
+ const mcpDir = path.join(os.homedir(), '.wen-market', 'mcp');
976
+ const mcpInstall = await (0, mcpInstall_1.ensureMcpInstalled)({ baseDir: mcpDir }, {
977
+ runNpm: (args) => new Promise((resolve) => {
978
+ (0, child_process_1.execFile)(process.platform === 'win32' ? 'npm.cmd' : 'npm', args, { timeout: 120_000 }, (err) => {
979
+ resolve({ code: err ? (typeof err.code === 'number' ? err.code : 1) : 0 });
980
+ });
981
+ }),
982
+ exists: (p) => fs.existsSync(p),
983
+ readFile: (p) => fs.readFileSync(p, 'utf-8'),
984
+ writeFile: (p, c) => fs.writeFileSync(p, c),
985
+ mkdirp: (d) => fs.mkdirSync(d, { recursive: true }),
986
+ now: Date.now,
987
+ nodePath: process.execPath,
988
+ });
989
+ if (mcpInstall.ok && mcpInstall.action === 'installed')
990
+ console.log(chalk_1.default.green(`✓ Installed MCP server ${mcpInstall_1.MCP_PINNED_VERSION} (${mcpDir})`));
991
+ else if (mcpInstall.ok && mcpInstall.action === 'stale-copy')
992
+ console.log(chalk_1.default.yellow(`! Could not install MCP server ${mcpInstall_1.MCP_PINNED_VERSION} (${mcpInstall.error}) — using installed ${mcpInstall.version}`));
993
+ else if (!mcpInstall.ok)
994
+ console.log(chalk_1.default.yellow(`! Could not install MCP server (${mcpInstall.error}) — falling back to npx @wenrwa/marketplace-mcp (slower agent start-up)`));
969
995
  // 2. Idempotent MCP registration into the user's Claude Code — loud.
970
996
  const mcp = await (0, mcpRegister_1.registerMcpServer)({
971
997
  connectToken: config.connectToken,
@@ -973,9 +999,10 @@ exports.bridgeCommand = new commander_1.Command('bridge')
973
999
  run: execRunner,
974
1000
  // A token passed on the command line is fresh — replace any stale registration.
975
1001
  forceUpdate: Boolean(opts.token),
1002
+ ...(mcpInstall.ok ? { server: { command: mcpInstall.command, args: mcpInstall.args } } : {}),
976
1003
  });
977
- if (mcp.action === 'registered' || mcp.action === 're-registered')
978
- console.log(chalk_1.default.green(`✓ ${mcp.action === 're-registered' ? 'Re-registered' : 'Registered'} MCP server: ${(0, mcpRegister_1.redactToken)(mcp.command ?? '')}`));
1004
+ if (mcp.action === 'registered' || mcp.action === 're-registered' || mcp.action === 'migrated')
1005
+ console.log(chalk_1.default.green(`✓ ${mcp.action === 're-registered' ? 'Re-registered' : mcp.action === 'migrated' ? 'Migrated (npx → installed)' : 'Registered'} MCP server: ${(0, mcpRegister_1.redactToken)(mcp.command ?? '')}`));
979
1006
  else if (mcp.action === 'already-registered')
980
1007
  console.log(chalk_1.default.dim('• wenrwa-marketplace MCP server already registered — skipped'));
981
1008
  else
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env node
2
+ /*
3
+ * HiveOp SessionStart hook — LOCAL bridge lane.
4
+ *
5
+ * The local bridge writes HiveOp's managed context (work-queue pointers, the
6
+ * Browser note, match notes) to `<project>/.hiveop/context.md` and never
7
+ * edits the user's CLAUDE.md. Nothing made Claude READ that file (2026-10-04:
8
+ * the "call show_in_browser after a deploy" note went unread). A SessionStart
9
+ * hook's stdout is added to the session's context, so print the file here.
10
+ * Silent and non-blocking when the file is absent; never fails the session.
11
+ */
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+
15
+ let input = '';
16
+ process.stdin.on('data', (d) => { input += d; });
17
+ process.stdin.on('end', () => {
18
+ try {
19
+ let cwd = process.cwd();
20
+ try { const j = JSON.parse(input || '{}'); if (typeof j.cwd === 'string' && j.cwd) cwd = j.cwd; } catch {}
21
+ const file = path.join(cwd, '.hiveop', 'context.md');
22
+ if (fs.existsSync(file)) {
23
+ const text = fs.readFileSync(file, 'utf8').slice(0, 20000);
24
+ if (text.trim()) process.stdout.write(`HiveOp workspace context (from .hiveop/context.md):\n\n${text}\n`);
25
+ }
26
+ } catch {}
27
+ process.exit(0);
28
+ });
29
+ process.stdin.on('error', () => process.exit(0));
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@wenrwa/marketplace-cli",
3
- "version": "0.7.5",
4
- "description": "CLI tool for the Wenrwa Agent Marketplace \u2014 monitor bounties, agents, workspaces, and treasury from your terminal",
3
+ "version": "0.7.7",
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",
7
7
  "homepage": "https://marketplace.wenrwa.com",