fraim-hub 2.0.290 → 2.0.291

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.
@@ -46,15 +46,10 @@ const git_utils_1 = require("../core/utils/git-utils");
46
46
  const path_1 = __importDefault(require("path"));
47
47
  const child_process_1 = require("child_process");
48
48
  const fs_1 = __importDefault(require("fs"));
49
- const net_1 = __importDefault(require("net"));
50
- const http_1 = __importDefault(require("http"));
51
- const tree_kill_1 = __importDefault(require("tree-kill"));
52
- const hub_launch_decision_1 = require("./hub-launch-decision");
53
49
  const hub_runtime_file_1 = require("./hub-runtime-file");
54
50
  const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
55
- const version_utils_1 = require("../cli/utils/version-utils");
56
51
  const electron_dist_1 = require("./electron-dist");
57
- const process_liveness_1 = require("./process-liveness");
52
+ const hub_instance_reconciliation_1 = require("./hub-instance-reconciliation");
58
53
  function resolveDesktopEntry() {
59
54
  const candidates = [
60
55
  path_1.default.resolve(__dirname, 'desktop-main.js'),
@@ -113,96 +108,6 @@ function openBrowser(url) {
113
108
  const child = (0, child_process_1.spawn)('xdg-open', [url], { detached: true, stdio: 'ignore' });
114
109
  child.unref();
115
110
  }
116
- function killPid(pid) {
117
- try {
118
- if (process.platform === 'win32') {
119
- // /T kills the entire process tree; /F forces termination without prompting.
120
- (0, child_process_1.execFileSync)('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore' });
121
- }
122
- else {
123
- // tree-kill sends SIGTERM to the process group so child Electron helper processes
124
- // are also terminated, not just the root node process (#921).
125
- (0, tree_kill_1.default)(pid, 'SIGTERM');
126
- }
127
- }
128
- catch {
129
- /* already gone */
130
- }
131
- }
132
- // #921: after killing the registered pid, probe ports 43091-43200 for any Hub HTTP
133
- // response. Any Hub found on those ports that is NOT the already-killed pid is an
134
- // orphan (e.g. an older version started via bare `npx` that never wrote hub-runtime.json).
135
- // GET /api/ai-hub/pid identifies the pid; we then kill it and wait for its port to free.
136
- async function fetchHubPid(port) {
137
- return new Promise((resolve) => {
138
- const req = http_1.default.get({ host: '127.0.0.1', port, path: '/api/ai-hub/pid', timeout: 1000 }, (res) => {
139
- if (res.statusCode !== 200) {
140
- res.resume();
141
- return resolve(null);
142
- }
143
- let body = '';
144
- res.on('data', (c) => { body += c; });
145
- res.on('end', () => { try {
146
- resolve(JSON.parse(body).pid ?? null);
147
- }
148
- catch {
149
- resolve(null);
150
- } });
151
- });
152
- req.on('error', () => resolve(null));
153
- req.on('timeout', () => { req.destroy(); resolve(null); });
154
- });
155
- }
156
- async function scanAndKillOrphanHubs(excludePid) {
157
- const HUB_PORT_SCAN_START = 43091;
158
- const HUB_PORT_SCAN_END = 43200;
159
- for (let port = HUB_PORT_SCAN_START; port <= HUB_PORT_SCAN_END; port++) {
160
- const version = await fetchRunningHubVersion(port);
161
- if (!version)
162
- continue; // port not a Hub
163
- const pid = await fetchHubPid(port);
164
- if (pid === null || pid === excludePid)
165
- continue; // same process we already killed
166
- console.log(`Killing orphan Hub (pid ${pid}) on port ${port}`);
167
- killPid(pid);
168
- await waitForPortFree(port);
169
- }
170
- }
171
- function isPortFree(port, timeoutMs = 500) {
172
- return new Promise((resolve) => {
173
- const sock = net_1.default.connect({ host: '127.0.0.1', port }, () => { sock.destroy(); resolve(false); });
174
- sock.on('error', () => resolve(true));
175
- sock.setTimeout(timeoutMs, () => { sock.destroy(); resolve(true); });
176
- });
177
- }
178
- async function waitForPortFree(port, totalMs = 5000) {
179
- const start = Date.now();
180
- while (Date.now() - start < totalMs) {
181
- if (await isPortFree(port))
182
- return;
183
- await new Promise((r) => setTimeout(r, 200));
184
- }
185
- }
186
- function fetchRunningHubVersion(port) {
187
- return new Promise((resolve) => {
188
- const req = http_1.default.get({ host: '127.0.0.1', port, path: '/api/ai-hub/version', timeout: 1000 }, (res) => {
189
- if (res.statusCode !== 200) {
190
- res.resume();
191
- return resolve(null);
192
- }
193
- let body = '';
194
- res.on('data', (c) => { body += c; });
195
- res.on('end', () => { try {
196
- resolve(JSON.parse(body).version ?? null);
197
- }
198
- catch {
199
- resolve(null);
200
- } });
201
- });
202
- req.on('error', () => resolve(null));
203
- req.on('timeout', () => { req.destroy(); resolve(null); });
204
- });
205
- }
206
111
  // #1110: the desktop shell's cold-start cost is work this launcher can neither see nor bound.
207
112
  // Before the Hub can answer /api/ai-hub/version it pays for Electron process boot, top-level
208
113
  // evaluation of the src/ai-hub/server.ts module graph, cert load plus a blocking certutil
@@ -278,7 +183,7 @@ async function waitForDesktopHubReady(child, preferredPort, options = {}) {
278
183
  while (Date.now() - start < timeoutMs) {
279
184
  failIfChildDied();
280
185
  for (const port of readinessProbePorts(fraimDir, runtimeId, preferredPort)) {
281
- const version = await fetchRunningHubVersion(port);
186
+ const version = await (0, hub_instance_reconciliation_1.fetchRunningHubVersion)(port);
282
187
  if (version) {
283
188
  return { port, version };
284
189
  }
@@ -298,32 +203,6 @@ async function waitForDesktopHubReady(child, preferredPort, options = {}) {
298
203
  + `${stillRunning}`
299
204
  + ` Set ${exports.DESKTOP_HUB_READY_TIMEOUT_ENV} to a larger value if this machine needs longer.`);
300
205
  }
301
- async function reconcileRunningHub(flags, runtimeId = 'hub') {
302
- const running = (0, hub_runtime_file_1.readHubRuntimeFile)((0, project_fraim_paths_1.getUserFraimDirPath)(), runtimeId);
303
- const confirmedVersion = running ? await fetchRunningHubVersion(running.port) : null;
304
- const live = !!(running && confirmedVersion && (0, process_liveness_1.isPidAlive)(running.pid));
305
- const effective = live && running ? { ...running, version: confirmedVersion } : null;
306
- const decision = (0, hub_launch_decision_1.decideHubLaunch)({
307
- running: effective,
308
- pidAlive: live,
309
- cliVersion: (0, version_utils_1.getFraimVersion)(),
310
- restart: flags.restart,
311
- noRestart: flags.keepRunning,
312
- });
313
- if (decision.action === 'replace' && effective) {
314
- console.log(`Replacing running Hub (v${effective.version}, pid ${effective.pid}) - ${decision.reason}`);
315
- killPid(effective.pid);
316
- await waitForPortFree(effective.port);
317
- // #921: after killing the registered pid, scan for orphan Hub processes that were
318
- // never registered in hub-runtime.json (e.g. older versions started via bare npx).
319
- if (runtimeId === 'hub') {
320
- await scanAndKillOrphanHubs(effective.pid);
321
- }
322
- }
323
- else if (decision.action === 'focus-existing' && effective) {
324
- console.log(`A Hub (v${effective.version}) is already running - focusing it. Use --restart to replace it.`);
325
- }
326
- }
327
206
  async function runHub(options) {
328
207
  const { AiHubServer, findAvailablePort } = await Promise.resolve().then(() => __importStar(require('./server')));
329
208
  const preferredPort = options.port || (0, git_utils_1.getPort)() + 100;
@@ -332,7 +211,7 @@ async function runHub(options) {
332
211
  if (options.open) {
333
212
  const wantDesktop = !options.browser;
334
213
  if (wantDesktop) {
335
- await reconcileRunningHub({ restart: !!options.restart, keepRunning: !!options.keepRunning }, runtimeId);
214
+ await (0, hub_instance_reconciliation_1.reconcileRunningHub)({ restart: !!options.restart, keepRunning: !!options.keepRunning }, runtimeId);
336
215
  }
337
216
  const desktopChild = wantDesktop ? await openDesktopWindow(projectPath, preferredPort, runtimeId) : null;
338
217
  if (!desktopChild) {
@@ -22,6 +22,7 @@ const session_service_1 = require("../first-run/session-service");
22
22
  const fraim_mcp_latest_launcher_1 = require("../cli/mcp/fraim-mcp-latest-launcher");
23
23
  const desktop_auto_updater_1 = require("./desktop-auto-updater");
24
24
  const hub_app_materializer_1 = require("./hub-app-materializer");
25
+ const hub_instance_reconciliation_1 = require("./hub-instance-reconciliation");
25
26
  // Keep installed, running, and user-pinned Windows shortcuts grouped under the
26
27
  // stable identity declared in packages/fraim-hub/package.json.
27
28
  electron_1.app.setAppUserModelId('ai.fraim.hub');
@@ -135,28 +136,54 @@ async function configureAutoUpdater() {
135
136
  });
136
137
  return result.action === 'installing';
137
138
  }
139
+ // Issue #1415: shared by every path that needs to know "is a newer FRAIM Hub published than the
140
+ // one currently running" — the automatic second-instance/activate recheck below, and the
141
+ // manually-triggered restart from the web UI's update badge (see restartToLatestNow). Compares
142
+ // `getFraimVersion()` (not `app.getVersion()`), because it resolves from wherever the
143
+ // currently-running code actually lives — the bundled app.asar or a previously materialized copy
144
+ // — while `app.getVersion()` always names the former.
145
+ async function resolveLatestHubAppVersion() {
146
+ const runningVersion = (0, version_utils_1.getFraimVersion)();
147
+ const resolved = await (0, hub_app_materializer_1.resolveHubAppEntry)({
148
+ bundledVersion: runningVersion,
149
+ bundledEntryPath: __filename,
150
+ });
151
+ return { upgradable: resolved.version !== runningVersion, latest: resolved.version };
152
+ }
138
153
  // Issue #1415: a second-instance activation (the user re-clicking the icon while the Hub is
139
154
  // already running) is the common case once login-item autostart + hide-to-tray keep the Hub
140
155
  // resident for days, and it is the one launch-shaped event where the desktop-launcher.ts
141
156
  // materializer never gets a chance to run — bootstrap() has already won the single-instance lock
142
157
  // by the time it fires. Without this check, an already-running Hub could only ever pick up a new
143
158
  // `fraim-hub` release through the slower electron-updater feed below, which is the exact lagging
144
- // signal #1415 was filed about. `getFraimVersion()` (not `app.getVersion()`) is compared, because
145
- // it resolves from wherever the currently-running code actually lives — the bundled app.asar or a
146
- // previously materialized copy — while `app.getVersion()` always names the former.
159
+ // signal #1415 was filed about.
147
160
  async function checkForHubAppUpdateOnSecondInstance() {
148
- const runningVersion = (0, version_utils_1.getFraimVersion)();
149
- const resolved = await (0, hub_app_materializer_1.resolveHubAppEntry)({
150
- bundledVersion: runningVersion,
151
- bundledEntryPath: __filename,
152
- });
153
- if (resolved.version === runningVersion)
161
+ const { upgradable, latest } = await resolveLatestHubAppVersion();
162
+ if (!upgradable)
154
163
  return false;
155
- console.log(`[fraim] newer FRAIM Hub ${resolved.version} available (was running ${runningVersion}); restarting to apply it`);
164
+ console.log(`[fraim] newer FRAIM Hub ${latest} available (was running ${(0, version_utils_1.getFraimVersion)()}); restarting to apply it`);
156
165
  electron_1.app.relaunch();
157
166
  electron_1.app.quit();
158
167
  return true;
159
168
  }
169
+ // Issue #1415: the web UI's persistent update badge (#1379) used to just tell the user to quit
170
+ // and relaunch by hand. This is the same "resolve latest, relaunch" as the automatic checks above,
171
+ // just triggered by an explicit click instead of a relaunch-shaped Electron event — wired into
172
+ // AiHubServer as the `restartToLatest` option (see launchDesktopShell) so POST
173
+ // /api/ai-hub/restart-to-latest can call it. The relaunch/quit is deferred a beat so the HTTP
174
+ // response reaches the browser (which uses it to start polling for the new instance) before this
175
+ // process starts tearing down.
176
+ async function restartToLatestNow() {
177
+ const { upgradable, latest } = await resolveLatestHubAppVersion();
178
+ if (!upgradable)
179
+ return { restarting: false, latest };
180
+ console.log(`[fraim] manual restart requested; relaunching to FRAIM Hub ${latest}`);
181
+ setTimeout(() => {
182
+ electron_1.app.relaunch();
183
+ electron_1.app.quit();
184
+ }, 300);
185
+ return { restarting: true, latest };
186
+ }
160
187
  // Issue #1415: shared by both re-launch-shaped events on an already-running Hub — Windows/Linux's
161
188
  // `second-instance` (a new process lost the single-instance lock) and macOS's `activate` (the
162
189
  // Dock icon click that never spawns a new process at all, so `second-instance` never fires there).
@@ -434,6 +461,7 @@ async function launchDesktopShell(options) {
434
461
  });
435
462
  return result.canceled || result.filePaths.length === 0 ? null : result.filePaths[0];
436
463
  },
464
+ restartToLatest: restartToLatestNow,
437
465
  });
438
466
  await server.start(httpPort);
439
467
  const resolvedProjectPath = server.getProjectPath();
@@ -525,6 +553,25 @@ async function bootstrap() {
525
553
  await electron_1.app.whenReady();
526
554
  electron_1.app.setName(displayName(options.runtimeId));
527
555
  configurePackagedFraimRuntime();
556
+ // Issue #1415 (follow-up): app.requestSingleInstanceLock() above only sees another instance of
557
+ // *this same identity* — it cannot see a Hub already running via a different launch path (e.g.
558
+ // `npx fraim-hub`'s raw electron.exe, confirmed live to end up under a different userData scope
559
+ // than this packaged app). hub-runtime.json is the identity-agnostic record both launch paths
560
+ // already write (#755); reconcile against it — and sweep for unregistered orphans (#921) —
561
+ // before binding our own port, so a direct double-click launch takes over whatever Hub is
562
+ // already running instead of silently opening a second, independent one on a fallback port.
563
+ // `restart: true` because a materialized/bundled launch is by construction already resolved to
564
+ // the current latest — there is nothing to compare, only to replace. Runs after whenReady, like
565
+ // every other network-touching step in this function (checkForRelaunchAttemptUpdate,
566
+ // configureAutoUpdater) — reconcileRunningHub makes real HTTP probes and process kills, and this
567
+ // keeps it on the same footing as those instead of racing Electron's own startup.
568
+ // Gated on the same FRAIM_AI_HUB_FAKE_HOST flag as the single-instance lock above, not on
569
+ // FRAIM_INSTALLER_LIFECYCLE_TEST: the orphan sweep probes real system ports 43091-43200
570
+ // regardless of FRAIM_USER_DIR isolation, so a test double (FAKE_HOST=1) skips it for the same
571
+ // reason it skips the lock — it must never disturb a real Hub already running on this machine.
572
+ if (!skipSingleInstance) {
573
+ await (0, hub_instance_reconciliation_1.reconcileRunningHub)({ restart: true, keepRunning: false }, options.runtimeId);
574
+ }
528
575
  // macOS reads the dock icon from the app bundle once packaged, but an
529
576
  // unpackaged `npm run hub:desktop` / `npx fraim-hub` run shows Electron's
530
577
  // own icon there unless we set it explicitly (#1329).
@@ -0,0 +1,156 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.killPid = killPid;
7
+ exports.isPortFree = isPortFree;
8
+ exports.waitForPortFree = waitForPortFree;
9
+ exports.fetchRunningHubVersion = fetchRunningHubVersion;
10
+ exports.fetchHubPid = fetchHubPid;
11
+ exports.scanAndKillOrphanHubs = scanAndKillOrphanHubs;
12
+ exports.reconcileRunningHub = reconcileRunningHub;
13
+ // Issue #755/#921: kill-and-take-over an already-running Hub, identified through the shared,
14
+ // identity-agnostic ~/.fraim/hub-runtime.json + a port scan - never through OS-level process
15
+ // identity (Electron's app.requestSingleInstanceLock() included). That distinction matters: a
16
+ // Hub can be running via `npx fraim-hub`'s raw electron.exe (a different userData scope than the
17
+ // packaged, branded app) or a bare-npx build that never wrote hub-runtime.json at all, and this
18
+ // module is what makes "only one Hub, and it's the one that just launched" true regardless of
19
+ // which of those started the one already running.
20
+ //
21
+ // Originally only exercised by the CLI (`fraim hub`, see cli.ts) before it spawns its own desktop
22
+ // shell. Issue #1415 follow-up: the packaged desktop app's own bootstrap (desktop-main.ts) never
23
+ // called any of this when launched directly (double-click / Windows shortcut, no CLI in front of
24
+ // it) - confirmed live: a Hub already running via `npx fraim-hub` kept its default ports while a
25
+ // freshly installed packaged build silently fell back to the next free ports and came up as an
26
+ // independent second Hub instead of replacing it. Both launch paths now share this module so
27
+ // there is exactly one implementation of "is a Hub already running, and if so, replace it."
28
+ const child_process_1 = require("child_process");
29
+ const net_1 = __importDefault(require("net"));
30
+ const http_1 = __importDefault(require("http"));
31
+ const tree_kill_1 = __importDefault(require("tree-kill"));
32
+ const hub_launch_decision_1 = require("./hub-launch-decision");
33
+ const hub_runtime_file_1 = require("./hub-runtime-file");
34
+ const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
35
+ const version_utils_1 = require("../cli/utils/version-utils");
36
+ const process_liveness_1 = require("./process-liveness");
37
+ function killPid(pid) {
38
+ try {
39
+ if (process.platform === 'win32') {
40
+ // /T kills the entire process tree; /F forces termination without prompting.
41
+ (0, child_process_1.execFileSync)('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore' });
42
+ }
43
+ else {
44
+ // tree-kill sends SIGTERM to the process group so child Electron helper processes
45
+ // are also terminated, not just the root node process (#921).
46
+ (0, tree_kill_1.default)(pid, 'SIGTERM');
47
+ }
48
+ }
49
+ catch {
50
+ /* already gone */
51
+ }
52
+ }
53
+ function isPortFree(port, timeoutMs = 500) {
54
+ return new Promise((resolve) => {
55
+ const sock = net_1.default.connect({ host: '127.0.0.1', port }, () => { sock.destroy(); resolve(false); });
56
+ sock.on('error', () => resolve(true));
57
+ sock.setTimeout(timeoutMs, () => { sock.destroy(); resolve(true); });
58
+ });
59
+ }
60
+ async function waitForPortFree(port, totalMs = 5000) {
61
+ const start = Date.now();
62
+ while (Date.now() - start < totalMs) {
63
+ if (await isPortFree(port))
64
+ return;
65
+ await new Promise((r) => setTimeout(r, 200));
66
+ }
67
+ }
68
+ function fetchRunningHubVersion(port) {
69
+ return new Promise((resolve) => {
70
+ const req = http_1.default.get({ host: '127.0.0.1', port, path: '/api/ai-hub/version', timeout: 1000 }, (res) => {
71
+ if (res.statusCode !== 200) {
72
+ res.resume();
73
+ return resolve(null);
74
+ }
75
+ let body = '';
76
+ res.on('data', (c) => { body += c; });
77
+ res.on('end', () => { try {
78
+ resolve(JSON.parse(body).version ?? null);
79
+ }
80
+ catch {
81
+ resolve(null);
82
+ } });
83
+ });
84
+ req.on('error', () => resolve(null));
85
+ req.on('timeout', () => { req.destroy(); resolve(null); });
86
+ });
87
+ }
88
+ // #921: after killing the registered pid, probe ports 43091-43200 for any Hub HTTP
89
+ // response. Any Hub found on those ports that is NOT the already-killed pid is an
90
+ // orphan (e.g. an older version started via bare `npx` that never wrote hub-runtime.json).
91
+ // GET /api/ai-hub/pid identifies the pid; we then kill it and wait for its port to free.
92
+ function fetchHubPid(port) {
93
+ return new Promise((resolve) => {
94
+ const req = http_1.default.get({ host: '127.0.0.1', port, path: '/api/ai-hub/pid', timeout: 1000 }, (res) => {
95
+ if (res.statusCode !== 200) {
96
+ res.resume();
97
+ return resolve(null);
98
+ }
99
+ let body = '';
100
+ res.on('data', (c) => { body += c; });
101
+ res.on('end', () => { try {
102
+ resolve(JSON.parse(body).pid ?? null);
103
+ }
104
+ catch {
105
+ resolve(null);
106
+ } });
107
+ });
108
+ req.on('error', () => resolve(null));
109
+ req.on('timeout', () => { req.destroy(); resolve(null); });
110
+ });
111
+ }
112
+ async function scanAndKillOrphanHubs(excludePid) {
113
+ const HUB_PORT_SCAN_START = 43091;
114
+ const HUB_PORT_SCAN_END = 43200;
115
+ for (let port = HUB_PORT_SCAN_START; port <= HUB_PORT_SCAN_END; port++) {
116
+ const version = await fetchRunningHubVersion(port);
117
+ if (!version)
118
+ continue; // port not a Hub
119
+ const pid = await fetchHubPid(port);
120
+ if (pid === null || pid === excludePid)
121
+ continue; // same process we already killed
122
+ console.log(`Killing orphan Hub (pid ${pid}) on port ${port}`);
123
+ killPid(pid);
124
+ await waitForPortFree(port);
125
+ }
126
+ }
127
+ // The one entry point both launch paths call before starting their own server: reads
128
+ // hub-runtime.json for `runtimeId`, confirms the recorded pid is actually alive and answering
129
+ // (not just present in a stale file), and - when the decision is `replace` - kills it, waits for
130
+ // its port to free, then sweeps for any unregistered orphan on the standard Hub port range.
131
+ async function reconcileRunningHub(flags, runtimeId = 'hub') {
132
+ const running = (0, hub_runtime_file_1.readHubRuntimeFile)((0, project_fraim_paths_1.getUserFraimDirPath)(), runtimeId);
133
+ const confirmedVersion = running ? await fetchRunningHubVersion(running.port) : null;
134
+ const live = !!(running && confirmedVersion && (0, process_liveness_1.isPidAlive)(running.pid));
135
+ const effective = live && running ? { ...running, version: confirmedVersion } : null;
136
+ const decision = (0, hub_launch_decision_1.decideHubLaunch)({
137
+ running: effective,
138
+ pidAlive: live,
139
+ cliVersion: (0, version_utils_1.getFraimVersion)(),
140
+ restart: flags.restart,
141
+ noRestart: flags.keepRunning,
142
+ });
143
+ if (decision.action === 'replace' && effective) {
144
+ console.log(`Replacing running Hub (v${effective.version}, pid ${effective.pid}) - ${decision.reason}`);
145
+ killPid(effective.pid);
146
+ await waitForPortFree(effective.port);
147
+ // #921: after killing the registered pid, scan for orphan Hub processes that were
148
+ // never registered in hub-runtime.json (e.g. older versions started via bare npx).
149
+ if (runtimeId === 'hub') {
150
+ await scanAndKillOrphanHubs(effective.pid);
151
+ }
152
+ }
153
+ else if (decision.action === 'focus-existing' && effective) {
154
+ console.log(`A Hub (v${effective.version}) is already running - focusing it. Use --restart to replace it.`);
155
+ }
156
+ }
@@ -2201,6 +2201,31 @@ function buildHubBackgroundTaskContinueMessage(run) {
2201
2201
  'If results are not yet available, wait briefly or check again. Once you have the results, continue with your original plan.',
2202
2202
  ].join('\n');
2203
2203
  }
2204
+ // #1419 (Change 3): auto-resume when the only unresolved condition is a
2205
+ // KILLED background task (see hasKilledBackgroundTask) — distinct from
2206
+ // buildHubBackgroundTaskContinueMessage above, which resumes a still-ACTIVE
2207
+ // resumable task. recoveryAttempts is NOT incremented — same budget-preserving
2208
+ // semantics as #1275's background_task resume.
2209
+ function createHubBackgroundTaskLostContinueEvent(run) {
2210
+ return (0, hosts_1.createHubEvent)('system', `Hub background-task-lost continuation for run ${run.id} session ${run.sessionId || 'unknown'} — a background task was killed before its work finished.${hostErrorSuffix(run)}`);
2211
+ }
2212
+ // `killedDescription` must be captured at classification time (classifyExit's
2213
+ // systemNote) rather than recomputed here: handleRunExit's resume path calls
2214
+ // clearBackgroundTaskLifecycle before this message is built (the setTimeout'd
2215
+ // continue), which wipes run.hostLifecycle.backgroundTasks. Recomputing from
2216
+ // the live run at that point would always fall back to the generic
2217
+ // 'a background task' description.
2218
+ function buildHubBackgroundTaskLostContinueMessage(run, killedDescription) {
2219
+ return [
2220
+ '[FRAIM Hub system recovery]',
2221
+ 'This is not a manager-authored instruction. Do not say the manager asked you to continue.',
2222
+ `Run id: ${run.id}`,
2223
+ `Session id: ${run.sessionId || 'unknown'}`,
2224
+ `A background task was killed when a turn ended before it reached a terminal state: ${killedDescription || describeKilledBackgroundTasks(run)}.`,
2225
+ 'Its result is unknown, not passing or failing.',
2226
+ 'Check for partial output first; if none exists, re-run the work and say plainly that this is a re-run because the prior attempt\'s result was never observed.',
2227
+ ].join('\n');
2228
+ }
2204
2229
  function isHumanActionGate(run) {
2205
2230
  if (run.stoppedByUser)
2206
2231
  return true;
@@ -2248,19 +2273,31 @@ function hasActiveResumableBackgroundTask(run) {
2248
2273
  return Object.values(tasks).some((task) => task.status === 'active' && isResumableBackgroundTaskType(task.taskType));
2249
2274
  }
2250
2275
  // Issue #1234: true when the host reported a background task as active
2251
- // (`background_tasks_changed`) and it either never resolved before the host's
2252
- // child process exited, or resolved as `killed` rather than `completed`. Both
2253
- // are evidence the agent's promised follow-up did not happen.
2276
+ // (`background_tasks_changed`) and it never resolved before the host's child
2277
+ // process exited a still-running, non-resumable task is a worse signal
2278
+ // than a cleanly killed one (it is "unexpectedly still running", not
2279
+ // "definitely gone") and must keep hard-erroring rather than auto-resuming.
2254
2280
  // #1275: tightened — local_bash active tasks are handled by hasActiveResumableBackgroundTask above.
2255
- // #1355: the same predicate decides both sides, so an untyped active task cannot be
2256
- // simultaneously non-resumable here and non-local_bash there. A killed task is
2257
- // unresolved regardless of its type that clause is independent of taskType.
2258
- function hasUnresolvedBackgroundTask(run) {
2281
+ // #1419: split out of the old hasUnresolvedBackgroundTask so a killed task can be
2282
+ // classified separately (see hasKilledBackgroundTask) Change 3's fix is to the
2283
+ // classification's *action* for the killed case, not to detection, which already
2284
+ // fired correctly for both cases.
2285
+ function hasActiveNonResumableBackgroundTask(run) {
2259
2286
  const tasks = run.hostLifecycle?.backgroundTasks;
2260
2287
  if (!tasks)
2261
2288
  return false;
2262
- return Object.values(tasks).some((task) => (task.status === 'active' && !isResumableBackgroundTaskType(task.taskType)) ||
2263
- task.status === 'killed');
2289
+ return Object.values(tasks).some((task) => task.status === 'active' && !isResumableBackgroundTaskType(task.taskType));
2290
+ }
2291
+ // #1419: true when the host reported a background task that resolved as
2292
+ // `killed` rather than `completed` — evidence the agent's (or its subagent's)
2293
+ // promised follow-up did not happen, but the work is definitively gone rather
2294
+ // than still running. Independent of taskType: a killed task is killed
2295
+ // regardless of what type the host labelled it.
2296
+ function hasKilledBackgroundTask(run) {
2297
+ const tasks = run.hostLifecycle?.backgroundTasks;
2298
+ if (!tasks)
2299
+ return false;
2300
+ return Object.values(tasks).some((task) => task.status === 'killed');
2264
2301
  }
2265
2302
  function describeUnresolvedBackgroundTasks(run) {
2266
2303
  const descriptions = Object.values(run.hostLifecycle?.backgroundTasks || {})
@@ -2270,6 +2307,15 @@ function describeUnresolvedBackgroundTasks(run) {
2270
2307
  return `The host ended this turn while ${list} was still active, and it was killed or never confirmed complete. `
2271
2308
  + `The agent's promised follow-up did not happen; review the conversation before continuing.`;
2272
2309
  }
2310
+ // #1419: names the specific killed task(s) so the auto-resume continuation
2311
+ // message (buildHubBackgroundTaskLostContinueMessage) can be concrete about
2312
+ // what was lost, same spirit as describeUnresolvedBackgroundTasks above.
2313
+ function describeKilledBackgroundTasks(run) {
2314
+ const descriptions = Object.values(run.hostLifecycle?.backgroundTasks || {})
2315
+ .filter((task) => task.status === 'killed')
2316
+ .map((task) => task.description || 'a background task');
2317
+ return descriptions.length > 0 ? descriptions.join('; ') : 'a background task';
2318
+ }
2273
2319
  function classifyExit(run, exitCode) {
2274
2320
  if (run.stoppedByUser) {
2275
2321
  return { action: 'park', pauseReason: 'stopped' };
@@ -2321,26 +2367,50 @@ function classifyExit(run, exitCode) {
2321
2367
  if (hasActiveResumableBackgroundTask(run)) {
2322
2368
  return { action: 'resume', pauseReason: 'working', recoveryKind: 'background_task' };
2323
2369
  }
2324
- if (hasUnresolvedBackgroundTask(run)) {
2370
+ // #1419 (Change 3): an unexpectedly-still-running non-resumable task is a
2371
+ // worse signal than a cleanly killed one — keep hard-erroring for it.
2372
+ if (hasActiveNonResumableBackgroundTask(run)) {
2325
2373
  return { action: 'error', pauseReason: 'error', systemNote: describeUnresolvedBackgroundTasks(run) };
2326
2374
  }
2375
+ // When the ONLY unresolved condition is a killed task, the work is
2376
+ // definitely gone rather than still running: auto-resume once with an
2377
+ // evidence-honest continuation instead of forcing the manager to say
2378
+ // "continue" with no evidence of what was lost.
2379
+ if (hasKilledBackgroundTask(run)) {
2380
+ // systemNote computed now, before clearBackgroundTaskLifecycle wipes
2381
+ // the task map in handleRunExit — the continuation message needs the
2382
+ // real description, not the post-clear fallback.
2383
+ return { action: 'resume', pauseReason: 'working', recoveryKind: 'background_task_lost', systemNote: describeKilledBackgroundTasks(run) };
2384
+ }
2327
2385
  return { action: 'park', pauseReason: 'awaiting_user' };
2328
2386
  }
2329
2387
  if (compactingActive) {
2330
2388
  return { action: 'resume', pauseReason: 'working', recoveryKind: 'compaction' };
2331
2389
  }
2332
- if (hasUnresolvedBackgroundTask(run)) {
2390
+ if (hasActiveNonResumableBackgroundTask(run)) {
2333
2391
  return { action: 'error', pauseReason: 'error', systemNote: describeUnresolvedBackgroundTasks(run) };
2334
2392
  }
2393
+ if (hasKilledBackgroundTask(run)) {
2394
+ // systemNote computed now, before clearBackgroundTaskLifecycle wipes
2395
+ // the task map in handleRunExit — the continuation message needs the
2396
+ // real description, not the post-clear fallback.
2397
+ return { action: 'resume', pauseReason: 'working', recoveryKind: 'background_task_lost', systemNote: describeKilledBackgroundTasks(run) };
2398
+ }
2335
2399
  // Phase mid-flight (started but not completed/incomplete): conservative park.
2336
2400
  return { action: 'park', pauseReason: 'awaiting_user' };
2337
2401
  }
2338
2402
  if (compactingActive) {
2339
2403
  return { action: 'resume', pauseReason: 'working', recoveryKind: 'compaction' };
2340
2404
  }
2341
- if (hasUnresolvedBackgroundTask(run)) {
2405
+ if (hasActiveNonResumableBackgroundTask(run)) {
2342
2406
  return { action: 'error', pauseReason: 'error', systemNote: describeUnresolvedBackgroundTasks(run) };
2343
2407
  }
2408
+ if (hasKilledBackgroundTask(run)) {
2409
+ // systemNote computed now, before clearBackgroundTaskLifecycle wipes the
2410
+ // task map in handleRunExit — the continuation message needs the real
2411
+ // description, not the post-clear fallback.
2412
+ return { action: 'resume', pauseReason: 'working', recoveryKind: 'background_task_lost', systemNote: describeKilledBackgroundTasks(run) };
2413
+ }
2344
2414
  // Signal-less non-FRAIM run: conservative park.
2345
2415
  return { action: 'park', pauseReason: 'awaiting_user' };
2346
2416
  }
@@ -2478,6 +2548,7 @@ class AiHubServer {
2478
2548
  this.configuredAgentStore = options.configuredAgentStore || new configured_agents_1.AiHubConfiguredAgentStore();
2479
2549
  this.wordTaskpaneDir = options.wordTaskpaneDir ?? resolveWordTaskpaneDir(this.projectPath);
2480
2550
  this.folderPicker = options.folderPicker ?? pickProjectPath;
2551
+ this.restartToLatest = options.restartToLatest ?? (async () => ({ restarting: false }));
2481
2552
  this.httpsPort = options.httpsPort;
2482
2553
  this.certBundle = options.certBundle;
2483
2554
  this.hubUiCacheDir = options.hubUiCacheDir || process.env.FRAIM_HUB_UI_CACHE_DIR || (0, ui_cache_1.defaultHubUiCacheDir)();
@@ -5381,6 +5452,19 @@ class AiHubServer {
5381
5452
  this.app.get('/api/ai-hub/pid', (_req, res) => {
5382
5453
  return res.json({ pid: process.pid });
5383
5454
  });
5455
+ // Issue #1415/#1379: the update badge's click handler POSTs here instead of just telling the
5456
+ // user to quit and relaunch by hand. `restartToLatest` (the Electron shell's
5457
+ // restartToLatestNow) responds before it actually tears the process down, so the client can
5458
+ // rely on this response arriving and then poll /api/ai-hub/version for the new instance.
5459
+ this.app.post('/api/ai-hub/restart-to-latest', async (_req, res) => {
5460
+ try {
5461
+ const result = await this.restartToLatest();
5462
+ return res.json(result);
5463
+ }
5464
+ catch (error) {
5465
+ return res.status(500).json({ error: error instanceof Error ? error.message : 'Could not restart to the latest version.' });
5466
+ }
5467
+ });
5384
5468
  this.app.post('/api/ai-hub/project-path/pick', async (_req, res) => {
5385
5469
  try {
5386
5470
  const projectPath = await this.folderPicker();
@@ -7407,9 +7491,11 @@ class AiHubServer {
7407
7491
  // entries from the dead Claude Code process don't re-trigger hasActiveResumableBackgroundTask
7408
7492
  // on the next exit, causing an infinite loop.
7409
7493
  this.runRegistry.update(runId, (current) => {
7410
- if (classification.recoveryKind === 'background_task') {
7494
+ if (classification.recoveryKind === 'background_task' || classification.recoveryKind === 'background_task_lost') {
7411
7495
  clearBackgroundTaskLifecycle(current);
7412
- current.events.push(createHubBackgroundTaskContinueEvent(current));
7496
+ current.events.push(classification.recoveryKind === 'background_task'
7497
+ ? createHubBackgroundTaskContinueEvent(current)
7498
+ : createHubBackgroundTaskLostContinueEvent(current));
7413
7499
  }
7414
7500
  else {
7415
7501
  current.recoveryAttempts = (current.recoveryAttempts ?? 0) + 1;
@@ -7444,7 +7530,9 @@ class AiHubServer {
7444
7530
  ? buildHubCompactionRecoveryContinueMessage(current, exitCode, attempt)
7445
7531
  : classification.recoveryKind === 'background_task'
7446
7532
  ? buildHubBackgroundTaskContinueMessage(current)
7447
- : buildHubRecoveryContinueMessage(current, exitCode, attempt);
7533
+ : classification.recoveryKind === 'background_task_lost'
7534
+ ? buildHubBackgroundTaskLostContinueMessage(current, classification.systemNote)
7535
+ : buildHubRecoveryContinueMessage(current, exitCode, attempt);
7448
7536
  // Issue #1150: recovery must relaunch as the SAME configured agent. Its setup
7449
7537
  // script supplies the env that selects the profile the host session lives in
7450
7538
  // (CODEX_HOME for Codex), and resuming without it sends `codex exec resume`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fraim-hub",
3
- "version": "2.0.290",
3
+ "version": "2.0.291",
4
4
  "description": "FRAIM Hub local companion package.",
5
5
  "author": "Sid Mathur <sid.mathur@gmail.com>",
6
6
  "homepage": "https://github.com/mathursrus/FRAIM#readme",
@@ -211,7 +211,7 @@
211
211
  "electron-updater": "^6.8.9",
212
212
  "express": "^5.2.1",
213
213
  "extract-zip": "^2.0.1",
214
- "fraim": "2.0.290",
214
+ "fraim": "2.0.291",
215
215
  "mongodb": "^7.0.0",
216
216
  "node-cron": "4.2.1",
217
217
  "node-edge-tts": "^1.2.10",
@@ -16078,11 +16078,12 @@ function tfWireShell() {
16078
16078
  }
16079
16079
  const avatar = document.getElementById('avatar-btn');
16080
16080
  if (avatar) avatar.addEventListener('click', tfToggleAccountMenu);
16081
- // #1379: clicking the top-bar badge opens the same account menu that already carries
16082
- // the full update-available detail, rather than duplicating that content into a
16083
- // second popover.
16081
+ // Issue #1415: clicking the top-bar badge now restarts the Hub straight into the latest
16082
+ // published version (the same "resolve latest, relaunch" the desktop shell already does on
16083
+ // every second-instance/activate attempt) instead of opening the account menu to read
16084
+ // instructions for quitting and relaunching by hand.
16084
16085
  const updateBadge = document.getElementById('hub-update-badge');
16085
- if (updateBadge) updateBadge.addEventListener('click', tfToggleAccountMenu);
16086
+ if (updateBadge) updateBadge.addEventListener('click', tfRestartHubToLatest);
16086
16087
  tfWireThemeToggle();
16087
16088
  const accountItem = document.getElementById('am-account');
16088
16089
  if (accountItem) accountItem.addEventListener('click', (e) => {
@@ -16334,6 +16335,60 @@ function tfPopulateVersionInfo() {
16334
16335
  .catch(() => { /* offline / best-effort: leave both surfaces hidden */ });
16335
16336
  }
16336
16337
 
16338
+ // Issue #1415: the update badge used to only tell the user to quit and relaunch by hand
16339
+ // (#1379). This resolves latest npm-published FRAIM Hub and relaunches into it directly - the
16340
+ // desktop shell responds to the POST before it actually tears itself down (see
16341
+ // restartToLatestNow in desktop-main.ts), so the ack below is reliable; the reload then waits
16342
+ // for the *new* instance specifically (matching `latest`), not just any response, so it never
16343
+ // reloads against the old process mid-shutdown.
16344
+ function tfRestartHubToLatest(event) {
16345
+ if (event) event.preventDefault();
16346
+ const badgeEl = document.getElementById('hub-update-badge');
16347
+ if (badgeEl) {
16348
+ badgeEl.disabled = true;
16349
+ badgeEl.textContent = '⬆️ Restarting…';
16350
+ }
16351
+ fetch('/api/ai-hub/restart-to-latest', { method: 'POST' })
16352
+ .then((r) => (r.ok ? r.json() : Promise.reject(new Error(`status ${r.status}`))))
16353
+ .then((info) => {
16354
+ if (info && info.restarting) {
16355
+ tfPollForHubRestart(info.latest || null, 0);
16356
+ } else if (badgeEl) {
16357
+ badgeEl.disabled = false;
16358
+ badgeEl.textContent = '⬆️ Updates available';
16359
+ }
16360
+ })
16361
+ .catch(() => {
16362
+ if (badgeEl) {
16363
+ badgeEl.disabled = false;
16364
+ badgeEl.textContent = '⬆️ Updates available';
16365
+ }
16366
+ });
16367
+ }
16368
+
16369
+ const HUB_RESTART_POLL_MS = 1000;
16370
+ const HUB_RESTART_POLL_MAX_ATTEMPTS = 45; // ~45s: generous for a fresh materialize-from-npm relaunch
16371
+
16372
+ function tfPollForHubRestart(targetVersion, attempt) {
16373
+ if (attempt >= HUB_RESTART_POLL_MAX_ATTEMPTS) {
16374
+ window.location.reload();
16375
+ return;
16376
+ }
16377
+ setTimeout(() => {
16378
+ fetch('/api/ai-hub/version')
16379
+ .then((r) => (r.ok ? r.json() : Promise.reject(new Error(`status ${r.status}`))))
16380
+ .then((info) => {
16381
+ // No target to match (best-effort) or the new instance is the one now answering.
16382
+ if (!targetVersion || (info && info.version === targetVersion)) {
16383
+ window.location.reload();
16384
+ } else {
16385
+ tfPollForHubRestart(targetVersion, attempt + 1);
16386
+ }
16387
+ })
16388
+ .catch(() => tfPollForHubRestart(targetVersion, attempt + 1));
16389
+ }, HUB_RESTART_POLL_MS);
16390
+ }
16391
+
16337
16392
  function tfRequestedConnectedSurface() {
16338
16393
  try {
16339
16394
  const params = new URLSearchParams(window.location.search);
@@ -4494,6 +4494,10 @@ body.hub-shell { display: flex; flex-direction: column; height: 100vh; overflow:
4494
4494
  .hub-update-badge:hover { opacity: .85; box-shadow: 0 2px 8px rgba(0,0,0,.22); }
4495
4495
  .hub-update-badge:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
4496
4496
  .hub-update-badge[hidden] { display: none; }
4497
+ /* Issue #1415: while the restart-to-latest request is in flight / the new instance is coming up,
4498
+ the badge shows a live "Restarting…" state rather than the static hover affordance. */
4499
+ .hub-update-badge:disabled { cursor: default; opacity: .7; }
4500
+ .hub-update-badge:disabled:hover { box-shadow: 0 1px 4px rgba(0,0,0,.18); }
4497
4501
 
4498
4502
  /* Suppress the old Hub's header and rail when inside the workspace-conv */
4499
4503
  .workspace-conv .header { display: none !important; }