fraim-hub 2.0.269 → 2.0.271

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.
@@ -14,6 +14,7 @@ exports.getAiHubCategories = getAiHubCategories;
14
14
  const fs_1 = __importDefault(require("fs"));
15
15
  const path_1 = __importDefault(require("path"));
16
16
  const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
17
+ const resolve_phase_edge_1 = require("../core/resolve-phase-edge");
17
18
  // Directories scanned for employee jobs at runtime, in lowest-to-highest
18
19
  // precedence order. Later entries win on {categoryId, jobId} collision.
19
20
  //
@@ -411,21 +412,6 @@ function findJobStubPath(projectPath, jobId) {
411
412
  }
412
413
  return null;
413
414
  }
414
- // Resolve a phase's `onSuccess` edge to the next phase id given the run's
415
- // discriminant. Returns null when the edge is absent or terminal.
416
- function nextPhase(edge, discriminant) {
417
- if (edge == null)
418
- return null;
419
- if (typeof edge === 'string')
420
- return edge;
421
- if (typeof edge === 'object') {
422
- if (typeof edge[discriminant] === 'string')
423
- return edge[discriminant];
424
- if (typeof edge.default === 'string')
425
- return edge.default;
426
- }
427
- return null;
428
- }
429
415
  // Parse the ordered phase list from a job stub's ## Steps section.
430
416
  // Real FRAIM job stubs use Markdown steps rather than JSON frontmatter;
431
417
  // this is the fallback parser that makes the pizza tracker work for them.
@@ -460,7 +446,7 @@ function loadJobPhases(jobId, projectPath, discriminant = 'feature') {
460
446
  const phaseDef = fm.phases[cursor];
461
447
  if (!phaseDef)
462
448
  break;
463
- cursor = nextPhase(phaseDef.onSuccess, discriminant);
449
+ cursor = (0, resolve_phase_edge_1.resolvePhaseEdge)(fm.phases[cursor]?.onSuccess, discriminant);
464
450
  }
465
451
  const labels = fm.phaseLabels || {};
466
452
  return ordered.map((id) => ({ id, label: friendlyPhaseLabel(id, labels[id]) }));
@@ -482,7 +468,7 @@ function resolveJobPhaseTransition(jobId, projectPath, phaseId, outcome, discrim
482
468
  if (!phaseDef)
483
469
  return null;
484
470
  const edge = outcome === 'complete' ? phaseDef.onSuccess : phaseDef.onFailure;
485
- return nextPhase(edge, discriminant);
471
+ return (0, resolve_phase_edge_1.resolvePhaseEdge)(edge, discriminant);
486
472
  }
487
473
  function loadAllJobPhaseIds(jobId, projectPath) {
488
474
  const stubPath = findJobStubPath(projectPath, jobId);
@@ -53,15 +53,8 @@ const hub_launch_decision_1 = require("./hub-launch-decision");
53
53
  const hub_runtime_file_1 = require("./hub-runtime-file");
54
54
  const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
55
55
  const version_utils_1 = require("../cli/utils/version-utils");
56
- function resolveElectronBinary() {
57
- try {
58
- // eslint-disable-next-line @typescript-eslint/no-var-requires
59
- return require('electron');
60
- }
61
- catch {
62
- return null;
63
- }
64
- }
56
+ const electron_dist_1 = require("./electron-dist");
57
+ const process_liveness_1 = require("./process-liveness");
65
58
  function resolveDesktopEntry() {
66
59
  const candidates = [
67
60
  path_1.default.resolve(__dirname, 'desktop-main.js'),
@@ -78,10 +71,20 @@ function resolveDesktopEntry() {
78
71
  }
79
72
  return null;
80
73
  }
81
- function openDesktopWindow(projectPath, preferredPort, runtimeId) {
82
- const electronBinary = resolveElectronBinary();
74
+ // #1112: the Electron binary is resolved (and, on a first launch for a version, extracted) before
75
+ // the spawn, not inside waitForDesktopHubReady. A one-time extraction is minutes of work on a slow
76
+ // disk; charging it to the readiness budget is exactly the mistake #1110 fixed, so it happens here
77
+ // with progress on stdout while nothing is being waited on.
78
+ async function openDesktopWindow(projectPath, preferredPort, runtimeId) {
83
79
  const desktopEntry = resolveDesktopEntry();
84
- if (!electronBinary || !desktopEntry) {
80
+ if (!desktopEntry) {
81
+ return null;
82
+ }
83
+ const electron = await (0, electron_dist_1.resolveHubElectronBinary)({
84
+ onProgress: (message) => console.log(message),
85
+ onError: (error) => console.log(`Could not prepare the shared Electron runtime (${error.message}). Opening the FRAIM Hub in your browser instead.`),
86
+ });
87
+ if (!electron) {
85
88
  return null;
86
89
  }
87
90
  const args = projectPath
@@ -90,7 +93,7 @@ function openDesktopWindow(projectPath, preferredPort, runtimeId) {
90
93
  if (runtimeId && runtimeId !== 'hub') {
91
94
  args.push('--hub-runtime-id', runtimeId);
92
95
  }
93
- const child = (0, child_process_1.spawn)(electronBinary, args, {
96
+ const child = (0, child_process_1.spawn)(electron.binaryPath, args, {
94
97
  detached: true,
95
98
  stdio: 'ignore',
96
99
  });
@@ -110,15 +113,6 @@ function openBrowser(url) {
110
113
  const child = (0, child_process_1.spawn)('xdg-open', [url], { detached: true, stdio: 'ignore' });
111
114
  child.unref();
112
115
  }
113
- function isProcessAlive(pid) {
114
- try {
115
- process.kill(pid, 0);
116
- return true;
117
- }
118
- catch (e) {
119
- return !!(e && e.code === 'EPERM');
120
- }
121
- }
122
116
  function killPid(pid) {
123
117
  try {
124
118
  if (process.platform === 'win32') {
@@ -307,7 +301,7 @@ async function waitForDesktopHubReady(child, preferredPort, options = {}) {
307
301
  async function reconcileRunningHub(flags, runtimeId = 'hub') {
308
302
  const running = (0, hub_runtime_file_1.readHubRuntimeFile)((0, project_fraim_paths_1.getUserFraimDirPath)(), runtimeId);
309
303
  const confirmedVersion = running ? await fetchRunningHubVersion(running.port) : null;
310
- const live = !!(running && confirmedVersion && isProcessAlive(running.pid));
304
+ const live = !!(running && confirmedVersion && (0, process_liveness_1.isPidAlive)(running.pid));
311
305
  const effective = live && running ? { ...running, version: confirmedVersion } : null;
312
306
  const decision = (0, hub_launch_decision_1.decideHubLaunch)({
313
307
  running: effective,
@@ -340,7 +334,7 @@ async function runHub(options) {
340
334
  if (wantDesktop) {
341
335
  await reconcileRunningHub({ restart: !!options.restart, keepRunning: !!options.keepRunning }, runtimeId);
342
336
  }
343
- const desktopChild = wantDesktop ? openDesktopWindow(projectPath, preferredPort, runtimeId) : null;
337
+ const desktopChild = wantDesktop ? await openDesktopWindow(projectPath, preferredPort, runtimeId) : null;
344
338
  if (!desktopChild) {
345
339
  const port = await findAvailablePort(preferredPort);
346
340
  const server = new AiHubServer(projectPath ? { projectPath } : {});
@@ -7,22 +7,13 @@ exports.isLockStale = isLockStale;
7
7
  exports.withBucketLock = withBucketLock;
8
8
  const fs_1 = __importDefault(require("fs"));
9
9
  const os_1 = __importDefault(require("os"));
10
+ const process_liveness_1 = require("./process-liveness");
10
11
  const DEFAULT_TIMEOUT_MS = 5000;
11
12
  const DEFAULT_STALE_MS = 10000;
12
13
  // Synchronous sleep without a busy spin (the store's write path is synchronous).
13
14
  function sleepMs(ms) {
14
15
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
15
16
  }
16
- function pidAlive(pid) {
17
- try {
18
- process.kill(pid, 0);
19
- return true;
20
- }
21
- catch (error) {
22
- // ESRCH: no such process. EPERM: exists but not ours (still alive).
23
- return error.code === 'EPERM';
24
- }
25
- }
26
17
  // A lock is stale (safe to steal) if its file is unreadable/absent, its timestamp is older than
27
18
  // staleMs, or it names a dead pid on this same host.
28
19
  function isLockStale(lockPath, staleMs = DEFAULT_STALE_MS) {
@@ -35,7 +26,7 @@ function isLockStale(lockPath, staleMs = DEFAULT_STALE_MS) {
35
26
  }
36
27
  if (typeof info.ts === 'number' && Date.now() - info.ts > staleMs)
37
28
  return true;
38
- if (typeof info.pid === 'number' && info.host === os_1.default.hostname() && !pidAlive(info.pid))
29
+ if (typeof info.pid === 'number' && info.host === os_1.default.hostname() && !(0, process_liveness_1.isPidAlive)(info.pid))
39
30
  return true;
40
31
  return false;
41
32
  }
@@ -26,7 +26,6 @@ const HEADER_OMITTED_FIELDS = [
26
26
  'events',
27
27
  'artifacts',
28
28
  'run',
29
- 'delegation',
30
29
  'handoffSummary',
31
30
  '_bodyLoaded',
32
31
  '_stopping',
@@ -181,13 +180,34 @@ function placeConversationInBucket(bucket, conv) {
181
180
  const existingScore = conversationRichness(existing);
182
181
  const incomingScore = conversationRichness(conv);
183
182
  if (incomingScore > existingScore) {
184
- bucket.conversations[idx] = conv;
183
+ bucket.conversations[idx] = withStableConversationCreatedAt(existing, conv);
185
184
  }
186
185
  else if (incomingScore === existingScore
187
186
  && timestampValue(value?.lastUpdatedAt) > timestampValue(existing.lastUpdatedAt)) {
188
- bucket.conversations[idx] = conv;
187
+ bucket.conversations[idx] = withStableConversationCreatedAt(existing, conv);
189
188
  }
190
189
  }
190
+ function stableConversationCreatedAt(existing, incoming) {
191
+ const existingValue = existing?.createdAt;
192
+ const incomingValue = incoming?.createdAt;
193
+ const existingTs = timestampValue(existingValue);
194
+ const incomingTs = timestampValue(incomingValue);
195
+ if (existingTs > 0 && incomingTs > 0)
196
+ return existingTs <= incomingTs ? existingValue : incomingValue;
197
+ if (existingTs > 0)
198
+ return existingValue;
199
+ if (incomingTs > 0)
200
+ return incomingValue;
201
+ return existingValue ?? incomingValue;
202
+ }
203
+ function withStableConversationCreatedAt(existing, incoming) {
204
+ if (!incoming || typeof incoming !== 'object')
205
+ return incoming;
206
+ const createdAt = stableConversationCreatedAt(existing, incoming);
207
+ if (createdAt === undefined)
208
+ return incoming;
209
+ return { ...incoming, createdAt };
210
+ }
191
211
  // Relocate any project-scoped record mis-filed under the wrong project bucket back to its own
192
212
  // project (see docs/rca/hub-conversation-cross-project-leak.md). Sentinel buckets are left as-is.
193
213
  function migrateProjectBuckets(store) {
@@ -303,14 +323,45 @@ function newestFirst(a, b) {
303
323
  }
304
324
  function toHeader(conv) {
305
325
  const header = { ...conv };
326
+ const delegation = compactDelegationForHeader(header.delegation);
306
327
  for (const field of HEADER_OMITTED_FIELDS)
307
328
  delete header[field];
329
+ if (delegation)
330
+ header.delegation = delegation;
308
331
  for (const field of Object.keys(header)) {
309
332
  if (field.startsWith('_'))
310
333
  delete header[field];
311
334
  }
312
335
  return header;
313
336
  }
337
+ function compactDelegationForHeader(raw) {
338
+ if (!raw || typeof raw !== 'object')
339
+ return undefined;
340
+ const delegation = raw;
341
+ const tasks = Array.isArray(delegation.tasks) ? delegation.tasks : [];
342
+ if (!tasks.length)
343
+ return undefined;
344
+ return {
345
+ delegationRequired: delegation.delegationRequired === true,
346
+ objective: delegation.objective,
347
+ orchestratorPersonaKey: delegation.orchestratorPersonaKey,
348
+ rootRunId: delegation.rootRunId,
349
+ managerRunId: delegation.managerRunId,
350
+ tasks: tasks.map((task) => {
351
+ const value = task && typeof task === 'object' ? task : {};
352
+ return {
353
+ taskId: value.taskId,
354
+ title: value.title,
355
+ status: value.status,
356
+ personaKey: value.personaKey,
357
+ jobId: value.jobId,
358
+ runId: value.runId,
359
+ conversationId: value.conversationId,
360
+ dependsOn: Array.isArray(value.dependsOn) ? value.dependsOn : [],
361
+ };
362
+ }),
363
+ };
364
+ }
314
365
  function headerNeedsSanitization(header) {
315
366
  const value = header;
316
367
  return Object.keys(value).some((field) => HEADER_OMITTED_FIELD_SET.has(field) || field.startsWith('_'));
@@ -345,6 +396,17 @@ class AiHubConversationStore {
345
396
  // Sibling directory of the legacy file, e.g. ~/.fraim/ai-hub-conversations/
346
397
  this.shardRoot = path_1.default.join(dir, base);
347
398
  }
399
+ /**
400
+ * Root directory holding the per-bucket shards.
401
+ *
402
+ * Issue #1164: exposed read-only so callers that walk the shard layout, such as
403
+ * the stale-bucket sweep, ask the store where its data lives instead of
404
+ * re-deriving the path. Two independent derivations of the same layout drift the
405
+ * moment one changes, and a sweep pointed at the wrong directory fails silently.
406
+ */
407
+ get shardRootPath() {
408
+ return this.shardRoot;
409
+ }
348
410
  // ---- path helpers ----
349
411
  bucketDir(bucketKey) {
350
412
  const canonical = bucketKey === exports.MANAGER_SCOPE_KEY || bucketKey === exports.COMPANY_SCOPE_KEY
@@ -732,6 +794,7 @@ class AiHubConversationStore {
732
794
  id: existing.id,
733
795
  projectPath: key,
734
796
  agentName: patch.agentName || existing.agentName,
797
+ createdAt: stableConversationCreatedAt(existing, patch) ?? existing.createdAt,
735
798
  lastUpdatedAt: patch.lastUpdatedAt ?? new Date().toISOString(),
736
799
  }) ?? existing;
737
800
  this.writeConvFile(bucketDir, key, merged);
@@ -0,0 +1,265 @@
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.HUB_ELECTRON_VERSION = void 0;
7
+ exports.electronExecutableRelativePath = electronExecutableRelativePath;
8
+ exports.resolveSharedElectronDistDir = resolveSharedElectronDistDir;
9
+ exports.resolveSharedElectronBinaryPath = resolveSharedElectronBinaryPath;
10
+ exports.isElectronDistComplete = isElectronDistComplete;
11
+ exports.ensureSharedElectronDist = ensureSharedElectronDist;
12
+ exports.resolveHubElectronBinary = resolveHubElectronBinary;
13
+ // #1112: one shared, versioned Electron dist instead of a private 345 MB copy per npx install.
14
+ //
15
+ // `electron` used to be a hard dependency of packages/fraim-hub. npm runs its postinstall in every
16
+ // tree it installs into, and that postinstall extracts the ~345 MB platform zip. Because
17
+ // `npx fraim-hub@latest` creates a fresh `_npx/<hash>/` tree for every published version, the same
18
+ // dist was unpacked again on every upgrade: 27 copies and 9.1 GB on one developer machine, and a
19
+ // first-execution cost on freshly written binaries that was the largest remaining term in the Hub
20
+ // cold start measured in #1110.
21
+ //
22
+ // The extraction cannot be skipped at launch time. electron/install.js short-circuits only on
23
+ // ELECTRON_SKIP_BINARY_DOWNLOAD, read from the environment of the npm process, and by the time
24
+ // src/ai-hub/cli.ts runs npm has already unpacked. So `electron` is no longer declared at all, and
25
+ // the Hub resolves the binary itself from ~/.fraim/bin/electron/<version>/ — extracted once per
26
+ // Electron version per machine, shared by every installed and future fraim-hub version.
27
+ //
28
+ // The zip still comes from the same shared cache npm used (~/AppData/Local/electron/Cache on
29
+ // Windows, ~/Library/Caches/electron on macOS, ~/.cache/electron on Linux), because this reuses
30
+ // @electron/get and extract-zip — the two modules electron/install.js itself uses. That keeps
31
+ // checksum validation, mirror and proxy environment variables, and symlink- and mode-preserving
32
+ // extraction behaving exactly as they do today.
33
+ const fs_1 = __importDefault(require("fs"));
34
+ const path_1 = __importDefault(require("path"));
35
+ const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
36
+ const process_liveness_1 = require("./process-liveness");
37
+ /**
38
+ * The exact Electron release the Hub runs on, and the runtime source of truth for it.
39
+ *
40
+ * @electron/get needs an exact version: a range cannot name a download. Two other declarations are
41
+ * pinned to this value and `scripts/validate-package-split.ts` fails the build if either drifts:
42
+ * - `packages/fraim-hub` `build.electronVersion`, which is how electron-builder learns the
43
+ * version now that `electron` is absent from that manifest.
44
+ * - the repo root's `dependencies.electron` range, which still installs Electron for local dev,
45
+ * `npm run hub:desktop`, and the Electron-launching test suites.
46
+ */
47
+ exports.HUB_ELECTRON_VERSION = '41.2.2';
48
+ /** Relative path of the Electron executable inside a dist, matching getPlatformPath() in electron/install.js. */
49
+ function electronExecutableRelativePath(platform = process.platform) {
50
+ switch (platform) {
51
+ case 'darwin':
52
+ return path_1.default.join('Electron.app', 'Contents', 'MacOS', 'Electron');
53
+ case 'win32':
54
+ return 'electron.exe';
55
+ default:
56
+ return 'electron';
57
+ }
58
+ }
59
+ // An Electron release is `x.y.z` or `x.y.z-<prerelease>`, and nothing else. Anchoring on that shape
60
+ // means a version string can never carry path syntax into the directory name below, which matters
61
+ // because `ensureSharedElectronDist` calls a recursive `fs.rmSync` on the resulting path: a version
62
+ // of `../../..` would delete an unrelated tree. Not reachable today - every caller resolves to the
63
+ // compile-time `HUB_ELECTRON_VERSION` - so this is a guard at the write boundary, kept next to the
64
+ // path construction it protects rather than left to every future caller to remember.
65
+ const EXACT_ELECTRON_VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
66
+ function assertPathSafeVersion(version) {
67
+ if (!EXACT_ELECTRON_VERSION.test(version)) {
68
+ throw new Error(`Refusing to use "${version}" as an Electron version: expected an exact release such as 41.2.2.`);
69
+ }
70
+ return version;
71
+ }
72
+ /**
73
+ * `~/.fraim/bin/electron/<version>/`.
74
+ *
75
+ * Deliberately a sibling of, not a child of, `resolveHubInstallDir()`
76
+ * (`~/.fraim/bin/fraim-hub-electron/`). That directory belongs to the installed Hub application:
77
+ * `runHubInstall` writes `fraim-hub.exe` and the downloaded release asset into it, and
78
+ * `installDownloadedAsset` does an `fs.rmSync` inside it on macOS. A shared runtime with a
79
+ * different lifecycle should not live inside a tree another code path deletes from.
80
+ */
81
+ function resolveSharedElectronDistDir(version, fraimDir = (0, project_fraim_paths_1.getUserFraimDirPath)()) {
82
+ return path_1.default.join(fraimDir, 'bin', 'electron', assertPathSafeVersion(version));
83
+ }
84
+ /** Absolute path of the Electron executable inside the shared dist for a version. */
85
+ function resolveSharedElectronBinaryPath(version, fraimDir = (0, project_fraim_paths_1.getUserFraimDirPath)(), platform = process.platform) {
86
+ return path_1.default.join(resolveSharedElectronDistDir(version, fraimDir), electronExecutableRelativePath(platform));
87
+ }
88
+ /**
89
+ * Whether a directory holds a complete dist for exactly this version.
90
+ *
91
+ * Same two conditions electron's own `isInstalled()` checks: the `version` file names this release,
92
+ * and the platform executable is present. A dist that fails either is stale or half-written, and
93
+ * spawning from it would fail at launch instead of here.
94
+ */
95
+ function isElectronDistComplete(distDir, version, platform = process.platform) {
96
+ try {
97
+ const recorded = fs_1.default.readFileSync(path_1.default.join(distDir, 'version'), 'utf8').trim().replace(/^v/, '');
98
+ if (recorded !== version)
99
+ return false;
100
+ return fs_1.default.existsSync(path_1.default.join(distDir, electronExecutableRelativePath(platform)));
101
+ }
102
+ catch {
103
+ return false;
104
+ }
105
+ }
106
+ /**
107
+ * Resolve the platform zip through @electron/get, so a zip npm already cached for this version is a
108
+ * cache hit and no bytes cross the network. Checksums stay enabled: @electron/get validates the
109
+ * artifact against SHASUMS256.txt, fetching that 2 KB file when no local checksums.json is
110
+ * available. That is the only integrity check standing between a 345 MB download and executing it.
111
+ */
112
+ const downloadElectronZip = async ({ version, platform, arch }) => {
113
+ // Required lazily: @electron/get pulls in got, fs-extra, and sumchecker, and none of that belongs
114
+ // in the module graph of a launch that resolves an already-extracted dist.
115
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
116
+ const { downloadArtifact } = require('@electron/get');
117
+ return downloadArtifact({ version, artifactName: 'electron', platform, arch });
118
+ };
119
+ const extractElectronZip = async (zipPath, destDir) => {
120
+ // extract-zip is what electron/install.js uses, and it preserves the mode bits and the symlinks
121
+ // inside Electron.app/Contents/Frameworks that a naive zip reader drops.
122
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
123
+ const extract = require('extract-zip');
124
+ await extract(zipPath, { dir: destDir });
125
+ };
126
+ /**
127
+ * Remove staging trees whose owning process is gone.
128
+ *
129
+ * The `finally` that cleans up a staging tree does not run when the process is killed, and the
130
+ * window it covers is a visible "Preparing the shared Electron runtime" message that invites a
131
+ * Ctrl+C. Without this sweep, one impatient interrupt strands ~345 MB under `~/.fraim` forever,
132
+ * which is the same disk leak this change exists to stop. A staging tree whose pid is still alive
133
+ * belongs to a concurrent launch and is left alone.
134
+ */
135
+ function sweepDeadStagingDirs(distDir) {
136
+ const parent = path_1.default.dirname(distDir);
137
+ const prefix = `${path_1.default.basename(distDir)}.staging-`;
138
+ let entries;
139
+ try {
140
+ entries = fs_1.default.readdirSync(parent, { withFileTypes: true });
141
+ }
142
+ catch {
143
+ return;
144
+ }
145
+ for (const entry of entries) {
146
+ if (!entry.isDirectory() || !entry.name.startsWith(prefix))
147
+ continue;
148
+ const pid = Number(entry.name.slice(prefix.length));
149
+ if (Number.isInteger(pid) && pid > 0 && (0, process_liveness_1.isPidAlive)(pid))
150
+ continue;
151
+ try {
152
+ fs_1.default.rmSync(path_1.default.join(parent, entry.name), { recursive: true, force: true });
153
+ }
154
+ catch {
155
+ /* best effort: a tree we cannot delete is not a reason to fail a launch */
156
+ }
157
+ }
158
+ }
159
+ /**
160
+ * Return the Electron executable in the shared dist for this version, extracting it first if it is
161
+ * not already there. Returns immediately when a complete dist exists, which is the case on every
162
+ * launch after the first for a given Electron version.
163
+ *
164
+ * Extraction goes to a staging sibling and is promoted with a rename, matching the
165
+ * `${dir}.staging-${pid}` pattern in `org-pack-sync.ts` and `manager-pack-sync.ts`. A failure
166
+ * therefore leaves no partial tree at the versioned path, so the next launch cannot mistake one for
167
+ * a usable dist.
168
+ */
169
+ async function ensureSharedElectronDist(options = {}) {
170
+ const version = options.version ?? exports.HUB_ELECTRON_VERSION;
171
+ const fraimDir = options.fraimDir ?? (0, project_fraim_paths_1.getUserFraimDirPath)();
172
+ const platform = options.platform ?? process.platform;
173
+ const arch = options.arch ?? process.arch;
174
+ const distDir = resolveSharedElectronDistDir(version, fraimDir);
175
+ const binaryPath = resolveSharedElectronBinaryPath(version, fraimDir, platform);
176
+ if (isElectronDistComplete(distDir, version, platform)) {
177
+ return binaryPath;
178
+ }
179
+ const downloadZip = options.downloadZip ?? downloadElectronZip;
180
+ const extractZip = options.extractZip ?? extractElectronZip;
181
+ const onProgress = options.onProgress;
182
+ onProgress?.(`Preparing the shared Electron ${version} runtime in ${distDir}. `
183
+ + 'This happens once per Electron version on this machine; later Hub launches reuse it.');
184
+ const zipPath = await downloadZip({ version, platform, arch });
185
+ await extractAndPromote({ zipPath, distDir, version, platform, extractZip });
186
+ onProgress?.(`Shared Electron ${version} runtime ready.`);
187
+ return binaryPath;
188
+ }
189
+ /**
190
+ * Unpack a zip into `distDir`, atomically.
191
+ *
192
+ * Extraction goes to a `${distDir}.staging-${pid}` sibling and is promoted with a rename, matching
193
+ * the pattern in `org-pack-sync.ts` and `manager-pack-sync.ts`. A failure therefore leaves no
194
+ * partial tree at the versioned path, so the next launch cannot mistake one for a usable dist.
195
+ */
196
+ async function extractAndPromote(args) {
197
+ const { zipPath, distDir, version, platform, extractZip } = args;
198
+ const stagingDir = `${distDir}.staging-${process.pid}`;
199
+ fs_1.default.mkdirSync(path_1.default.dirname(distDir), { recursive: true });
200
+ sweepDeadStagingDirs(distDir);
201
+ fs_1.default.rmSync(stagingDir, { recursive: true, force: true });
202
+ try {
203
+ await extractZip(zipPath, stagingDir);
204
+ if (!isElectronDistComplete(stagingDir, version, platform)) {
205
+ throw new Error(`Extracted Electron ${version} is missing its version file or ${electronExecutableRelativePath(platform)}; `
206
+ + `refusing to install an incomplete dist from ${zipPath}.`);
207
+ }
208
+ // A concurrent launch may have finished extracting the same version while this one worked.
209
+ // Promoting over a complete dist would delete the tree that launch is spawning from, so the
210
+ // winner keeps it and this one discards its own copy.
211
+ if (!isElectronDistComplete(distDir, version, platform)) {
212
+ fs_1.default.rmSync(distDir, { recursive: true, force: true });
213
+ try {
214
+ fs_1.default.renameSync(stagingDir, distDir);
215
+ }
216
+ catch (error) {
217
+ // The same race, one step later: the target can appear between that check and this rename.
218
+ // A complete dist at the target is the outcome this call wanted, so it is success.
219
+ if (!isElectronDistComplete(distDir, version, platform))
220
+ throw error;
221
+ }
222
+ }
223
+ }
224
+ finally {
225
+ fs_1.default.rmSync(stagingDir, { recursive: true, force: true });
226
+ }
227
+ }
228
+ /** Resolve the `electron` npm package's own binary, or null when it is absent or points nowhere. */
229
+ function resolveInstalledElectronBinary(requireElectron) {
230
+ try {
231
+ const binaryPath = requireElectron();
232
+ return typeof binaryPath === 'string' && fs_1.default.existsSync(binaryPath) ? binaryPath : null;
233
+ }
234
+ catch {
235
+ return null;
236
+ }
237
+ }
238
+ /**
239
+ * The Electron binary this Hub should launch, or null when there is none.
240
+ *
241
+ * An installed `electron` package wins when one is present. That is this repo, and any consumer who
242
+ * depends on Electron themselves: its dist is already on disk, so preferring it avoids a needless
243
+ * download and keeps `npm run hub:desktop` and the Electron-launching suites on the exact path they
244
+ * use today. The published `fraim-hub` package declares no `electron`, so it takes the shared dist.
245
+ *
246
+ * Null is a supported outcome, not a failure: `openDesktopWindow()` returns null in that case and
247
+ * `runHub` starts the in-process server and opens a browser. An offline first launch must reach that
248
+ * fallback rather than throwing, so a download failure is reported through `onError` and swallowed.
249
+ */
250
+ async function resolveHubElectronBinary(options = {}) {
251
+ const requireElectron = options.requireElectron
252
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
253
+ ?? (() => require('electron'));
254
+ const installed = resolveInstalledElectronBinary(requireElectron);
255
+ if (installed) {
256
+ return { binaryPath: installed, source: 'installed-package' };
257
+ }
258
+ try {
259
+ return { binaryPath: await ensureSharedElectronDist(options), source: 'shared-dist' };
260
+ }
261
+ catch (error) {
262
+ options.onError?.(error instanceof Error ? error : new Error(String(error)));
263
+ return null;
264
+ }
265
+ }
@@ -381,7 +381,19 @@ function extractSignalFromArgs(args) {
381
381
  : 'starting';
382
382
  const findings = args.findings;
383
383
  const findingsText = findings && typeof findings.summary === 'string' ? findings.summary : undefined;
384
- const discriminant = typeof args.runDiscriminant === 'string' ? args.runDiscriminant : undefined;
384
+ // Issue #1135: `runDiscriminant` is not a field of the seekMentoring tool schema
385
+ // and no agent sends it, so before issue #1123 this was populated only by the
386
+ // scripted test double and production always resolved with the literal default
387
+ // 'feature'. The real discriminant is the one the mentor routes on
388
+ // (`findings.phaseOutcome`), so read that first and keep the legacy field as a
389
+ // fallback for the test double.
390
+ const evidenceArgs = args.evidence;
391
+ const discriminantFromFindings = findings && typeof findings.phaseOutcome === 'string' ? findings.phaseOutcome
392
+ : findings && typeof findings.issueType === 'string' ? findings.issueType
393
+ : evidenceArgs && typeof evidenceArgs.issueType === 'string' ? evidenceArgs.issueType
394
+ : undefined;
395
+ const discriminant = discriminantFromFindings
396
+ ?? (typeof args.runDiscriminant === 'string' ? args.runDiscriminant : undefined);
385
397
  const jobName = typeof args.jobName === 'string' ? args.jobName : undefined;
386
398
  const jobId = typeof args.jobId === 'string' ? args.jobId : undefined;
387
399
  const issueNumber = typeof args.issueNumber === 'string' ? args.issueNumber
@@ -1787,6 +1799,7 @@ class CliHostRuntime {
1787
1799
  exports.CliHostRuntime = CliHostRuntime;
1788
1800
  class FakeHostRuntime {
1789
1801
  constructor() {
1802
+ this.isTestDouble = true;
1790
1803
  this.employees = [
1791
1804
  { id: 'codex', label: 'Codex', available: true, detail: 'Test double employee.', supportsRaw: true },
1792
1805
  { id: 'claude', label: 'Claude Code', available: true, detail: 'Test double employee.', supportsRaw: true },
@@ -1878,6 +1891,7 @@ exports.FakeHostRuntime = FakeHostRuntime;
1878
1891
  // FakeHostRuntime (smaller surface, no seekMentoring).
1879
1892
  class ScriptedHostRuntime {
1880
1893
  constructor() {
1894
+ this.isTestDouble = true;
1881
1895
  this.employees = [
1882
1896
  { id: 'codex', label: 'Codex', available: true, detail: 'Scripted test double.', supportsRaw: true },
1883
1897
  { id: 'claude', label: 'Claude Code', available: true, detail: 'Scripted test double.', supportsRaw: true },
@@ -0,0 +1,25 @@
1
+ "use strict";
2
+ // Is a pid on this host still running? A leaf with no dependencies, so any Hub module can use it
3
+ // without pulling in a graph or creating an import cycle.
4
+ //
5
+ // #1112 consolidated three byte-identical copies of this: `isProcessAlive` in `cli.ts` (which
6
+ // decides whether a recorded Hub instance is live), `pidAlive` in `conversation-store-lock.ts`
7
+ // (which decides whether a lock is stealable), and a third that the shared-Electron-dist sweep
8
+ // would otherwise have added.
9
+ Object.defineProperty(exports, "__esModule", { value: true });
10
+ exports.isPidAlive = isPidAlive;
11
+ /**
12
+ * `process.kill(pid, 0)` sends no signal; it only checks that the process exists and is signalable.
13
+ * `ESRCH` means no such process. `EPERM` means it exists but belongs to another user, which still
14
+ * counts as alive — treating it as dead is what would let a caller steal a live lock or delete a
15
+ * live process's working tree.
16
+ */
17
+ function isPidAlive(pid) {
18
+ try {
19
+ process.kill(pid, 0);
20
+ return true;
21
+ }
22
+ catch (error) {
23
+ return error.code === 'EPERM';
24
+ }
25
+ }