fraim-hub 2.0.221 → 2.0.223

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.
@@ -45,6 +45,7 @@ const child_process_1 = require("child_process");
45
45
  const fs_1 = __importDefault(require("fs"));
46
46
  const net_1 = __importDefault(require("net"));
47
47
  const http_1 = __importDefault(require("http"));
48
+ const tree_kill_1 = __importDefault(require("tree-kill"));
48
49
  const hub_launch_decision_1 = require("./hub-launch-decision");
49
50
  const hub_runtime_file_1 = require("./hub-runtime-file");
50
51
  const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
@@ -116,16 +117,58 @@ function isProcessAlive(pid) {
116
117
  function killPid(pid) {
117
118
  try {
118
119
  if (process.platform === 'win32') {
120
+ // /T kills the entire process tree; /F forces termination without prompting.
119
121
  (0, child_process_1.execFileSync)('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore' });
120
122
  }
121
123
  else {
122
- process.kill(pid, 'SIGTERM');
124
+ // tree-kill sends SIGTERM to the process group so child Electron helper processes
125
+ // are also terminated, not just the root node process (#921).
126
+ (0, tree_kill_1.default)(pid, 'SIGTERM');
123
127
  }
124
128
  }
125
129
  catch {
126
130
  /* already gone */
127
131
  }
128
132
  }
133
+ // #921: after killing the registered pid, probe ports 43091-43200 for any Hub HTTP
134
+ // response. Any Hub found on those ports that is NOT the already-killed pid is an
135
+ // orphan (e.g. an older version started via bare `npx` that never wrote hub-runtime.json).
136
+ // GET /api/ai-hub/pid identifies the pid; we then kill it and wait for its port to free.
137
+ async function fetchHubPid(port) {
138
+ return new Promise((resolve) => {
139
+ const req = http_1.default.get({ host: '127.0.0.1', port, path: '/api/ai-hub/pid', timeout: 1000 }, (res) => {
140
+ if (res.statusCode !== 200) {
141
+ res.resume();
142
+ return resolve(null);
143
+ }
144
+ let body = '';
145
+ res.on('data', (c) => { body += c; });
146
+ res.on('end', () => { try {
147
+ resolve(JSON.parse(body).pid ?? null);
148
+ }
149
+ catch {
150
+ resolve(null);
151
+ } });
152
+ });
153
+ req.on('error', () => resolve(null));
154
+ req.on('timeout', () => { req.destroy(); resolve(null); });
155
+ });
156
+ }
157
+ async function scanAndKillOrphanHubs(excludePid) {
158
+ const HUB_PORT_SCAN_START = 43091;
159
+ const HUB_PORT_SCAN_END = 43200;
160
+ for (let port = HUB_PORT_SCAN_START; port <= HUB_PORT_SCAN_END; port++) {
161
+ const version = await fetchRunningHubVersion(port);
162
+ if (!version)
163
+ continue; // port not a Hub
164
+ const pid = await fetchHubPid(port);
165
+ if (pid === null || pid === excludePid)
166
+ continue; // same process we already killed
167
+ console.log(`Killing orphan Hub (pid ${pid}) on port ${port}`);
168
+ killPid(pid);
169
+ await waitForPortFree(port);
170
+ }
171
+ }
129
172
  function isPortFree(port, timeoutMs = 500) {
130
173
  return new Promise((resolve) => {
131
174
  const sock = net_1.default.connect({ host: '127.0.0.1', port }, () => { sock.destroy(); resolve(false); });
@@ -177,6 +220,9 @@ async function reconcileRunningHub(flags) {
177
220
  console.log(`Replacing running Hub (v${effective.version}, pid ${effective.pid}) - ${decision.reason}`);
178
221
  killPid(effective.pid);
179
222
  await waitForPortFree(effective.port);
223
+ // #921: after killing the registered pid, scan for orphan Hub processes that were
224
+ // never registered in hub-runtime.json (e.g. older versions started via bare npx).
225
+ await scanAndKillOrphanHubs(effective.pid);
180
226
  }
181
227
  else if (decision.action === 'focus-existing' && effective) {
182
228
  console.log(`A Hub (v${effective.version}) is already running - focusing it. Use --restart to replace it.`);
@@ -0,0 +1,166 @@
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.hubInstallCommand = void 0;
7
+ exports.resolveHubInstallDir = resolveHubInstallDir;
8
+ exports.resolveShortcutTarget = resolveShortcutTarget;
9
+ exports.getOsShortcutPaths = getOsShortcutPaths;
10
+ exports.writeMacosLaunchAgent = writeMacosLaunchAgent;
11
+ exports.writeLinuxDesktopEntry = writeLinuxDesktopEntry;
12
+ exports.runHubInstall = runHubInstall;
13
+ // #921: `fraim hub install` — register the FRAIM Hub as an OS application so users
14
+ // can launch it without a terminal or npx command.
15
+ //
16
+ // This is an interim mitigation. Full packaged installers (electron-builder, code
17
+ // signing, .msi/.dmg) are a separate effort requiring signing infrastructure.
18
+ const commander_1 = require("commander");
19
+ const fs_1 = __importDefault(require("fs"));
20
+ const path_1 = __importDefault(require("path"));
21
+ const os_1 = __importDefault(require("os"));
22
+ const child_process_1 = require("child_process");
23
+ const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
24
+ // Stable install directory under ~/.fraim/bin/fraim-hub-electron/
25
+ function resolveHubInstallDir() {
26
+ return path_1.default.join((0, project_fraim_paths_1.getUserFraimDirPath)(), 'bin', 'fraim-hub-electron');
27
+ }
28
+ // The binary that OS shortcuts should point at. On Windows this is the Electron .exe;
29
+ // on POSIX systems it is the unpacked Electron binary or a shell wrapper.
30
+ function resolveShortcutTarget() {
31
+ const installDir = resolveHubInstallDir();
32
+ if (process.platform === 'win32') {
33
+ return path_1.default.join(installDir, 'fraim-hub.exe');
34
+ }
35
+ if (process.platform === 'darwin') {
36
+ return path_1.default.join(installDir, 'FRAIM Hub.app', 'Contents', 'MacOS', 'FRAIM Hub');
37
+ }
38
+ return path_1.default.join(installDir, 'fraim-hub');
39
+ }
40
+ // Platform-correct locations for OS launcher entries.
41
+ function getOsShortcutPaths(home) {
42
+ if (process.platform === 'win32') {
43
+ const appData = process.env.APPDATA || path_1.default.join(home, 'AppData', 'Roaming');
44
+ return {
45
+ startMenu: path_1.default.join(appData, 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'FRAIM Hub.lnk'),
46
+ };
47
+ }
48
+ if (process.platform === 'darwin') {
49
+ return {
50
+ launchAgent: path_1.default.join(home, 'Library', 'LaunchAgents', 'ai.fraim.hub.plist'),
51
+ };
52
+ }
53
+ return {
54
+ desktopEntry: path_1.default.join(home, '.local', 'share', 'applications', 'fraim-hub.desktop'),
55
+ };
56
+ }
57
+ function writeMacosLaunchAgent(plistPath, target) {
58
+ const content = `<?xml version="1.0" encoding="UTF-8"?>
59
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
60
+ <plist version="1.0">
61
+ <dict>
62
+ <key>Label</key>
63
+ <string>ai.fraim.hub</string>
64
+ <key>ProgramArguments</key>
65
+ <array>
66
+ <string>${target}</string>
67
+ <string>--no-open</string>
68
+ </array>
69
+ <key>RunAtLoad</key>
70
+ <true/>
71
+ <key>KeepAlive</key>
72
+ <false/>
73
+ <key>StandardOutPath</key>
74
+ <string>${path_1.default.join(os_1.default.homedir(), '.fraim', 'hub-launchagent.log')}</string>
75
+ <key>StandardErrorPath</key>
76
+ <string>${path_1.default.join(os_1.default.homedir(), '.fraim', 'hub-launchagent-error.log')}</string>
77
+ </dict>
78
+ </plist>
79
+ `;
80
+ fs_1.default.mkdirSync(path_1.default.dirname(plistPath), { recursive: true });
81
+ fs_1.default.writeFileSync(plistPath, content, 'utf8');
82
+ }
83
+ function writeLinuxDesktopEntry(desktopPath, target) {
84
+ const content = `[Desktop Entry]
85
+ Name=FRAIM Hub
86
+ Comment=FRAIM AI Workforce Hub
87
+ Exec=${target} --no-open
88
+ Icon=fraim-hub
89
+ Terminal=false
90
+ Type=Application
91
+ Categories=Utility;
92
+ StartupNotify=true
93
+ `;
94
+ fs_1.default.mkdirSync(path_1.default.dirname(desktopPath), { recursive: true });
95
+ fs_1.default.writeFileSync(desktopPath, content, 'utf8');
96
+ try {
97
+ fs_1.default.chmodSync(desktopPath, 0o644);
98
+ }
99
+ catch { /* non-fatal */ }
100
+ }
101
+ function writeWindowsShortcutScript(lnkPath, target) {
102
+ // PowerShell WScript.Shell creates a real .lnk shortcut.
103
+ const ps = [
104
+ '$ws = New-Object -ComObject WScript.Shell',
105
+ `$sc = $ws.CreateShortcut('${lnkPath.replace(/'/g, "''")}')`,
106
+ `$sc.TargetPath = '${target.replace(/'/g, "''")}'`,
107
+ "$sc.WindowStyle = 1",
108
+ '$sc.Save()',
109
+ ].join('; ');
110
+ try {
111
+ (0, child_process_1.execFileSync)('powershell', ['-NoProfile', '-NonInteractive', '-Command', ps], { stdio: 'pipe' });
112
+ }
113
+ catch {
114
+ // PowerShell unavailable (e.g. minimal CI image) — write a plain .cmd launcher as fallback.
115
+ const safeTarget = target.replace(/"/g, '""');
116
+ const cmd = `@echo off\r\nstart "" "${safeTarget}"\r\n`;
117
+ const cmdPath = lnkPath.replace(/\.lnk$/i, '.cmd');
118
+ fs_1.default.mkdirSync(path_1.default.dirname(cmdPath), { recursive: true });
119
+ fs_1.default.writeFileSync(cmdPath, cmd, 'utf8');
120
+ }
121
+ }
122
+ async function runHubInstall() {
123
+ console.log('Installing FRAIM Hub as an OS application...');
124
+ const installDir = resolveHubInstallDir();
125
+ const target = resolveShortcutTarget();
126
+ const home = os_1.default.homedir();
127
+ const shortcuts = getOsShortcutPaths(home);
128
+ // Ensure the install directory exists (the actual Electron binary download
129
+ // is a separate step — this wires the OS launcher to the expected path so
130
+ // the shortcut is ready as soon as the binary is downloaded).
131
+ fs_1.default.mkdirSync(installDir, { recursive: true });
132
+ if (process.platform === 'win32' && shortcuts.startMenu) {
133
+ fs_1.default.mkdirSync(path_1.default.dirname(shortcuts.startMenu), { recursive: true });
134
+ writeWindowsShortcutScript(shortcuts.startMenu, target);
135
+ console.log(` Start Menu shortcut: ${shortcuts.startMenu}`);
136
+ }
137
+ else if (process.platform === 'darwin' && shortcuts.launchAgent) {
138
+ writeMacosLaunchAgent(shortcuts.launchAgent, target);
139
+ // Load the agent so it is active immediately without a logout/login.
140
+ try {
141
+ (0, child_process_1.execFileSync)('launchctl', ['load', shortcuts.launchAgent], { stdio: 'ignore' });
142
+ }
143
+ catch { /* non-fatal if already loaded */ }
144
+ console.log(` LaunchAgent: ${shortcuts.launchAgent}`);
145
+ }
146
+ else if (shortcuts.desktopEntry) {
147
+ writeLinuxDesktopEntry(shortcuts.desktopEntry, target);
148
+ // Notify the desktop environment to refresh its app database.
149
+ try {
150
+ (0, child_process_1.execFileSync)('update-desktop-database', [path_1.default.dirname(shortcuts.desktopEntry)], { stdio: 'ignore' });
151
+ }
152
+ catch { /* non-fatal if tool absent */ }
153
+ console.log(` Desktop entry: ${shortcuts.desktopEntry}`);
154
+ }
155
+ console.log('');
156
+ console.log('FRAIM Hub is registered as an OS application.');
157
+ console.log('Launch it from your Start Menu / Launchpad / application launcher.');
158
+ console.log('');
159
+ console.log('Note: the Hub binary will be downloaded on first launch via:');
160
+ console.log(' fraim hub (or npx fraim-hub@latest)');
161
+ }
162
+ exports.hubInstallCommand = new commander_1.Command('install')
163
+ .description('Register FRAIM Hub as an OS application (Start Menu / Launchpad entry)')
164
+ .action(async () => {
165
+ await runHubInstall();
166
+ });
@@ -3182,6 +3182,11 @@ class AiHubServer {
3182
3182
  const updateAvailable = !!(latest && semver.valid(version) && semver.valid(latest) && semver.gt(latest, version));
3183
3183
  return res.json({ version, latest, updateAvailable });
3184
3184
  });
3185
+ // #921: expose the server's pid so the orphan scanner in cli.ts can identify and
3186
+ // kill Hub processes that are not registered in hub-runtime.json.
3187
+ this.app.get('/api/ai-hub/pid', (_req, res) => {
3188
+ return res.json({ pid: process.pid });
3189
+ });
3185
3190
  this.app.post('/api/ai-hub/project-path/pick', async (_req, res) => {
3186
3191
  try {
3187
3192
  const projectPath = await this.folderPicker();
@@ -9,6 +9,7 @@ const commander_1 = require("commander");
9
9
  const fs_1 = __importDefault(require("fs"));
10
10
  const path_1 = __importDefault(require("path"));
11
11
  const cli_1 = require("../ai-hub/cli");
12
+ const hub_install_1 = require("../ai-hub/hub-install");
12
13
  function readPackageVersion() {
13
14
  try {
14
15
  let currentDir = __dirname;
@@ -40,6 +41,8 @@ function createFraimHubProgram(action = cli_1.runHub) {
40
41
  .option('--keep-running', 'Do not replace a running Hub; keep single-instance focus (default replaces only an older/stale instance)')
41
42
  .allowExcessArguments(false)
42
43
  .action(action);
44
+ // #921: `fraim-hub install` — register Hub as an OS app (Start Menu / Launchpad entry)
45
+ program.addCommand(hub_install_1.hubInstallCommand);
43
46
  return program;
44
47
  }
45
48
  if (require.main === module) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fraim-hub",
3
- "version": "2.0.221",
3
+ "version": "2.0.223",
4
4
  "description": "FRAIM Hub local companion package.",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -93,7 +93,7 @@
93
93
  "dotenv": "^16.4.7",
94
94
  "electron": "^41.2.2",
95
95
  "express": "^5.2.1",
96
- "fraim": "2.0.221",
96
+ "fraim": "2.0.223",
97
97
  "mongodb": "^7.0.0",
98
98
  "node-cron": "4.2.1",
99
99
  "node-edge-tts": "^1.2.10",
@@ -16,8 +16,8 @@
16
16
  // before company branding). script.js refines the per-theme accent on load.
17
17
  try{var b=JSON.parse(localStorage.getItem('fraim-org-brand')||'null');if(b){if(typeof b.color==='string'&&/^#[0-9a-f]{6}$/i.test(b.color)){h.style.setProperty('--accent',b.color);h.style.setProperty('--accent-strong',b.color);}if(typeof b.name==='string'&&b.name){document.title=b.name+' Hub';}}}catch(e){}
18
18
  })();</script>
19
- <link rel="stylesheet" href="./styles.css?v=conv-panels-20260611b">
20
- <link rel="stylesheet" href="./review.css">
19
+ <link rel="stylesheet" href="./styles.css?v=dark-status-20260721">
20
+ <link rel="stylesheet" href="./review.css?v=dark-status-20260721">
21
21
  </head>
22
22
  <body>
23
23
 
@@ -1037,6 +1037,6 @@
1037
1037
  </div>
1038
1038
  </div>
1039
1039
 
1040
- <script src="./script.js?v=persona-mgr-del-20260707"></script>
1040
+ <script src="./script.js?v=dark-status-20260721"></script>
1041
1041
  </body>
1042
1042
  </html>
@@ -589,8 +589,8 @@ function normalizeGeminiConversationMessages(conv) {
589
589
  // registry and writes them to the conversation store on every run update
590
590
  // (persistRunConversation), so the client must NOT re-upload them: a large run's
591
591
  // messages/events/artifacts push the PUT body past the server's size limit, the
592
- // request 413s, the error is swallowed, and the client-owned flags in the SAME
593
- // payload (e.g. reviewApproved from "Mark complete") never persist. Strip them
592
+ // request 413s, the error is swallowed, and the client-owned fields in the SAME
593
+ // payload (e.g. pauseReason='done' from "Mark complete") never persist. Strip them
594
594
  // here; the server's PUT handler merges them back from the stored record.
595
595
  const SERVER_OWNED_CONV_FIELDS = ['messages', 'events', 'artifacts', 'run', 'delegation'];
596
596
  const CLIENT_ONLY_CONV_FIELDS = ['_bodyLoaded', '_stopping'];
@@ -1798,11 +1798,11 @@ function statusLabel(s) {
1798
1798
  }
1799
1799
 
1800
1800
  function conversationUiState(conv) {
1801
- if (!conv) return 'idle';
1802
- if (conv.blocked) return 'blocked';
1803
- if (conv.status === 'running') return 'working';
1804
- // Issue #904: read pauseReason first for non-running records so the pill
1805
- // reflects the exit classification rather than mapping all completed -> 'waiting'.
1801
+ if (!conv) return 'idle';
1802
+ if (conv.blocked) return 'blocked';
1803
+ if (conv.status === 'running') return 'working';
1804
+ // Issue #904: read pauseReason first for non-running records so the pill
1805
+ // reflects the exit classification rather than mapping all completed -> 'waiting'.
1806
1806
  if (conv.pauseReason === 'working') return 'working';
1807
1807
  if (conv.pauseReason === 'done') return 'complete';
1808
1808
  if (conv.pauseReason === 'error') return 'error';
@@ -1815,10 +1815,13 @@ function conversationUiState(conv) {
1815
1815
  // Guard on status==='failed' only: a completed run should resolve to complete/waiting,
1816
1816
  // not stay stuck as stopped even if the stopped flag was not cleared.
1817
1817
  if (conv.stopped && conv.status === 'failed') return 'stopped';
1818
- if (conv.status === 'failed') return 'waiting';
1819
- if (isManagedDelegationChild(conv) && conv.status === 'completed' && conv.managedReviewStatus === 'reviewed') return 'complete';
1820
- if (conv.status === 'completed' && conv.reviewApproved) return 'complete';
1821
- if (conv.status === 'completed') {
1818
+ if (conv.status === 'failed') return 'waiting';
1819
+ if (isManagedDelegationChild(conv) && conv.status === 'completed' && conv.managedReviewStatus === 'reviewed') return 'complete';
1820
+ // Legacy records written before pauseReason='done' used reviewApproved=true as
1821
+ // the only persisted manual-completion marker. Preserve those as done, but do
1822
+ // not let reviewApproved override an explicit awaiting_* pauseReason.
1823
+ if (conv.status === 'completed' && conv.reviewApproved && conv.pauseReason === undefined) return 'complete';
1824
+ if (conv.status === 'completed') {
1822
1825
  // For fully-delegate runs: if child workstreams are still active, the manager
1823
1826
  // is waiting for them — show 'working' not 'waiting' so the user isn't prompted
1824
1827
  // to act when the Hub will auto-route child deliverables back to Mandy.
@@ -2552,18 +2555,13 @@ function renderConversationIdentity(conv) {
2552
2555
  host.appendChild(text);
2553
2556
  }
2554
2557
 
2555
- function renderRunStatePill(conv) {
2556
- const pill = els['run-state-pill'];
2557
- if (!pill) return;
2558
- if (conv.reviewApproved && conv.status === 'completed') {
2559
- pill.textContent = 'DONE';
2560
- pill.className = 'run-state-pill complete';
2561
- } else {
2562
- // #549 R3: conversationUiState now returns 'stopped' for manager-stopped runs.
2563
- // conversationStateLabel returns 'Stopped' for that state; toUpperCase() => 'STOPPED'.
2564
- pill.textContent = conversationStateLabel(conv).toUpperCase();
2565
- pill.className = `run-state-pill ${conversationUiState(conv)}`;
2566
- }
2558
+ function renderRunStatePill(conv) {
2559
+ const pill = els['run-state-pill'];
2560
+ if (!pill) return;
2561
+ // #549 R3: conversationUiState now returns 'stopped' for manager-stopped runs.
2562
+ // conversationStateLabel returns 'Stopped' for that state; toUpperCase() => 'STOPPED'.
2563
+ pill.textContent = conversationStateLabel(conv).toUpperCase();
2564
+ pill.className = `run-state-pill ${conversationUiState(conv)}`;
2567
2565
  // #521: Stop is offered only while the employee is actively working.
2568
2566
  const stopBtn = els['run-stop-btn'];
2569
2567
  if (stopBtn) {
@@ -3828,12 +3826,14 @@ function clearPendingCoachingJob() {
3828
3826
  // Awaiting-review signal: the read-side run model does not yet emit a structured
3829
3827
  // "awaiting-manager" event, so we degrade gracefully and drive it from the
3830
3828
  // terminal run status the read-side already returns (`completed`). A run that
3831
- // the manager has approved (conv.reviewApproved) is no longer awaiting.
3829
+ // the manager has approved (conv.reviewApproved) or manually completed
3830
+ // (pauseReason='done') is no longer awaiting.
3832
3831
  // ---------------------------------------------------------------------------
3833
3832
 
3834
3833
  function convAwaitingReview(conv) {
3835
- if (!conv) return false;
3836
- if (conv.reviewApproved) return false;
3834
+ if (!conv) return false;
3835
+ if (conv.pauseReason === 'done') return false;
3836
+ if (conv.reviewApproved) return false;
3837
3837
  // #770 R2/R4: never ask for a decision while the run is actively working a
3838
3838
  // (possibly later, no-decision) phase. conv.status is maintained by
3839
3839
  // foldRunIntoConversation. While 'running' the manager is coaching, not
@@ -4942,10 +4942,11 @@ async function approveReview(options) {
4942
4942
  showStatus(opts.reviewAction ? 'Approval command sent.' : 'Approval sent.', false);
4943
4943
  await continueRun(approvalMessage, { preserveReviewApproved: true });
4944
4944
  return;
4945
- }
4946
- conv.status = 'completed';
4947
- conv.reviewApproved = true;
4948
- upsertConversation(conv);
4945
+ }
4946
+ conv.status = 'completed';
4947
+ conv.reviewApproved = false;
4948
+ conv.pauseReason = 'done';
4949
+ upsertConversation(conv);
4949
4950
  refreshStatusSurfaces();
4950
4951
  renderActive();
4951
4952
  syncSendButton();