ftown-bridge 0.19.44 → 0.19.49

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.
Files changed (57) hide show
  1. package/claude-mod/.claude-plugin/plugin.json +6 -0
  2. package/claude-mod/hooks/hooks.json +3 -0
  3. package/claude-mod/hooks/register.js +137 -0
  4. package/dist/agent-commands.js +17 -2
  5. package/dist/agent-commands.js.map +1 -1
  6. package/dist/attention-store.d.ts +21 -1
  7. package/dist/attention-store.js +131 -3
  8. package/dist/attention-store.js.map +1 -1
  9. package/dist/claude-mod-installer.d.ts +9 -0
  10. package/dist/claude-mod-installer.js +55 -0
  11. package/dist/claude-mod-installer.js.map +1 -0
  12. package/dist/claude-runner.d.ts +8 -0
  13. package/dist/claude-runner.js +11 -4
  14. package/dist/claude-runner.js.map +1 -1
  15. package/dist/ftown-cli.d.ts +1 -1
  16. package/dist/ftown-cli.js +11 -3
  17. package/dist/ftown-cli.js.map +1 -1
  18. package/dist/harness-registry.js +4 -0
  19. package/dist/harness-registry.js.map +1 -1
  20. package/dist/index.d.ts +1 -1
  21. package/dist/index.js +719 -671
  22. package/dist/index.js.map +1 -1
  23. package/dist/install-ftown-command-cli.d.ts +1 -1
  24. package/dist/install-ftown-command-cli.js +35 -44
  25. package/dist/install-ftown-command-cli.js.map +1 -1
  26. package/dist/installation-manifest.d.ts +25 -0
  27. package/dist/installation-manifest.js +37 -0
  28. package/dist/installation-manifest.js.map +1 -0
  29. package/dist/local-api-server.d.ts +8 -0
  30. package/dist/local-api-server.js +73 -1
  31. package/dist/local-api-server.js.map +1 -1
  32. package/dist/mail-delivery.d.ts +30 -0
  33. package/dist/mail-delivery.js +202 -8
  34. package/dist/mail-delivery.js.map +1 -1
  35. package/dist/mcp/fleet.d.ts +2 -2
  36. package/dist/native-mail.d.ts +31 -0
  37. package/dist/native-mail.js +142 -0
  38. package/dist/native-mail.js.map +1 -0
  39. package/dist/production-dependencies.d.ts +13 -0
  40. package/dist/production-dependencies.js +175 -0
  41. package/dist/production-dependencies.js.map +1 -0
  42. package/dist/push-notifier.d.ts +40 -0
  43. package/dist/push-notifier.js +126 -0
  44. package/dist/push-notifier.js.map +1 -0
  45. package/dist/sessions-commands.js +278 -310
  46. package/dist/sessions-commands.js.map +1 -1
  47. package/dist/setup-state.d.ts +1 -2
  48. package/dist/setup-state.js +31 -11
  49. package/dist/setup-state.js.map +1 -1
  50. package/dist/setup.js +3 -1
  51. package/dist/setup.js.map +1 -1
  52. package/dist/start-commands.d.ts +10 -0
  53. package/dist/start-commands.js +15 -0
  54. package/dist/start-commands.js.map +1 -0
  55. package/opencode-plugin/ftown.js +142 -129
  56. package/package.json +3 -2
  57. package/skills/ftown/SKILL.md +7 -2
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { Command as Commander } from 'commander';
2
+ import { Command as Commander, CommanderError } from 'commander';
3
3
  import { v4 as uuidv4 } from 'uuid';
4
4
  import { resolve, dirname, join } from 'node:path';
5
5
  import { hostname as osHostname, networkInterfaces, homedir } from 'node:os';
@@ -16,6 +16,7 @@ import { ProcessRunner } from './claude-runner.js';
16
16
  import { SessionStore } from './session-store.js';
17
17
  import { MailStore } from './mail-store.js';
18
18
  import { AttentionStore } from './attention-store.js';
19
+ import { BridgePushNotifier } from './push-notifier.js';
19
20
  import { LocalApiServer } from './local-api-server.js';
20
21
  import { BrowserAccess } from './browser-access.js';
21
22
  import { registerLocalBrowserCommands } from './local-pair-cli.js';
@@ -42,6 +43,7 @@ import { DEFAULT_SOLO_PORT, SOLO_USER_ID } from './solo/contract.js';
42
43
  import { ensureHubBinary, startHub, stopHub, writeHubConfig } from './solo/hub-manager.js';
43
44
  import { ensurePanelBundle, findPanelServerDir, normalizePanelVersion, startPanel, stopPanel, } from './solo/panel-manager.js';
44
45
  import { createSoloServer } from './solo/solo-server.js';
46
+ import { executeCli, isCliEntry } from './cli-entry.js';
45
47
  /** Bridge package version — doubles as the default panel bundle version. */
46
48
  const BRIDGE_VERSION = JSON.parse(readFileSync(resolve(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf8')).version;
47
49
  /** LAN IPv4 addresses for the solo banner (loopback excluded). */
@@ -65,722 +67,768 @@ function isLoopbackOnly(ips) {
65
67
  const __filename = fileURLToPath(import.meta.url);
66
68
  const __dirname = dirname(__filename);
67
69
  const DEFAULT_API_URL = 'https://ftown.ia.br';
68
- const program = new Commander();
69
- program
70
- .name('ftown-bridge')
71
- .description('ftown orchestrator bridge for Centrifugo')
72
- .option('--local', 'Local browser control only: no cloud account or connection')
73
- .option('--solo', 'Single-port LAN deployment: no account service, managed hub + panel children, key-based auth')
74
- .option('--port <port>', 'Public port for --solo front (default: see DEFAULT_SOLO_PORT)')
75
- .option('--rotate-key', 'With --solo: regenerate the access key, print the new banner, exit (offline)')
76
- .option('--token <jwt>', 'Bridge bootstrap token from the ftown dashboard (short-lived; used once to onboard, then a rotating refresh token is stored). Optional: with no token and no stored refresh token, the bridge runs interactive device pairing instead.')
77
- .option('--api-url <url>', 'ftown UI API URL', DEFAULT_API_URL)
78
- .option('--data-dir <path>', 'Directory for session data (default: ~/.ftown/data)')
79
- .option('--bridge-id <id>', 'Bridge instance ID (default: persisted per data dir)')
80
- .action(async (opts) => {
81
- if (opts.local && opts.solo)
82
- program.error('--local and --solo cannot be combined');
83
- const apiUrl = new URL(opts.apiUrl);
84
- const isLocalHost = apiUrl.hostname === 'localhost' ||
85
- apiUrl.hostname === '127.0.0.1' ||
86
- apiUrl.hostname === '::1' ||
87
- apiUrl.hostname.endsWith('.localhost');
88
- if (apiUrl.protocol !== 'https:' && !isLocalHost && process.env.FTOWN_ALLOW_INSECURE_API !== '1') {
89
- program.error(`--api-url must use https:// for non-local hosts (${opts.apiUrl}). ` +
90
- 'Bridge tokens would otherwise cross the network unencrypted. ' +
91
- 'Set FTOWN_ALLOW_INSECURE_API=1 to override for trusted LAN setups.');
92
- }
93
- // Explicit setup must finish before any migration, identity, network or auth side effect.
94
- assertSetupReady(BRIDGE_VERSION);
95
- // Read-only default resolution; explicit setup owns legacy data migration.
96
- const defaultDataDir = resolveDefaultDataDir();
97
- const dataDir = opts.dataDir ? resolve(opts.dataDir) : defaultDataDir;
98
- // Instance-scoped ".ftown home" for pointer and registry files. The DEFAULT
99
- // data dir resolves to $HOME/.ftown (unchanged — the harness CLIs hardcode
100
- // that path). A non-default --data-dir (Solo test, Docker, a second bridge)
101
- // gets its own home so it never touches the primary bridge's bridge.json,
102
- // or session-registry.json. Configure the store
103
- // singletons ONCE here, before any of them is read/written.
104
- const ftownHome = resolveFtownHome(dataDir, defaultDataDir);
105
- configureSessionRegistryHome(ftownHome);
106
- // Bridge identity sticks to the data dir so a plain restart auto-resumes:
107
- // same id → same dashboard entry, sessions reattach without any flags.
108
- const bridgeIdPath = join(dataDir, 'bridge-id');
109
- let persistedBridgeId;
110
- try {
111
- persistedBridgeId = readFileSync(bridgeIdPath, 'utf8').trim() || undefined;
112
- }
113
- catch {
114
- persistedBridgeId = undefined;
115
- }
116
- const bridgeId = opts.bridgeId ?? persistedBridgeId ?? uuidv4();
117
- try {
118
- mkdirSync(dataDir, { recursive: true });
119
- writeFileSync(bridgeIdPath, `${bridgeId}\n`, { mode: 0o600 });
120
- }
121
- catch (err) {
122
- console.error('[Bridge] Failed to persist bridge id:', err instanceof Error ? err.message : String(err));
123
- }
124
- // L4: --rotate-key short-circuits BEFORE any listener binds (exit 0,
125
- // nothing started). Without --solo it is an argument error.
126
- if (opts.rotateKey && !opts.solo) {
127
- program.error('--rotate-key requires --solo');
128
- }
129
- if (opts.solo && opts.rotateKey) {
130
- const raw = generateAccessKey().raw;
131
- mkdirSync(join(dataDir, 'solo'), { recursive: true, mode: 0o700 });
132
- writeFileSync(join(dataDir, 'solo', 'access-key-hash'), `${sha256Hex(raw)}\n`, { mode: 0o600 });
133
- const port = opts.port ? Number(opts.port) : DEFAULT_SOLO_PORT;
134
- console.log('========================================');
135
- console.log(' ftown SOLO — access key rotated');
136
- for (const ip of networkInterfacesForBanner()) {
137
- console.log(` Open: http://${ip}:${port}/#k=${raw}`);
70
+ export async function runBridge(argv, commandName = 'ftown-bridge') {
71
+ const program = new Commander();
72
+ program.exitOverride();
73
+ program
74
+ .name(commandName)
75
+ .description('ftown orchestrator bridge for Centrifugo')
76
+ .option('--local', 'Local browser control only: no cloud account or connection')
77
+ .option('--solo', 'Single-port LAN deployment: no account service, managed hub + panel children, key-based auth')
78
+ .option('--port <port>', 'Public port for --solo front (default: see DEFAULT_SOLO_PORT)')
79
+ .option('--rotate-key', 'With --solo: regenerate the access key, print the new banner, exit (offline)')
80
+ .option('--token <jwt>', 'Bridge bootstrap token from the ftown dashboard (short-lived; used once to onboard, then a rotating refresh token is stored). Optional: with no token and no stored refresh token, the bridge runs interactive device pairing instead.')
81
+ .option('--api-url <url>', 'ftown UI API URL', DEFAULT_API_URL)
82
+ .option('--data-dir <path>', 'Directory for session data (default: ~/.ftown/data)')
83
+ .option('--bridge-id <id>', 'Bridge instance ID (default: persisted per data dir)')
84
+ .action(async (opts) => {
85
+ if (opts.local && opts.solo)
86
+ program.error('--local and --solo cannot be combined');
87
+ const apiUrl = new URL(opts.apiUrl);
88
+ const isLocalHost = apiUrl.hostname === 'localhost' ||
89
+ apiUrl.hostname === '127.0.0.1' ||
90
+ apiUrl.hostname === '::1' ||
91
+ apiUrl.hostname.endsWith('.localhost');
92
+ if (apiUrl.protocol !== 'https:' && !isLocalHost && process.env.FTOWN_ALLOW_INSECURE_API !== '1') {
93
+ program.error(`--api-url must use https:// for non-local hosts (${opts.apiUrl}). ` +
94
+ 'Bridge tokens would otherwise cross the network unencrypted. ' +
95
+ 'Set FTOWN_ALLOW_INSECURE_API=1 to override for trusted LAN setups.');
138
96
  }
139
- console.log('========================================');
140
- process.exit(0);
141
- }
142
- // The local API server binds before the token fetch: its ephemeral port and
143
- // the per-process loopback nonce ride the auth request so the UI can embed
144
- // them in the Centrifugo connection JWT `info` claim (presence advert, L2).
145
- // Binding needs no post-auth state — dependencies/routes are wired later.
146
- const localApiServer = new LocalApiServer();
147
- const apiToken = randomBytes(32).toString('hex');
148
- localApiServer.setAuthToken(apiToken);
149
- // Reuse the local port so remembered browsers survive restarts. If another
150
- // process took it, the server chooses an available port and pair prints it.
151
- const localPortPath = join(dataDir, 'local-api-port');
152
- let preferredLocalPort = 0;
153
- try {
154
- const saved = Number(readFileSync(localPortPath, 'utf8').trim());
155
- if (Number.isInteger(saved) && saved > 0 && saved <= 65535)
156
- preferredLocalPort = saved;
157
- }
158
- catch { /* first start */ }
159
- const hookPort = await localApiServer.start(preferredLocalPort);
160
- writeFileSync(localPortPath, `${hookPort}\n`, { mode: 0o600 });
161
- console.log(`[Bridge] Local API server started on port ${hookPort}`);
162
- const localNonce = randomBytes(16).toString('hex');
163
- // ----- SOLO MODE (contract: bridge/src/solo/contract.ts, lifecycle L1-L4) -----
164
- let solo;
165
- if (opts.solo) {
166
- const hubSecretPath = join(dataDir, 'solo', 'hub-secret');
167
- let hubSecret;
97
+ // Explicit setup must finish before any migration, identity, network or auth side effect.
98
+ assertSetupReady(BRIDGE_VERSION);
99
+ // Read-only default resolution; explicit setup owns legacy data migration.
100
+ const defaultDataDir = resolveDefaultDataDir();
101
+ const dataDir = opts.dataDir ? resolve(opts.dataDir) : defaultDataDir;
102
+ // Instance-scoped ".ftown home" for pointer and registry files. The DEFAULT
103
+ // data dir resolves to $HOME/.ftown (unchanged — the harness CLIs hardcode
104
+ // that path). A non-default --data-dir (Solo test, Docker, a second bridge)
105
+ // gets its own home so it never touches the primary bridge's bridge.json,
106
+ // or session-registry.json. Configure the store
107
+ // singletons ONCE here, before any of them is read/written.
108
+ const ftownHome = resolveFtownHome(dataDir, defaultDataDir);
109
+ configureSessionRegistryHome(ftownHome);
110
+ // Bridge identity sticks to the data dir so a plain restart auto-resumes:
111
+ // same id → same dashboard entry, sessions reattach without any flags.
112
+ const bridgeIdPath = join(dataDir, 'bridge-id');
113
+ let persistedBridgeId;
168
114
  try {
169
- hubSecret = readFileSync(hubSecretPath, 'utf8').trim();
115
+ persistedBridgeId = readFileSync(bridgeIdPath, 'utf8').trim() || undefined;
170
116
  }
171
117
  catch {
172
- hubSecret = randomBytes(32).toString('hex');
173
- mkdirSync(dirname(hubSecretPath), { recursive: true, mode: 0o700 });
174
- writeFileSync(hubSecretPath, `${hubSecret}\n`, { mode: 0o600 });
118
+ persistedBridgeId = undefined;
175
119
  }
176
- // Key lifecycle (S1/S11): raw key exists only in memory at generation and
177
- // in the one-time banner. Persisted state is the hash alone. Rotation is
178
- // handled by the L4 short-circuit above; here a missing hash means FIRST
179
- // boot — generate fresh so the banner can print the full link.
180
- const keyHashPath = join(dataDir, 'solo', 'access-key-hash');
181
- let accessKeyRaw;
182
- let accessKeyHash;
120
+ const bridgeId = opts.bridgeId ?? persistedBridgeId ?? uuidv4();
183
121
  try {
184
- accessKeyHash = readFileSync(keyHashPath, 'utf8').trim();
122
+ mkdirSync(dataDir, { recursive: true });
123
+ writeFileSync(bridgeIdPath, `${bridgeId}\n`, { mode: 0o600 });
185
124
  }
186
- catch {
187
- accessKeyRaw = generateAccessKey().raw;
188
- accessKeyHash = sha256Hex(accessKeyRaw);
125
+ catch (err) {
126
+ console.error('[Bridge] Failed to persist bridge id:', err instanceof Error ? err.message : String(err));
189
127
  }
190
- mkdirSync(join(dataDir, 'solo'), { recursive: true, mode: 0o700 });
191
- writeFileSync(keyHashPath, `${accessKeyHash}\n`, { mode: 0o600 });
192
- // Ephemeral private ports for children: probe free ports on loopback so
193
- // collisions are practically impossible (contract "Ports" block).
194
- const probeFreePort = () => new Promise((resolveP, rejectP) => {
195
- const srv = createServer();
196
- srv.listen(0, '127.0.0.1', () => {
197
- const addr = srv.address();
198
- if (!addr || typeof addr === 'string') {
199
- srv.close(() => rejectP(new Error('Failed to allocate child port')));
200
- return;
128
+ // L4: --rotate-key short-circuits BEFORE any listener binds (exit 0,
129
+ // nothing started). Without --solo it is an argument error.
130
+ if (opts.rotateKey && !opts.solo) {
131
+ program.error('--rotate-key requires --solo');
132
+ }
133
+ if (opts.solo && opts.rotateKey) {
134
+ const raw = generateAccessKey().raw;
135
+ mkdirSync(join(dataDir, 'solo'), { recursive: true, mode: 0o700 });
136
+ writeFileSync(join(dataDir, 'solo', 'access-key-hash'), `${sha256Hex(raw)}\n`, { mode: 0o600 });
137
+ const port = opts.port ? Number(opts.port) : DEFAULT_SOLO_PORT;
138
+ console.log('========================================');
139
+ console.log(' ftown SOLO — access key rotated');
140
+ for (const ip of networkInterfacesForBanner()) {
141
+ console.log(` Open: http://${ip}:${port}/#k=${raw}`);
142
+ }
143
+ console.log('========================================');
144
+ process.exit(0);
145
+ }
146
+ // The local API server binds before the token fetch: its ephemeral port and
147
+ // the per-process loopback nonce ride the auth request so the UI can embed
148
+ // them in the Centrifugo connection JWT `info` claim (presence advert, L2).
149
+ // Binding needs no post-auth state — dependencies/routes are wired later.
150
+ const localApiServer = new LocalApiServer();
151
+ const apiToken = randomBytes(32).toString('hex');
152
+ localApiServer.setAuthToken(apiToken);
153
+ // Reuse the local port so remembered browsers survive restarts. If another
154
+ // process took it, the server chooses an available port and pair prints it.
155
+ const localPortPath = join(dataDir, 'local-api-port');
156
+ let preferredLocalPort = 0;
157
+ try {
158
+ const saved = Number(readFileSync(localPortPath, 'utf8').trim());
159
+ if (Number.isInteger(saved) && saved > 0 && saved <= 65535)
160
+ preferredLocalPort = saved;
161
+ }
162
+ catch { /* first start */ }
163
+ const hookPort = await localApiServer.start(preferredLocalPort);
164
+ writeFileSync(localPortPath, `${hookPort}\n`, { mode: 0o600 });
165
+ console.log(`[Bridge] Local API server started on port ${hookPort}`);
166
+ const localNonce = randomBytes(16).toString('hex');
167
+ // ----- SOLO MODE (contract: bridge/src/solo/contract.ts, lifecycle L1-L4) -----
168
+ let solo;
169
+ if (opts.solo) {
170
+ const hubSecretPath = join(dataDir, 'solo', 'hub-secret');
171
+ let hubSecret;
172
+ try {
173
+ hubSecret = readFileSync(hubSecretPath, 'utf8').trim();
174
+ }
175
+ catch {
176
+ hubSecret = randomBytes(32).toString('hex');
177
+ mkdirSync(dirname(hubSecretPath), { recursive: true, mode: 0o700 });
178
+ writeFileSync(hubSecretPath, `${hubSecret}\n`, { mode: 0o600 });
179
+ }
180
+ // Key lifecycle (S1/S11): raw key exists only in memory at generation and
181
+ // in the one-time banner. Persisted state is the hash alone. Rotation is
182
+ // handled by the L4 short-circuit above; here a missing hash means FIRST
183
+ // boot — generate fresh so the banner can print the full link.
184
+ const keyHashPath = join(dataDir, 'solo', 'access-key-hash');
185
+ let accessKeyRaw;
186
+ let accessKeyHash;
187
+ try {
188
+ accessKeyHash = readFileSync(keyHashPath, 'utf8').trim();
189
+ }
190
+ catch {
191
+ accessKeyRaw = generateAccessKey().raw;
192
+ accessKeyHash = sha256Hex(accessKeyRaw);
193
+ }
194
+ mkdirSync(join(dataDir, 'solo'), { recursive: true, mode: 0o700 });
195
+ writeFileSync(keyHashPath, `${accessKeyHash}\n`, { mode: 0o600 });
196
+ // Ephemeral private ports for children: probe free ports on loopback so
197
+ // collisions are practically impossible (contract "Ports" block).
198
+ const probeFreePort = () => new Promise((resolveP, rejectP) => {
199
+ const srv = createServer();
200
+ srv.listen(0, '127.0.0.1', () => {
201
+ const addr = srv.address();
202
+ if (!addr || typeof addr === 'string') {
203
+ srv.close(() => rejectP(new Error('Failed to allocate child port')));
204
+ return;
205
+ }
206
+ srv.close(() => resolveP(addr.port));
207
+ });
208
+ srv.on('error', rejectP);
209
+ });
210
+ const hubPort = await probeFreePort();
211
+ const panelPort = await probeFreePort();
212
+ const soloConfig = {
213
+ port: opts.port ? Number(opts.port) : DEFAULT_SOLO_PORT,
214
+ hubPort,
215
+ panelPort,
216
+ dataDir,
217
+ accessKeyHash,
218
+ hubSecret,
219
+ };
220
+ const hubHealthy = { up: false };
221
+ const panelHealthy = { up: false };
222
+ const front = await createSoloServer({
223
+ config: soloConfig,
224
+ localApiPort: hookPort,
225
+ hub: { isHealthy: () => hubHealthy.up },
226
+ panel: { isHealthy: () => panelHealthy.up },
227
+ });
228
+ // Children ensured asynchronously AFTER the front listens (L1). The hub
229
+ // must be healthy before the Centrifugo client connects — gated below.
230
+ const soloDir = join(dataDir, 'solo');
231
+ const hubReady = (async () => {
232
+ const binPath = await ensureHubBinary({ dataDir });
233
+ const configPath = join(soloDir, 'centrifugo.json');
234
+ await writeHubConfig(configPath, { port: hubPort, secret: hubSecret });
235
+ await startHub({ configPath, binPath, dataDir });
236
+ hubHealthy.up = true;
237
+ console.log('[Solo] Hub is up (private port)');
238
+ })();
239
+ hubReady.catch((err) => {
240
+ console.error('[Solo] Hub failed to start:', err instanceof Error ? err.message : String(err));
241
+ });
242
+ // Panel version defaults to the bare bridge version (release-tag
243
+ // parity — panelBundleUrl's template already supplies the `v` prefix
244
+ // for the tag segment); override with FTOWN_SOLO_PANEL_VERSION for
245
+ // development. Either source may be typed with a leading `v`, so both
246
+ // are normalized to the bare semver panelBundleUrl expects.
247
+ const panelVersion = normalizePanelVersion(process.env.FTOWN_SOLO_PANEL_VERSION ?? BRIDGE_VERSION);
248
+ const panelReady = (async () => {
249
+ if (process.env.FTOWN_SOLO_PANEL_DIR) {
250
+ // Dev escape hatch: serve an already-built standalone directory.
251
+ const serverDir = await findPanelServerDir(resolve(process.env.FTOWN_SOLO_PANEL_DIR));
252
+ console.log(`[Solo] Using local panel dir: ${serverDir}`);
253
+ await startPanel({ bundleDir: serverDir, port: panelPort, dataDir });
201
254
  }
202
- srv.close(() => resolveP(addr.port));
255
+ else {
256
+ const bundleDir = await ensurePanelBundle({ dataDir, version: panelVersion });
257
+ const serverDir = await findPanelServerDir(bundleDir);
258
+ await startPanel({ bundleDir: serverDir, port: panelPort, dataDir });
259
+ }
260
+ panelHealthy.up = true;
261
+ console.log('[Solo] Panel is up (private port)');
262
+ })();
263
+ panelReady.catch((err) => {
264
+ console.error('[Solo] Panel failed to start:', err instanceof Error ? err.message : String(err));
203
265
  });
204
- srv.on('error', rejectP);
205
- });
206
- const hubPort = await probeFreePort();
207
- const panelPort = await probeFreePort();
208
- const soloConfig = {
209
- port: opts.port ? Number(opts.port) : DEFAULT_SOLO_PORT,
210
- hubPort,
211
- panelPort,
212
- dataDir,
213
- accessKeyHash,
214
- hubSecret,
215
- };
216
- const hubHealthy = { up: false };
217
- const panelHealthy = { up: false };
218
- const front = await createSoloServer({
219
- config: soloConfig,
220
- localApiPort: hookPort,
221
- hub: { isHealthy: () => hubHealthy.up },
222
- panel: { isHealthy: () => panelHealthy.up },
223
- });
224
- // Children ensured asynchronously AFTER the front listens (L1). The hub
225
- // must be healthy before the Centrifugo client connects — gated below.
226
- const soloDir = join(dataDir, 'solo');
227
- const hubReady = (async () => {
228
- const binPath = await ensureHubBinary({ dataDir });
229
- const configPath = join(soloDir, 'centrifugo.json');
230
- await writeHubConfig(configPath, { port: hubPort, secret: hubSecret });
231
- await startHub({ configPath, binPath, dataDir });
232
- hubHealthy.up = true;
233
- console.log('[Solo] Hub is up (private port)');
234
- })();
235
- hubReady.catch((err) => {
236
- console.error('[Solo] Hub failed to start:', err instanceof Error ? err.message : String(err));
237
- });
238
- // Panel version defaults to the bare bridge version (release-tag
239
- // parity — panelBundleUrl's template already supplies the `v` prefix
240
- // for the tag segment); override with FTOWN_SOLO_PANEL_VERSION for
241
- // development. Either source may be typed with a leading `v`, so both
242
- // are normalized to the bare semver panelBundleUrl expects.
243
- const panelVersion = normalizePanelVersion(process.env.FTOWN_SOLO_PANEL_VERSION ?? BRIDGE_VERSION);
244
- const panelReady = (async () => {
245
- if (process.env.FTOWN_SOLO_PANEL_DIR) {
246
- // Dev escape hatch: serve an already-built standalone directory.
247
- const serverDir = await findPanelServerDir(resolve(process.env.FTOWN_SOLO_PANEL_DIR));
248
- console.log(`[Solo] Using local panel dir: ${serverDir}`);
249
- await startPanel({ bundleDir: serverDir, port: panelPort, dataDir });
266
+ // One-time banner (S1 exception): full link ONLY when a fresh key was
267
+ // generated; otherwise print keyless URL + rotation hint.
268
+ const bannerIps = networkInterfacesForBanner();
269
+ console.log('========================================');
270
+ console.log(' ftown SOLO mode');
271
+ console.log(` Bridge ID: ${bridgeId}`);
272
+ for (const ip of bannerIps) {
273
+ const base = `http://${ip}:${front.port}`;
274
+ if (accessKeyRaw) {
275
+ console.log(` Open: ${base}/#k=${accessKeyRaw}`);
276
+ }
277
+ else {
278
+ console.log(` URL: ${base} (key already paired — --rotate-key reprints)`);
279
+ }
250
280
  }
251
- else {
252
- const bundleDir = await ensurePanelBundle({ dataDir, version: panelVersion });
253
- const serverDir = await findPanelServerDir(bundleDir);
254
- await startPanel({ bundleDir: serverDir, port: panelPort, dataDir });
281
+ if (!accessKeyRaw) {
282
+ console.log(' Key not shown: already issued once. --rotate-key regenerates it.');
255
283
  }
256
- panelHealthy.up = true;
257
- console.log('[Solo] Panel is up (private port)');
258
- })();
259
- panelReady.catch((err) => {
260
- console.error('[Solo] Panel failed to start:', err instanceof Error ? err.message : String(err));
261
- });
262
- // One-time banner (S1 exception): full link ONLY when a fresh key was
263
- // generated; otherwise print keyless URL + rotation hint.
264
- const bannerIps = networkInterfacesForBanner();
265
- console.log('========================================');
266
- console.log(' ftown SOLO mode');
267
- console.log(` Bridge ID: ${bridgeId}`);
268
- for (const ip of bannerIps) {
269
- const base = `http://${ip}:${front.port}`;
270
- if (accessKeyRaw) {
271
- console.log(` Open: ${base}/#k=${accessKeyRaw}`);
284
+ if (bannerIps.length === 0) {
285
+ console.log(' WARNING: no LAN interface detected; loopback only.');
272
286
  }
273
287
  else {
274
- console.log(` URL: ${base} (key already paired — --rotate-key reprints)`);
288
+ console.log(' WARNING: bound to a non-loopback interface over plain HTTP (S9).');
289
+ }
290
+ console.log('========================================');
291
+ solo = {
292
+ config: soloConfig,
293
+ front,
294
+ hub: {
295
+ stop: () => {
296
+ void stopHub(dataDir).catch(() => { });
297
+ },
298
+ },
299
+ panel: {
300
+ stop: () => {
301
+ void stopPanel(dataDir).catch(() => { });
302
+ },
303
+ },
304
+ hubReady,
305
+ panelReady,
306
+ };
307
+ }
308
+ // ----- END SOLO MODE -----
309
+ // The bootstrap token (--token) is single-use and short-lived (F1). Once a
310
+ // bridge has onboarded it persists its rotating refresh token (F3) and
311
+ // resumes from that on restart, so the bootstrap token is never reused.
312
+ const local = { localPort: hookPort, localNonce };
313
+ const refreshTokenPath = join(dataDir, 'refresh-token');
314
+ let persistedRefreshToken;
315
+ try {
316
+ persistedRefreshToken = readFileSync(refreshTokenPath, 'utf8').trim() || undefined;
317
+ }
318
+ catch {
319
+ persistedRefreshToken = undefined;
320
+ }
321
+ const persistRefreshToken = (token) => {
322
+ try {
323
+ writeFileSync(refreshTokenPath, `${token}\n`, { mode: 0o600 });
324
+ }
325
+ catch (err) {
326
+ console.error('[Bridge] Failed to persist refresh token:', err instanceof Error ? err.message : String(err));
327
+ }
328
+ };
329
+ // Onboarding precedence (P7): stored refresh token → else --token exchange →
330
+ // else interactive device pairing. Pairing yields the SAME token bundle shape
331
+ // as /api/auth/bridge, so all three paths converge on `auth` and the identical
332
+ // refresh-token persist/resume logic below.
333
+ const onboard = async () => {
334
+ if (opts.token) {
335
+ console.log('[Bridge] Authenticating with API...');
336
+ return fetchBridgeToken(opts.apiUrl, opts.token, bridgeId, local);
275
337
  }
338
+ console.log('[Bridge] No bootstrap token or stored session — starting device pairing...');
339
+ return runPairing({
340
+ apiUrl: opts.apiUrl,
341
+ bridgeId,
342
+ hostname: osHostname(),
343
+ platform: process.platform,
344
+ localPort: local.localPort,
345
+ localNonce: local.localNonce,
346
+ log: (msg) => console.log(msg),
347
+ });
348
+ };
349
+ let auth;
350
+ if (opts.local) {
351
+ auth = { userId: 'local', token: '', refreshToken: '', centrifugoUrl: 'ws://127.0.0.1/disabled' };
276
352
  }
277
- if (!accessKeyRaw) {
278
- console.log(' Key not shown: already issued once. --rotate-key regenerates it.');
353
+ else if (solo) {
354
+ // Solo mode: identity is synthesized locally (contract S2/S10). No
355
+ // refresh token exists; getToken() mints a fresh hub JWT on demand.
356
+ // The bridge's own connection token carries `info` (matching the cloud
357
+ // minter's shape) so the panel's presence-based bridge list sees it —
358
+ // browser tokens from /api/solo/bootstrap|token deliberately omit it.
359
+ const token = mintHubJwt({
360
+ secret: solo.config.hubSecret,
361
+ info: { bridgeId, hostname: osHostname() },
362
+ });
363
+ auth = {
364
+ userId: SOLO_USER_ID,
365
+ token,
366
+ refreshToken: '',
367
+ centrifugoUrl: `ws://127.0.0.1:${solo.config.hubPort}/connection/websocket`,
368
+ };
279
369
  }
280
- if (bannerIps.length === 0) {
281
- console.log(' WARNING: no LAN interface detected; loopback only.');
370
+ else if (persistedRefreshToken) {
371
+ console.log('[Bridge] Resuming from stored refresh token...');
372
+ try {
373
+ auth = await refreshBridgeToken(opts.apiUrl, persistedRefreshToken, bridgeId, local);
374
+ }
375
+ catch (err) {
376
+ console.warn('[Bridge] Stored refresh token rejected, falling back to bootstrap token / pairing:', err instanceof Error ? err.message : String(err));
377
+ auth = await onboard();
378
+ }
282
379
  }
283
380
  else {
284
- console.log(' WARNING: bound to a non-loopback interface over plain HTTP (S9).');
381
+ auth = await onboard();
285
382
  }
286
- console.log('========================================');
287
- solo = {
288
- config: soloConfig,
289
- front,
290
- hub: {
291
- stop: () => {
292
- void stopHub(dataDir).catch(() => { });
383
+ const currentRefreshToken = auth.refreshToken;
384
+ let currentBridgeToken = auth.token;
385
+ if (!solo && !opts.local)
386
+ persistRefreshToken(currentRefreshToken);
387
+ const tokenRefresher = solo || opts.local
388
+ ? null
389
+ : new RotatingTokenRefresher({
390
+ initialRefreshToken: currentRefreshToken,
391
+ refresh: async (refreshToken) => {
392
+ const refreshed = await refreshBridgeToken(opts.apiUrl, refreshToken, bridgeId, local);
393
+ currentBridgeToken = refreshed.token;
394
+ return refreshed;
293
395
  },
294
- },
295
- panel: {
296
- stop: () => {
297
- void stopPanel(dataDir).catch(() => { });
396
+ loadPersistedRefreshToken: () => {
397
+ try {
398
+ return readFileSync(refreshTokenPath, 'utf8').trim() || undefined;
399
+ }
400
+ catch {
401
+ return undefined;
402
+ }
403
+ },
404
+ persistRefreshToken,
405
+ onPersistedTokenRecovery: () => {
406
+ console.warn('[Bridge] In-memory refresh token was stale; retrying with the newer persisted token.');
298
407
  },
408
+ });
409
+ const userId = auth.userId;
410
+ const centrifugoUrl = auth.centrifugoUrl;
411
+ console.log('========================================');
412
+ console.log(' ftown-bridge starting');
413
+ console.log(` Bridge ID: ${bridgeId}`);
414
+ console.log(` User ID: ${userId}`);
415
+ console.log(` Centrifugo URL: ${centrifugoUrl}`);
416
+ console.log(` Data dir: ${dataDir}`);
417
+ console.log('========================================');
418
+ async function getToken() {
419
+ if (solo) {
420
+ // Solo: mint locally — no remote refresh, no refresh-token rotation.
421
+ // Carries the same `info` claim as the initial mint above so the
422
+ // panel keeps seeing this bridge across every refresh.
423
+ return mintHubJwt({
424
+ secret: solo.config.hubSecret,
425
+ info: { bridgeId, hostname: osHostname() },
426
+ });
427
+ }
428
+ console.log('[Bridge] Refreshing Centrifugo token...');
429
+ const token = await tokenRefresher.getToken();
430
+ console.log('[Bridge] Token refreshed successfully');
431
+ return token;
432
+ }
433
+ const store = new SessionStore(dataDir);
434
+ // Sessions left in a live state by a previous bridge are handled by
435
+ // resurrectSessions() once Centrifugo is connected (tmux reattach,
436
+ // resume respawn, or marked as error).
437
+ const terminalManager = new TerminalManager(50000, 120);
438
+ const runner = new ProcessRunner();
439
+ const centrifugo = new CentrifugoClient(centrifugoUrl, auth.token, getToken, {
440
+ disabled: opts.local,
441
+ // On a transport reconnect, re-publish the session snapshot. The UI does
442
+ // not re-request its list on reconnect, so without this its session list
443
+ // goes stale/empty after a Centrifugo blip until a page reload.
444
+ onReconnect: async () => {
445
+ // Re-publishing EVERY session on each reconnect is a publish storm that
446
+ // (before the flapping fix) fed the command-echo backlog. Terminal
447
+ // records (completed/error) don't change and were already in the UI's
448
+ // list before the blip, so only live sessions need a re-push. Publish in
449
+ // bounded batches with a macrotask yield between them so a large account
450
+ // can't monopolize the event loop. A single publish's payload is
451
+ // unchanged.
452
+ const RESYNC_CHUNK = 10;
453
+ const sessions = await store.listSessions();
454
+ const live = sessions.filter((session) => session.status === 'running' || session.status === 'pending');
455
+ for (let i = 0; i < live.length; i += RESYNC_CHUNK) {
456
+ await Promise.all(live.slice(i, i + RESYNC_CHUNK).map((session) => centrifugo.publishSessionUpdate(userId, session)));
457
+ await new Promise((resolve) => setTimeout(resolve, 0));
458
+ }
459
+ console.log(`[Bridge] Re-synced ${live.length} session(s) after Centrifugo reconnect`);
299
460
  },
300
- hubReady,
301
- panelReady,
302
- };
303
- }
304
- // ----- END SOLO MODE -----
305
- // The bootstrap token (--token) is single-use and short-lived (F1). Once a
306
- // bridge has onboarded it persists its rotating refresh token (F3) and
307
- // resumes from that on restart, so the bootstrap token is never reused.
308
- const local = { localPort: hookPort, localNonce };
309
- const refreshTokenPath = join(dataDir, 'refresh-token');
310
- let persistedRefreshToken;
311
- try {
312
- persistedRefreshToken = readFileSync(refreshTokenPath, 'utf8').trim() || undefined;
313
- }
314
- catch {
315
- persistedRefreshToken = undefined;
316
- }
317
- const persistRefreshToken = (token) => {
461
+ });
462
+ // Direct transport (WebRTC DataChannel) data plane. Peers attach per session;
463
+ // terminal output/screen fan out to them directly, and to Centrifugo only when
464
+ // the session has an unexpired remote watcher (R2). Signaling and watch
465
+ // heartbeats ride the existing commands:rpc channel.
466
+ const watchRegistry = new WatchRegistry();
467
+ const directPeers = new DirectPeerManager({
468
+ bridgeId,
469
+ sendSignal: (msg) => {
470
+ centrifugo.publishSignal(userId, msg).catch((err) => {
471
+ console.error('[Bridge] Failed to publish WebRTC signal:', err);
472
+ });
473
+ },
474
+ onInput: (sid, data) => { runner.write(sid, data); },
475
+ onResize: (sid, cols, rows) => { handleClientResize(sid, cols, rows); },
476
+ onAttach: (sid) => terminalManager.serialize(sid, 20000) ?? '',
477
+ });
478
+ // Loopback WebSocket rung: same DirectMessage protocol as WebRTC, tunneled
479
+ // over TCP on the existing 127.0.0.1 local API server. Bypasses VPN/endpoint
480
+ // filters that kill UDP hairpin. Input/resize/attach feed the SAME sinks as
481
+ // the WebRTC peer manager; upgrades are gated on the per-process nonce (L1/L2).
482
+ let browserAccess;
483
+ let apiOrigin = '';
318
484
  try {
319
- writeFileSync(refreshTokenPath, `${token}\n`, { mode: 0o600 });
485
+ apiOrigin = new URL(opts.apiUrl).origin;
320
486
  }
321
- catch (err) {
322
- console.error('[Bridge] Failed to persist refresh token:', err instanceof Error ? err.message : String(err));
323
- }
324
- };
325
- // Onboarding precedence (P7): stored refresh token → else --token exchange →
326
- // else interactive device pairing. Pairing yields the SAME token bundle shape
327
- // as /api/auth/bridge, so all three paths converge on `auth` and the identical
328
- // refresh-token persist/resume logic below.
329
- const onboard = async () => {
330
- if (opts.token) {
331
- console.log('[Bridge] Authenticating with API...');
332
- return fetchBridgeToken(opts.apiUrl, opts.token, bridgeId, local);
487
+ catch { /* leave empty; only localhost origins accepted */ }
488
+ const loopbackServer = new LoopbackPeerServer({
489
+ bridgeId,
490
+ nonce: localNonce,
491
+ authorize: (token, origin) => browserAccess?.authorize(token, origin) ?? false,
492
+ allowedOrigins: apiOrigin ? [apiOrigin] : [],
493
+ onInput: (sid, data) => { runner.write(sid, data); },
494
+ onResize: (sid, cols, rows) => { handleClientResize(sid, cols, rows); },
495
+ onAttach: (sid) => terminalManager.serialize(sid, 20000) ?? '',
496
+ });
497
+ const publishRouter = new PublishRouter({
498
+ registry: watchRegistry,
499
+ peerManager: directPeers,
500
+ loopback: loopbackServer,
501
+ centrifugo,
502
+ userId,
503
+ // Watch messages fan out to every bridge on commands:rpc; only register
504
+ // watchers for sessions this bridge actually serves terminal data for.
505
+ // hasTmuxSession is the third arm: a session can be alive in tmux with no
506
+ // PTY client in THIS process (agent-spawned re-run, adopted after a bridge
507
+ // restart, resurrection deferred). Those used to fail the guard, so the
508
+ // watch was dropped and every device rendered a blank pane. The tmux probe
509
+ // is a subprocess, so it runs last — the two in-memory checks short-circuit
510
+ // for every session this bridge already serves.
511
+ isKnownSession: (sid) => runner.isRunning(sid) || terminalManager.has(sid) || runner.hasTmuxSession(sid),
512
+ });
513
+ // Accepting the watch is not enough on its own to make output flow.
514
+ // publishScreenDump serves entirely from terminalManager, which is fed by
515
+ // TerminalPump from runner 'data' events — there is no tmux capture-pane
516
+ // path — so a session alive in tmux with no PTY client here dumps an empty
517
+ // screen and then streams nothing. Attach one client through the SAME adopt
518
+ // path session resurrection uses (runner.reattach); tmux redraws the full
519
+ // screen to a joining client, so live output resumes within milliseconds and
520
+ // rides the already-attached pump. No new pump, no new tmux plumbing.
521
+ const watchReattachInFlight = new Set();
522
+ const ensureWatchedSessionAttached = (sid) => {
523
+ if (runner.isRunning(sid) || watchReattachInFlight.has(sid))
524
+ return;
525
+ if (!runner.hasTmuxSession(sid))
526
+ return;
527
+ watchReattachInFlight.add(sid);
528
+ void store.loadSession(sid)
529
+ .then((session) => {
530
+ // Only adopt what this store still considers live: a tmux session with
531
+ // no live record may belong to another bridge on this machine.
532
+ if (!session || !shouldResurrectStoredSession(session))
533
+ return;
534
+ if (runner.isRunning(sid))
535
+ return;
536
+ if (!runner.reattach(sid, {
537
+ workingDir: session.workingDir,
538
+ parentSessionId: session.parentSessionId,
539
+ }))
540
+ return;
541
+ // Idempotent: subscribeToTerminalInput no-ops on an existing channel.
542
+ wireTerminalInput(sid);
543
+ console.log(`[Bridge] Reattached tmux session ${sid} for a new terminal watcher`);
544
+ })
545
+ .catch((err) => {
546
+ console.error(`[Bridge] Failed to reattach ${sid} for a terminal watcher:`, err);
547
+ })
548
+ .finally(() => { watchReattachInFlight.delete(sid); });
549
+ };
550
+ // Every NEW remote watcher (first, each additional distinct client, or a
551
+ // post-expiry re-registration) ⇒ push a full screen resync so the joining
552
+ // Centrifugo-fallback client renders before incremental output (R1). The
553
+ // dump is channel-wide; existing viewers re-render idempotently.
554
+ watchRegistry.onNewWatcher((sid) => {
555
+ ensureWatchedSessionAttached(sid);
556
+ publishScreenDump(sid);
557
+ });
558
+ // Bind the loopback WS upgrade handler onto the already-listening server.
559
+ const loopbackHttpServer = localApiServer.getHttpServer();
560
+ if (loopbackHttpServer) {
561
+ loopbackServer.attach(loopbackHttpServer);
562
+ console.log(`[Bridge] Loopback WS rung ready at ws://127.0.0.1:${hookPort}/ws`);
333
563
  }
334
- console.log('[Bridge] No bootstrap token or stored session — starting device pairing...');
335
- return runPairing({
564
+ localApiServer.setDependencies(store, runner, centrifugo, userId, terminalManager);
565
+ const pushNotifier = opts.local || solo ? undefined : new BridgePushNotifier({
336
566
  apiUrl: opts.apiUrl,
337
567
  bridgeId,
338
- hostname: osHostname(),
339
- platform: process.platform,
340
- localPort: local.localPort,
341
- localNonce: local.localNonce,
342
- log: (msg) => console.log(msg),
568
+ getToken: () => currentBridgeToken,
569
+ getSession: (sessionId) => store.loadSession(sessionId),
343
570
  });
344
- };
345
- let auth;
346
- if (opts.local) {
347
- auth = { userId: 'local', token: '', refreshToken: '', centrifugoUrl: 'ws://127.0.0.1/disabled' };
348
- }
349
- else if (solo) {
350
- // Solo mode: identity is synthesized locally (contract S2/S10). No
351
- // refresh token exists; getToken() mints a fresh hub JWT on demand.
352
- // The bridge's own connection token carries `info` (matching the cloud
353
- // minter's shape) so the panel's presence-based bridge list sees it —
354
- // browser tokens from /api/solo/bootstrap|token deliberately omit it.
355
- const token = mintHubJwt({
356
- secret: solo.config.hubSecret,
357
- info: { bridgeId, hostname: osHostname() },
571
+ const attentionStore = new AttentionStore((sessionId) => store.sessionDir(sessionId), 500, pushNotifier ? (sessionId, notice) => pushNotifier.notify(sessionId, notice) : undefined, {
572
+ isSessionActive: async (sessionId) => {
573
+ const session = await store.loadSession(sessionId);
574
+ return !!session && session.status === 'running' && session.shellType !== 'shell';
575
+ },
576
+ onIdleCanceled: (sessionId) => pushNotifier?.cancelIdle(sessionId),
577
+ });
578
+ localApiServer.setAttentionStore(attentionStore);
579
+ const stopPushSessionObserver = centrifugo.onLocalPublication((channel, data) => {
580
+ if (channel !== `sessions:updates#${userId}` || !data || typeof data !== 'object')
581
+ return;
582
+ const update = data;
583
+ if (update.type === 'session_update' && update.session?.id && update.session.status !== 'running') {
584
+ void attentionStore.cancelIdleNotification(update.session.id).catch(() => { });
585
+ }
358
586
  });
359
- auth = {
360
- userId: SOLO_USER_ID,
361
- token,
362
- refreshToken: '',
363
- centrifugoUrl: `ws://127.0.0.1:${solo.config.hubPort}/connection/websocket`,
587
+ if (pushNotifier) {
588
+ const knownSessions = await store.listSessions();
589
+ await attentionStore.restoreIdleNotifications(knownSessions
590
+ .filter((session) => session.status === 'running' && session.shellType !== 'shell')
591
+ .map((session) => session.id));
592
+ }
593
+ const mailStore = new MailStore((sessionId) => store.sessionDir(sessionId));
594
+ localApiServer.setMailStore(mailStore);
595
+ const notifyScriptPath = canonicalNotifyPath();
596
+ const wireTerminalInput = (sessionId) => {
597
+ centrifugo.subscribeToTerminalInput(userId, sessionId, (sid, data) => { runner.write(sid, data); }, (sid, cols, rows) => { handleClientResize(sid, cols, rows); }, (sid) => { publishScreenDump(sid); });
364
598
  };
365
- }
366
- else if (persistedRefreshToken) {
367
- console.log('[Bridge] Resuming from stored refresh token...');
599
+ const sessionFactoryDeps = {
600
+ store,
601
+ runner,
602
+ centrifugo,
603
+ userId,
604
+ bridgeId,
605
+ hookPort,
606
+ hookToken: apiToken,
607
+ notifyScriptPath,
608
+ wireTerminalInput,
609
+ };
610
+ localApiServer.setSessionFactory(sessionFactoryDeps);
611
+ // Terminal output pump: buffers runner PTY output and owns the runner
612
+ // 'complete'/'error' lifecycle handlers.
613
+ const pump = new TerminalPump({
614
+ store,
615
+ terminalManager,
616
+ publishTerminalData: (sid, data) => publishRouter.publishTerminalData(sid, data),
617
+ publishSessionUpdate: (session) => centrifugo.publishSessionUpdate(userId, session),
618
+ publishHookEvent: (sid, event) => centrifugo.publishHookEvent(userId, sid, event),
619
+ unregisterSession: (sid) => unregisterSession(sid),
620
+ collectUsage: (session) => collectSessionUsage(session),
621
+ });
622
+ pump.attach(runner);
623
+ // Resurrection engine for sessions left live by a previous bridge process.
624
+ const resurrection = new SessionResurrection({
625
+ store,
626
+ runner,
627
+ bridgeId,
628
+ sessionFactoryDeps,
629
+ publishSessionUpdate: (session) => centrifugo.publishSessionUpdate(userId, session),
630
+ wireTerminalInput,
631
+ });
632
+ // Persists Claude/Cursor/Codex-native session ids from hook events.
633
+ const agentIdPersister = new AgentSessionIdPersister({
634
+ store,
635
+ publishSessionUpdate: (session) => centrifugo.publishSessionUpdate(userId, session),
636
+ });
637
+ const hookUsagePersister = new HookUsagePersister({
638
+ store,
639
+ publishSessionUpdate: (session) => centrifugo.publishSessionUpdate(userId, session),
640
+ });
641
+ // Session operations shared by local HTTP and dashboard RPC.
642
+ const sessionController = new SessionController({
643
+ store,
644
+ runner,
645
+ publishSessionUpdate: (session) => centrifugo.publishSessionUpdate(userId, session),
646
+ removeSession: (id, options) => removeFtownSession({ store, runner, centrifugo, userId }, id, options),
647
+ sessionFactory: sessionFactoryDeps,
648
+ collectUsage: (session) => collectSessionUsage(session),
649
+ publishSyntheticStop: (sid, reason) => pump.publishSyntheticStop(sid, reason),
650
+ withSessionWrite: (sid, task) => pump.withSessionWrite(sid, task),
651
+ unregisterSession: (sid) => unregisterSession(sid),
652
+ flushTerminalBuffer: (sid) => pump.flush(sid),
653
+ destroyTerminal: (sid) => terminalManager.destroy(sid),
654
+ });
655
+ localApiServer.setSessionController(sessionController);
656
+ // Pointer lives under the instance home (see configure* above): default →
657
+ // $HOME/.ftown (harness CLIs read it there), custom --data-dir → that dir.
658
+ // Write and cleanup unlink MUST use this same resolved path.
659
+ const bridgeStateDir = ftownHome;
660
+ const bridgePointerPath = join(bridgeStateDir, 'bridge.json');
368
661
  try {
369
- auth = await refreshBridgeToken(opts.apiUrl, persistedRefreshToken, bridgeId, local);
662
+ mkdirSync(bridgeStateDir, { recursive: true, mode: 0o700 });
663
+ writeFileSync(bridgePointerPath, JSON.stringify({
664
+ port: hookPort,
665
+ apiUrl: opts.apiUrl,
666
+ token: apiToken,
667
+ bridgeId,
668
+ pid: process.pid,
669
+ startedAt: new Date().toISOString(),
670
+ harness: join(homedir(), '.ftown', 'bin', 'ftown-harness'),
671
+ harnessCli: join(homedir(), '.ftown', 'cli', 'dist', 'ftown-cli.js'),
672
+ }, null, 2), { mode: 0o600 });
673
+ console.log(`[Bridge] Wrote bridge pointer to ${bridgePointerPath}`);
370
674
  }
371
675
  catch (err) {
372
- console.warn('[Bridge] Stored refresh token rejected, falling back to bootstrap token / pairing:', err instanceof Error ? err.message : String(err));
373
- auth = await onboard();
676
+ console.error('[Bridge] Failed to write bridge pointer:', err);
374
677
  }
375
- }
376
- else {
377
- auth = await onboard();
378
- }
379
- const currentRefreshToken = auth.refreshToken;
380
- if (!solo && !opts.local)
381
- persistRefreshToken(currentRefreshToken);
382
- const tokenRefresher = solo || opts.local
383
- ? null
384
- : new RotatingTokenRefresher({
385
- initialRefreshToken: currentRefreshToken,
386
- refresh: (refreshToken) => refreshBridgeToken(opts.apiUrl, refreshToken, bridgeId, local),
387
- loadPersistedRefreshToken: () => {
388
- try {
389
- return readFileSync(refreshTokenPath, 'utf8').trim() || undefined;
390
- }
391
- catch {
392
- return undefined;
393
- }
394
- },
395
- persistRefreshToken,
396
- onPersistedTokenRecovery: () => {
397
- console.warn('[Bridge] In-memory refresh token was stale; retrying with the newer persisted token.');
398
- },
399
- });
400
- const userId = auth.userId;
401
- const centrifugoUrl = auth.centrifugoUrl;
402
- console.log('========================================');
403
- console.log(' ftown-bridge starting');
404
- console.log(` Bridge ID: ${bridgeId}`);
405
- console.log(` User ID: ${userId}`);
406
- console.log(` Centrifugo URL: ${centrifugoUrl}`);
407
- console.log(` Data dir: ${dataDir}`);
408
- console.log('========================================');
409
- async function getToken() {
410
- if (solo) {
411
- // Solo: mint locally — no remote refresh, no refresh-token rotation.
412
- // Carries the same `info` claim as the initial mint above so the
413
- // panel keeps seeing this bridge across every refresh.
414
- return mintHubJwt({
415
- secret: solo.config.hubSecret,
416
- info: { bridgeId, hostname: osHostname() },
417
- });
418
- }
419
- console.log('[Bridge] Refreshing Centrifugo token...');
420
- const token = await tokenRefresher.getToken();
421
- console.log('[Bridge] Token refreshed successfully');
422
- return token;
423
- }
424
- const store = new SessionStore(dataDir);
425
- // Sessions left in a live state by a previous bridge are handled by
426
- // resurrectSessions() once Centrifugo is connected (tmux reattach,
427
- // resume respawn, or marked as error).
428
- const terminalManager = new TerminalManager(50000, 120);
429
- const runner = new ProcessRunner();
430
- const centrifugo = new CentrifugoClient(centrifugoUrl, auth.token, getToken, {
431
- disabled: opts.local,
432
- // On a transport reconnect, re-publish the session snapshot. The UI does
433
- // not re-request its list on reconnect, so without this its session list
434
- // goes stale/empty after a Centrifugo blip until a page reload.
435
- onReconnect: async () => {
436
- // Re-publishing EVERY session on each reconnect is a publish storm that
437
- // (before the flapping fix) fed the command-echo backlog. Terminal
438
- // records (completed/error) don't change and were already in the UI's
439
- // list before the blip, so only live sessions need a re-push. Publish in
440
- // bounded batches with a macrotask yield between them so a large account
441
- // can't monopolize the event loop. A single publish's payload is
442
- // unchanged.
443
- const RESYNC_CHUNK = 10;
444
- const sessions = await store.listSessions();
445
- const live = sessions.filter((session) => session.status === 'running' || session.status === 'pending');
446
- for (let i = 0; i < live.length; i += RESYNC_CHUNK) {
447
- await Promise.all(live.slice(i, i + RESYNC_CHUNK).map((session) => centrifugo.publishSessionUpdate(userId, session)));
448
- await new Promise((resolve) => setTimeout(resolve, 0));
678
+ const cleanupPointer = () => {
679
+ try {
680
+ const pointer = JSON.parse(readFileSync(bridgePointerPath, 'utf8'));
681
+ if (pointer.pid === process.pid && pointer.token === apiToken)
682
+ unlinkSync(bridgePointerPath);
449
683
  }
450
- console.log(`[Bridge] Re-synced ${live.length} session(s) after Centrifugo reconnect`);
451
- },
452
- });
453
- // Direct transport (WebRTC DataChannel) data plane. Peers attach per session;
454
- // terminal output/screen fan out to them directly, and to Centrifugo only when
455
- // the session has an unexpired remote watcher (R2). Signaling and watch
456
- // heartbeats ride the existing commands:rpc channel.
457
- const watchRegistry = new WatchRegistry();
458
- const directPeers = new DirectPeerManager({
459
- bridgeId,
460
- sendSignal: (msg) => {
461
- centrifugo.publishSignal(userId, msg).catch((err) => {
462
- console.error('[Bridge] Failed to publish WebRTC signal:', err);
463
- });
464
- },
465
- onInput: (sid, data) => { runner.write(sid, data); },
466
- onResize: (sid, cols, rows) => { handleClientResize(sid, cols, rows); },
467
- onAttach: (sid) => terminalManager.serialize(sid, 20000) ?? '',
468
- });
469
- // Loopback WebSocket rung: same DirectMessage protocol as WebRTC, tunneled
470
- // over TCP on the existing 127.0.0.1 local API server. Bypasses VPN/endpoint
471
- // filters that kill UDP hairpin. Input/resize/attach feed the SAME sinks as
472
- // the WebRTC peer manager; upgrades are gated on the per-process nonce (L1/L2).
473
- let browserAccess;
474
- let apiOrigin = '';
475
- try {
476
- apiOrigin = new URL(opts.apiUrl).origin;
477
- }
478
- catch { /* leave empty; only localhost origins accepted */ }
479
- const loopbackServer = new LoopbackPeerServer({
480
- bridgeId,
481
- nonce: localNonce,
482
- authorize: (token, origin) => browserAccess?.authorize(token, origin) ?? false,
483
- allowedOrigins: apiOrigin ? [apiOrigin] : [],
484
- onInput: (sid, data) => { runner.write(sid, data); },
485
- onResize: (sid, cols, rows) => { handleClientResize(sid, cols, rows); },
486
- onAttach: (sid) => terminalManager.serialize(sid, 20000) ?? '',
487
- });
488
- const publishRouter = new PublishRouter({
489
- registry: watchRegistry,
490
- peerManager: directPeers,
491
- loopback: loopbackServer,
492
- centrifugo,
493
- userId,
494
- // Watch messages fan out to every bridge on commands:rpc; only register
495
- // watchers for sessions this bridge actually serves terminal data for.
496
- // hasTmuxSession is the third arm: a session can be alive in tmux with no
497
- // PTY client in THIS process (agent-spawned re-run, adopted after a bridge
498
- // restart, resurrection deferred). Those used to fail the guard, so the
499
- // watch was dropped and every device rendered a blank pane. The tmux probe
500
- // is a subprocess, so it runs last — the two in-memory checks short-circuit
501
- // for every session this bridge already serves.
502
- isKnownSession: (sid) => runner.isRunning(sid) || terminalManager.has(sid) || runner.hasTmuxSession(sid),
503
- });
504
- // Accepting the watch is not enough on its own to make output flow.
505
- // publishScreenDump serves entirely from terminalManager, which is fed by
506
- // TerminalPump from runner 'data' events — there is no tmux capture-pane
507
- // path — so a session alive in tmux with no PTY client here dumps an empty
508
- // screen and then streams nothing. Attach one client through the SAME adopt
509
- // path session resurrection uses (runner.reattach); tmux redraws the full
510
- // screen to a joining client, so live output resumes within milliseconds and
511
- // rides the already-attached pump. No new pump, no new tmux plumbing.
512
- const watchReattachInFlight = new Set();
513
- const ensureWatchedSessionAttached = (sid) => {
514
- if (runner.isRunning(sid) || watchReattachInFlight.has(sid))
515
- return;
516
- if (!runner.hasTmuxSession(sid))
517
- return;
518
- watchReattachInFlight.add(sid);
519
- void store.loadSession(sid)
520
- .then((session) => {
521
- // Only adopt what this store still considers live: a tmux session with
522
- // no live record may belong to another bridge on this machine.
523
- if (!session || !shouldResurrectStoredSession(session))
524
- return;
525
- if (runner.isRunning(sid))
684
+ catch { /* already gone or owned by a newer bridge */ }
685
+ };
686
+ process.on('exit', cleanupPointer);
687
+ function publishScreenDump(sid) {
688
+ // Phase 1: viewport-only dump (~rows*cols bytes) for instant render.
689
+ const viewportRaw = terminalManager.serialize(sid, 0);
690
+ if (viewportRaw) {
691
+ publishRouter.publishTerminalScreen(sid, viewportRaw);
692
+ }
693
+ // Phase 2: full dump with scrollback. Client re-renders on top of phase 1.
694
+ // Cap accounts for ~50% JSON-encoding inflation on ANSI-heavy buffers
695
+ // (each \x1b ->  expands 1 -> 6 bytes), keeping the on-wire
696
+ // Centrifugo frame under websocket_message_size_limit.
697
+ const MAX_BYTES = 2_500_000;
698
+ let scrollback = 20000;
699
+ let raw = terminalManager.serialize(sid, scrollback);
700
+ while (raw && Buffer.byteLength(raw, 'utf8') > MAX_BYTES && scrollback > 100) {
701
+ scrollback = Math.floor(scrollback / 2);
702
+ raw = terminalManager.serialize(sid, scrollback);
703
+ }
704
+ if (!raw)
526
705
  return;
527
- if (!runner.reattach(sid, {
528
- workingDir: session.workingDir,
529
- parentSessionId: session.parentSessionId,
530
- }))
706
+ if (raw === viewportRaw)
531
707
  return;
532
- // Idempotent: subscribeToTerminalInput no-ops on an existing channel.
533
- wireTerminalInput(sid);
534
- console.log(`[Bridge] Reattached tmux session ${sid} for a new terminal watcher`);
535
- })
536
- .catch((err) => {
537
- console.error(`[Bridge] Failed to reattach ${sid} for a terminal watcher:`, err);
538
- })
539
- .finally(() => { watchReattachInFlight.delete(sid); });
540
- };
541
- // Every NEW remote watcher (first, each additional distinct client, or a
542
- // post-expiry re-registration) ⇒ push a full screen resync so the joining
543
- // Centrifugo-fallback client renders before incremental output (R1). The
544
- // dump is channel-wide; existing viewers re-render idempotently.
545
- watchRegistry.onNewWatcher((sid) => {
546
- ensureWatchedSessionAttached(sid);
547
- publishScreenDump(sid);
548
- });
549
- // Bind the loopback WS upgrade handler onto the already-listening server.
550
- const loopbackHttpServer = localApiServer.getHttpServer();
551
- if (loopbackHttpServer) {
552
- loopbackServer.attach(loopbackHttpServer);
553
- console.log(`[Bridge] Loopback WS rung ready at ws://127.0.0.1:${hookPort}/ws`);
554
- }
555
- localApiServer.setDependencies(store, runner, centrifugo, userId, terminalManager);
556
- localApiServer.setAttentionStore(new AttentionStore((sessionId) => store.sessionDir(sessionId)));
557
- const mailStore = new MailStore((sessionId) => store.sessionDir(sessionId));
558
- localApiServer.setMailStore(mailStore);
559
- const notifyScriptPath = canonicalNotifyPath();
560
- const wireTerminalInput = (sessionId) => {
561
- centrifugo.subscribeToTerminalInput(userId, sessionId, (sid, data) => { runner.write(sid, data); }, (sid, cols, rows) => { handleClientResize(sid, cols, rows); }, (sid) => { publishScreenDump(sid); });
562
- };
563
- const sessionFactoryDeps = {
564
- store,
565
- runner,
566
- centrifugo,
567
- userId,
568
- bridgeId,
569
- hookPort,
570
- hookToken: apiToken,
571
- notifyScriptPath,
572
- wireTerminalInput,
573
- };
574
- localApiServer.setSessionFactory(sessionFactoryDeps);
575
- // Terminal output pump: buffers runner PTY output and owns the runner
576
- // 'complete'/'error' lifecycle handlers.
577
- const pump = new TerminalPump({
578
- store,
579
- terminalManager,
580
- publishTerminalData: (sid, data) => publishRouter.publishTerminalData(sid, data),
581
- publishSessionUpdate: (session) => centrifugo.publishSessionUpdate(userId, session),
582
- publishHookEvent: (sid, event) => centrifugo.publishHookEvent(userId, sid, event),
583
- unregisterSession: (sid) => unregisterSession(sid),
584
- collectUsage: (session) => collectSessionUsage(session),
585
- });
586
- pump.attach(runner);
587
- // Resurrection engine for sessions left live by a previous bridge process.
588
- const resurrection = new SessionResurrection({
589
- store,
590
- runner,
591
- bridgeId,
592
- sessionFactoryDeps,
593
- publishSessionUpdate: (session) => centrifugo.publishSessionUpdate(userId, session),
594
- wireTerminalInput,
595
- });
596
- // Persists Claude/Cursor/Codex-native session ids from hook events.
597
- const agentIdPersister = new AgentSessionIdPersister({
598
- store,
599
- publishSessionUpdate: (session) => centrifugo.publishSessionUpdate(userId, session),
600
- });
601
- const hookUsagePersister = new HookUsagePersister({
602
- store,
603
- publishSessionUpdate: (session) => centrifugo.publishSessionUpdate(userId, session),
604
- });
605
- // Session operations shared by local HTTP and dashboard RPC.
606
- const sessionController = new SessionController({
607
- store,
608
- runner,
609
- publishSessionUpdate: (session) => centrifugo.publishSessionUpdate(userId, session),
610
- removeSession: (id, options) => removeFtownSession({ store, runner, centrifugo, userId }, id, options),
611
- sessionFactory: sessionFactoryDeps,
612
- collectUsage: (session) => collectSessionUsage(session),
613
- publishSyntheticStop: (sid, reason) => pump.publishSyntheticStop(sid, reason),
614
- withSessionWrite: (sid, task) => pump.withSessionWrite(sid, task),
615
- unregisterSession: (sid) => unregisterSession(sid),
616
- flushTerminalBuffer: (sid) => pump.flush(sid),
617
- destroyTerminal: (sid) => terminalManager.destroy(sid),
618
- });
619
- localApiServer.setSessionController(sessionController);
620
- // Pointer lives under the instance home (see configure* above): default →
621
- // $HOME/.ftown (harness CLIs read it there), custom --data-dir → that dir.
622
- // Write and cleanup unlink MUST use this same resolved path.
623
- const bridgeStateDir = ftownHome;
624
- const bridgePointerPath = join(bridgeStateDir, 'bridge.json');
625
- try {
626
- mkdirSync(bridgeStateDir, { recursive: true, mode: 0o700 });
627
- writeFileSync(bridgePointerPath, JSON.stringify({
628
- port: hookPort,
629
- apiUrl: opts.apiUrl,
630
- token: apiToken,
631
- bridgeId,
632
- pid: process.pid,
633
- startedAt: new Date().toISOString(),
634
- harness: join(homedir(), '.ftown', 'bin', 'ftown-harness'),
635
- harnessCli: join(homedir(), '.ftown', 'cli', 'dist', 'ftown-cli.js'),
636
- }, null, 2), { mode: 0o600 });
637
- console.log(`[Bridge] Wrote bridge pointer to ${bridgePointerPath}`);
638
- }
639
- catch (err) {
640
- console.error('[Bridge] Failed to write bridge pointer:', err);
641
- }
642
- const cleanupPointer = () => {
643
- try {
644
- const pointer = JSON.parse(readFileSync(bridgePointerPath, 'utf8'));
645
- if (pointer.pid === process.pid && pointer.token === apiToken)
646
- unlinkSync(bridgePointerPath);
647
- }
648
- catch { /* already gone or owned by a newer bridge */ }
649
- };
650
- process.on('exit', cleanupPointer);
651
- function publishScreenDump(sid) {
652
- // Phase 1: viewport-only dump (~rows*cols bytes) for instant render.
653
- const viewportRaw = terminalManager.serialize(sid, 0);
654
- if (viewportRaw) {
655
- publishRouter.publishTerminalScreen(sid, viewportRaw);
708
+ publishRouter.publishTerminalScreen(sid, raw);
656
709
  }
657
- // Phase 2: full dump with scrollback. Client re-renders on top of phase 1.
658
- // Cap accounts for ~50% JSON-encoding inflation on ANSI-heavy buffers
659
- // (each \x1b ->  expands 1 -> 6 bytes), keeping the on-wire
660
- // Centrifugo frame under websocket_message_size_limit.
661
- const MAX_BYTES = 2_500_000;
662
- let scrollback = 20000;
663
- let raw = terminalManager.serialize(sid, scrollback);
664
- while (raw && Buffer.byteLength(raw, 'utf8') > MAX_BYTES && scrollback > 100) {
665
- scrollback = Math.floor(scrollback / 2);
666
- raw = terminalManager.serialize(sid, scrollback);
710
+ function handleClientResize(sid, cols, rows) {
711
+ runner.resize(sid, cols, rows);
712
+ terminalManager.resize(sid, cols, rows);
667
713
  }
668
- if (!raw)
669
- return;
670
- if (raw === viewportRaw)
671
- return;
672
- publishRouter.publishTerminalScreen(sid, raw);
673
- }
674
- function handleClientResize(sid, cols, rows) {
675
- runner.resize(sid, cols, rows);
676
- terminalManager.resize(sid, cols, rows);
677
- }
678
- localApiServer.on('event', (hookEvent) => {
679
- pump.withSessionWrite(hookEvent.sessionId, async () => {
680
- await agentIdPersister.persist(hookEvent);
681
- const usage = await hookUsagePersister.persist(hookEvent);
682
- await centrifugo.publishHookEvent(userId, hookEvent.sessionId, {
683
- type: 'hook_event',
684
- eventName: hookEvent.eventName,
685
- data: hookEvent.data,
686
- ...(usage ? { usage } : {}),
714
+ localApiServer.on('event', (hookEvent) => {
715
+ pump.withSessionWrite(hookEvent.sessionId, async () => {
716
+ await agentIdPersister.persist(hookEvent);
717
+ const usage = await hookUsagePersister.persist(hookEvent);
718
+ await centrifugo.publishHookEvent(userId, hookEvent.sessionId, {
719
+ type: 'hook_event',
720
+ eventName: hookEvent.eventName,
721
+ data: hookEvent.data,
722
+ ...(usage ? { usage } : {}),
723
+ });
724
+ }).catch((err) => {
725
+ console.error(`[Bridge] Failed to handle hook event for ${hookEvent.sessionId}:`, err);
687
726
  });
688
- }).catch((err) => {
689
- console.error(`[Bridge] Failed to handle hook event for ${hookEvent.sessionId}:`, err);
690
727
  });
691
- });
692
- let ready = false;
693
- browserAccess = new BrowserAccess({
694
- dataDir,
695
- cloudNonce: opts.local || solo ? undefined : localNonce,
696
- allowedOrigins: apiOrigin ? [apiOrigin] : [],
697
- bootstrap: () => ({ version: 1, userId, bridgeId, hostname: osHostname(), localPort: hookPort, localNonce: '' }),
698
- execute: async (command) => {
699
- if (!ready)
700
- return { requestId: command.requestId, success: false, error: 'Bridge is starting; try again shortly' };
701
- let result;
702
- await createCommandHandler({
703
- bridgeId, sessionController,
704
- publishCommandResponse: async (response) => { result = response; },
705
- })(command);
706
- return result ?? { requestId: command.requestId, success: false, error: 'Command was not handled' };
707
- },
708
- onRevoke: () => loopbackServer.disconnectPeers(),
709
- });
710
- localApiServer.setBrowserAccess(browserAccess);
711
- centrifugo.onLocalPublication((channel, data) => browserAccess?.publish(channel, data));
712
- console.log(`[Bridge] Local browser access: run ftown-bridge pair${opts.dataDir ? ` --data-dir ${JSON.stringify(dataDir)}` : ''}`);
713
- const handleCommand = createCommandHandler({
714
- bridgeId,
715
- relayRequest: createRelayHandler(hookPort, apiToken),
716
- sessionController,
717
- publishCommandResponse: (response) => centrifugo.publishCommandResponse(userId, response),
718
- });
719
- // Solo: the hub child must be healthy before connecting (L1 gates the
720
- // transport, not the front). A failed hub is fatal for solo — exit cleanly
721
- // instead of crashing on an unhandled rejection.
722
- if (solo) {
723
- try {
724
- await solo.hubReady;
725
- console.log('[Solo] Hub healthy — connecting Centrifugo client');
726
- }
727
- catch (err) {
728
- console.error('[Solo] Cannot continue without the hub:', err instanceof Error ? err.message : String(err));
729
- process.exit(1);
730
- }
731
- }
732
- centrifugo.connect();
733
- centrifugo.joinBridgesChannel(userId, bridgeId);
734
- centrifugo.subscribeToSessions(userId);
735
- centrifugo.subscribeToCommands(userId, (command) => {
736
- if (!ready)
737
- return;
738
- handleCommand(command).catch((err) => {
739
- console.error(`[Bridge] Unhandled error in command handler:`, err);
728
+ let ready = false;
729
+ browserAccess = new BrowserAccess({
730
+ dataDir,
731
+ cloudNonce: opts.local || solo ? undefined : localNonce,
732
+ allowedOrigins: apiOrigin ? [apiOrigin] : [],
733
+ bootstrap: () => ({ version: 1, userId, bridgeId, hostname: osHostname(), localPort: hookPort, localNonce: '' }),
734
+ execute: async (command) => {
735
+ if (!ready)
736
+ return { requestId: command.requestId, success: false, error: 'Bridge is starting; try again shortly' };
737
+ let result;
738
+ await createCommandHandler({
739
+ bridgeId, sessionController,
740
+ publishCommandResponse: async (response) => { result = response; },
741
+ })(command);
742
+ return result ?? { requestId: command.requestId, success: false, error: 'Command was not handled' };
743
+ },
744
+ onRevoke: () => loopbackServer.disconnectPeers(),
740
745
  });
741
- }, (directCommand) => {
742
- publishRouter.handleCommand(directCommand);
743
- });
744
- setTimeout(() => {
745
- void (async () => {
746
- ready = true;
747
- console.log('[Bridge] Ready and listening for commands');
746
+ localApiServer.setBrowserAccess(browserAccess);
747
+ centrifugo.onLocalPublication((channel, data) => browserAccess?.publish(channel, data));
748
+ console.log(`[Bridge] Local browser access: run ${commandName} pair${opts.dataDir ? ` --data-dir ${JSON.stringify(dataDir)}` : ''}`);
749
+ const handleCommand = createCommandHandler({
750
+ bridgeId,
751
+ relayRequest: createRelayHandler(hookPort, apiToken),
752
+ sessionController,
753
+ publishCommandResponse: (response) => centrifugo.publishCommandResponse(userId, response),
754
+ });
755
+ // Solo: the hub child must be healthy before connecting (L1 gates the
756
+ // transport, not the front). A failed hub is fatal for solo — exit cleanly
757
+ // instead of crashing on an unhandled rejection.
758
+ if (solo) {
748
759
  try {
749
- await resurrection.resurrectSessions();
760
+ await solo.hubReady;
761
+ console.log('[Solo] Hub healthy — connecting Centrifugo client');
750
762
  }
751
763
  catch (err) {
752
- console.error('[Bridge] Session resurrection failed:', err);
764
+ console.error('[Solo] Cannot continue without the hub:', err instanceof Error ? err.message : String(err));
765
+ process.exit(1);
753
766
  }
754
- })();
755
- }, 2000);
756
- const shutdown = () => {
757
- console.log('\n[Bridge] Shutting down...');
758
- // Solo L2 ordering: stop accepting first, then children (panel before
759
- // hub), then the rest of the bridge teardown.
760
- if (solo) {
761
- void solo.front.close().catch(() => { });
762
- solo.panel.stop();
763
- solo.hub.stop();
764
- }
765
- localApiServer.stop();
766
- runner.stopAll();
767
- loopbackServer.closeAll();
768
- directPeers.closeAll();
769
- watchRegistry.dispose();
770
- // node-datachannel module-level cleanup — avoids the native crash-on-exit race.
771
- try {
772
- ndcCleanup();
773
767
  }
774
- catch { /* native lib already torn down */ }
775
- centrifugo.disconnect();
776
- process.exit(0);
777
- };
778
- process.on('SIGINT', shutdown);
779
- process.on('SIGTERM', shutdown);
780
- });
781
- registerLocalBrowserCommands(program);
782
- program.parseAsync().catch((err) => {
783
- console.error(err instanceof Error ? err.message : String(err));
784
- process.exit(1);
785
- });
768
+ centrifugo.connect();
769
+ centrifugo.joinBridgesChannel(userId, bridgeId);
770
+ centrifugo.subscribeToSessions(userId);
771
+ centrifugo.subscribeToCommands(userId, (command) => {
772
+ if (!ready)
773
+ return;
774
+ handleCommand(command).catch((err) => {
775
+ console.error(`[Bridge] Unhandled error in command handler:`, err);
776
+ });
777
+ }, (directCommand) => {
778
+ publishRouter.handleCommand(directCommand);
779
+ });
780
+ setTimeout(() => {
781
+ void (async () => {
782
+ ready = true;
783
+ console.log('[Bridge] Ready and listening for commands');
784
+ try {
785
+ await resurrection.resurrectSessions();
786
+ }
787
+ catch (err) {
788
+ console.error('[Bridge] Session resurrection failed:', err);
789
+ }
790
+ })();
791
+ }, 2000);
792
+ const shutdown = () => {
793
+ console.log('\n[Bridge] Shutting down...');
794
+ attentionStore.stopNotifications();
795
+ stopPushSessionObserver();
796
+ pushNotifier?.stop();
797
+ // Solo L2 ordering: stop accepting first, then children (panel before
798
+ // hub), then the rest of the bridge teardown.
799
+ if (solo) {
800
+ void solo.front.close().catch(() => { });
801
+ solo.panel.stop();
802
+ solo.hub.stop();
803
+ }
804
+ localApiServer.stop();
805
+ runner.stopAll();
806
+ loopbackServer.closeAll();
807
+ directPeers.closeAll();
808
+ watchRegistry.dispose();
809
+ // node-datachannel module-level cleanup — avoids the native crash-on-exit race.
810
+ try {
811
+ ndcCleanup();
812
+ }
813
+ catch { /* native lib already torn down */ }
814
+ centrifugo.disconnect();
815
+ process.exit(0);
816
+ };
817
+ process.on('SIGINT', shutdown);
818
+ process.on('SIGTERM', shutdown);
819
+ });
820
+ registerLocalBrowserCommands(program);
821
+ try {
822
+ await program.parseAsync(argv, { from: 'user' });
823
+ return 0;
824
+ }
825
+ catch (error) {
826
+ if (error instanceof CommanderError)
827
+ return error.exitCode;
828
+ throw error;
829
+ }
830
+ }
831
+ if (isCliEntry(import.meta.url, process.argv[1])) {
832
+ void executeCli(() => runBridge(process.argv.slice(2)));
833
+ }
786
834
  //# sourceMappingURL=index.js.map