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.
- package/claude-mod/.claude-plugin/plugin.json +6 -0
- package/claude-mod/hooks/hooks.json +3 -0
- package/claude-mod/hooks/register.js +137 -0
- package/dist/agent-commands.js +17 -2
- package/dist/agent-commands.js.map +1 -1
- package/dist/attention-store.d.ts +21 -1
- package/dist/attention-store.js +131 -3
- package/dist/attention-store.js.map +1 -1
- package/dist/claude-mod-installer.d.ts +9 -0
- package/dist/claude-mod-installer.js +55 -0
- package/dist/claude-mod-installer.js.map +1 -0
- package/dist/claude-runner.d.ts +8 -0
- package/dist/claude-runner.js +11 -4
- package/dist/claude-runner.js.map +1 -1
- package/dist/ftown-cli.d.ts +1 -1
- package/dist/ftown-cli.js +11 -3
- package/dist/ftown-cli.js.map +1 -1
- package/dist/harness-registry.js +4 -0
- package/dist/harness-registry.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +719 -671
- package/dist/index.js.map +1 -1
- package/dist/install-ftown-command-cli.d.ts +1 -1
- package/dist/install-ftown-command-cli.js +35 -44
- package/dist/install-ftown-command-cli.js.map +1 -1
- package/dist/installation-manifest.d.ts +25 -0
- package/dist/installation-manifest.js +37 -0
- package/dist/installation-manifest.js.map +1 -0
- package/dist/local-api-server.d.ts +8 -0
- package/dist/local-api-server.js +73 -1
- package/dist/local-api-server.js.map +1 -1
- package/dist/mail-delivery.d.ts +30 -0
- package/dist/mail-delivery.js +202 -8
- package/dist/mail-delivery.js.map +1 -1
- package/dist/mcp/fleet.d.ts +2 -2
- package/dist/native-mail.d.ts +31 -0
- package/dist/native-mail.js +142 -0
- package/dist/native-mail.js.map +1 -0
- package/dist/production-dependencies.d.ts +13 -0
- package/dist/production-dependencies.js +175 -0
- package/dist/production-dependencies.js.map +1 -0
- package/dist/push-notifier.d.ts +40 -0
- package/dist/push-notifier.js +126 -0
- package/dist/push-notifier.js.map +1 -0
- package/dist/sessions-commands.js +278 -310
- package/dist/sessions-commands.js.map +1 -1
- package/dist/setup-state.d.ts +1 -2
- package/dist/setup-state.js +31 -11
- package/dist/setup-state.js.map +1 -1
- package/dist/setup.js +3 -1
- package/dist/setup.js.map +1 -1
- package/dist/start-commands.d.ts +10 -0
- package/dist/start-commands.js +15 -0
- package/dist/start-commands.js.map +1 -0
- package/opencode-plugin/ftown.js +142 -129
- package/package.json +3 -2
- 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
|
-
|
|
69
|
-
program
|
|
70
|
-
.
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
apiUrl
|
|
86
|
-
apiUrl.hostname === '
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
const
|
|
155
|
-
|
|
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
|
-
|
|
115
|
+
persistedBridgeId = readFileSync(bridgeIdPath, 'utf8').trim() || undefined;
|
|
170
116
|
}
|
|
171
117
|
catch {
|
|
172
|
-
|
|
173
|
-
mkdirSync(dirname(hubSecretPath), { recursive: true, mode: 0o700 });
|
|
174
|
-
writeFileSync(hubSecretPath, `${hubSecret}\n`, { mode: 0o600 });
|
|
118
|
+
persistedBridgeId = undefined;
|
|
175
119
|
}
|
|
176
|
-
|
|
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
|
-
|
|
122
|
+
mkdirSync(dataDir, { recursive: true });
|
|
123
|
+
writeFileSync(bridgeIdPath, `${bridgeId}\n`, { mode: 0o600 });
|
|
185
124
|
}
|
|
186
|
-
catch {
|
|
187
|
-
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
252
|
-
|
|
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
|
-
|
|
257
|
-
|
|
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(
|
|
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 (
|
|
278
|
-
|
|
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 (
|
|
281
|
-
console.log('
|
|
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
|
-
|
|
381
|
+
auth = await onboard();
|
|
285
382
|
}
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
-
|
|
485
|
+
apiOrigin = new URL(opts.apiUrl).origin;
|
|
320
486
|
}
|
|
321
|
-
catch
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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
|
-
|
|
335
|
-
|
|
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
|
-
|
|
339
|
-
|
|
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
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
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
|
-
|
|
367
|
-
|
|
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
|
-
|
|
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.
|
|
373
|
-
auth = await onboard();
|
|
676
|
+
console.error('[Bridge] Failed to write bridge pointer:', err);
|
|
374
677
|
}
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
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
|
-
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
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 (
|
|
528
|
-
workingDir: session.workingDir,
|
|
529
|
-
parentSessionId: session.parentSessionId,
|
|
530
|
-
}))
|
|
706
|
+
if (raw === viewportRaw)
|
|
531
707
|
return;
|
|
532
|
-
|
|
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
|
-
|
|
658
|
-
|
|
659
|
-
|
|
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
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
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
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
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
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
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
|
|
760
|
+
await solo.hubReady;
|
|
761
|
+
console.log('[Solo] Hub healthy — connecting Centrifugo client');
|
|
750
762
|
}
|
|
751
763
|
catch (err) {
|
|
752
|
-
console.error('[
|
|
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
|
-
|
|
775
|
-
centrifugo.
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
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
|