fraim-hub 2.0.310 → 2.0.312

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.
@@ -0,0 +1,278 @@
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.HubMainDiagnostics = void 0;
7
+ exports.getHubMainDiagnostics = getHubMainDiagnostics;
8
+ exports.resetHubMainDiagnosticsForTest = resetHubMainDiagnosticsForTest;
9
+ exports.recordHubMainDiagnostic = recordHubMainDiagnostic;
10
+ exports.getHubMainDiagnosticsSnapshot = getHubMainDiagnosticsSnapshot;
11
+ exports.installHubMainConsoleTee = installHubMainConsoleTee;
12
+ const node_fs_1 = __importDefault(require("node:fs"));
13
+ const node_os_1 = __importDefault(require("node:os"));
14
+ const node_path_1 = __importDefault(require("node:path"));
15
+ const node_util_1 = __importDefault(require("node:util"));
16
+ const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
17
+ const version_utils_1 = require("../cli/utils/version-utils");
18
+ const DEFAULT_MAX_EVENTS = 100;
19
+ const DEFAULT_MAX_CURRENT_BYTES = 1024 * 1024;
20
+ const DEFAULT_MAX_RETAINED_CHUNKS = 20;
21
+ const DEFAULT_MAX_RETAINED_BYTES = 50 * 1024 * 1024;
22
+ const MAX_STRING_LENGTH = 1000;
23
+ const SENSITIVE_KEY_PATTERN = /token|secret|password|api[-_]?key|credential|authorization/i;
24
+ const EMAIL_PATTERN = /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi;
25
+ const SENSITIVE_TEXT_PATTERNS = [
26
+ /\bsk-ant-[A-Za-z0-9_-]{20,}\b/g,
27
+ /\bsk-[A-Za-z0-9]{48,}\b/g,
28
+ /\bgh[pousr]_[A-Za-z0-9]{36,}\b/g,
29
+ /\bAKIA[0-9A-Z]{16}\b/g,
30
+ /\bSG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}\b/g,
31
+ /\bsk_live_[A-Za-z0-9]{24,}\b/g,
32
+ /(?:authorization|api[-_]?key|token|secret|password|credential)(["'\s:=]+)([^"',\s]{8,})/gi,
33
+ ];
34
+ function resolveDefaultLogDir() {
35
+ try {
36
+ return node_path_1.default.join((0, project_fraim_paths_1.getUserFraimDirPath)(), 'logs');
37
+ }
38
+ catch {
39
+ return node_path_1.default.join(node_os_1.default.homedir(), '.fraim', 'logs');
40
+ }
41
+ }
42
+ function redactSensitiveText(value) {
43
+ let redacted = value.replace(EMAIL_PATTERN, '[redacted-email]');
44
+ for (const pattern of SENSITIVE_TEXT_PATTERNS) {
45
+ redacted = redacted.replace(pattern, (match, separator) => {
46
+ if (typeof separator === 'string' && match.includes(separator)) {
47
+ return match.replace(new RegExp(`${separator.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}.*$`), `${separator}[redacted]`);
48
+ }
49
+ return '[redacted-secret]';
50
+ });
51
+ }
52
+ return redacted;
53
+ }
54
+ function boundedString(value, max = MAX_STRING_LENGTH) {
55
+ const redacted = redactSensitiveText(value);
56
+ return redacted.length > max ? `${redacted.slice(0, max)}...[truncated ${redacted.length - max} chars]` : redacted;
57
+ }
58
+ function safeFileSegment(value) {
59
+ const sanitized = value.replace(/[^a-z0-9._-]+/gi, '-').replace(/^-+|-+$/g, '');
60
+ return sanitized || 'hub';
61
+ }
62
+ function sanitizeDetail(value, depth = 0) {
63
+ if (value == null || typeof value === 'boolean' || typeof value === 'number')
64
+ return value;
65
+ if (typeof value === 'string')
66
+ return boundedString(value);
67
+ if (value instanceof Error)
68
+ return { name: value.name, message: boundedString(value.message) };
69
+ if (depth >= 4)
70
+ return '[truncated-depth]';
71
+ if (Array.isArray(value))
72
+ return value.slice(0, 20).map((item) => sanitizeDetail(item, depth + 1));
73
+ if (typeof value === 'object') {
74
+ const out = {};
75
+ for (const [key, item] of Object.entries(value).slice(0, 40)) {
76
+ out[key] = SENSITIVE_KEY_PATTERN.test(key) ? '[redacted]' : sanitizeDetail(item, depth + 1);
77
+ }
78
+ return out;
79
+ }
80
+ return boundedString(String(value));
81
+ }
82
+ function toDetails(details) {
83
+ if (!details)
84
+ return undefined;
85
+ const sanitized = sanitizeDetail(details);
86
+ return sanitized && typeof sanitized === 'object' && !Array.isArray(sanitized)
87
+ ? sanitized
88
+ : { value: sanitized };
89
+ }
90
+ function safeStat(filePath) {
91
+ try {
92
+ return node_fs_1.default.statSync(filePath);
93
+ }
94
+ catch {
95
+ return null;
96
+ }
97
+ }
98
+ function buildVersions() {
99
+ return {
100
+ node: process.versions.node,
101
+ ...(process.versions.electron ? { electron: process.versions.electron } : {}),
102
+ ...(process.versions.chrome ? { chrome: process.versions.chrome } : {}),
103
+ };
104
+ }
105
+ class HubMainDiagnostics {
106
+ constructor(options = {}) {
107
+ this.events = [];
108
+ this.rotationSequence = 0;
109
+ this.lastWriteError = null;
110
+ this.lastRenderProcessGone = null;
111
+ this.lastChildProcessGone = null;
112
+ this.lastUnresponsiveAt = null;
113
+ this.lastResponsiveAt = null;
114
+ this.runtimeId = options.runtimeId || process.env.FRAIM_HUB_RUNTIME_ID || 'hub';
115
+ this.now = options.now || (() => new Date());
116
+ this.maxEvents = options.maxEvents || DEFAULT_MAX_EVENTS;
117
+ this.maxCurrentBytes = options.maxCurrentBytes || DEFAULT_MAX_CURRENT_BYTES;
118
+ this.maxRetainedChunks = options.maxRetainedChunks || DEFAULT_MAX_RETAINED_CHUNKS;
119
+ this.maxRetainedBytes = options.maxRetainedBytes || DEFAULT_MAX_RETAINED_BYTES;
120
+ this.logDir = node_path_1.default.resolve(options.logDir || resolveDefaultLogDir());
121
+ this.logPath = node_path_1.default.join(this.logDir, 'hub-main.log');
122
+ }
123
+ record(type, details) {
124
+ const event = {
125
+ type,
126
+ timestamp: this.now().toISOString(),
127
+ runtimeId: this.runtimeId,
128
+ pid: process.pid,
129
+ appVersion: (0, version_utils_1.getFraimVersion)(),
130
+ ...(details ? { details: toDetails(details) } : {}),
131
+ };
132
+ this.events.push(event);
133
+ if (this.events.length > this.maxEvents)
134
+ this.events.splice(0, this.events.length - this.maxEvents);
135
+ if (type === 'webcontents.render_process_gone')
136
+ this.lastRenderProcessGone = event;
137
+ if (type === 'app.child_process_gone')
138
+ this.lastChildProcessGone = event;
139
+ if (type === 'webcontents.unresponsive')
140
+ this.lastUnresponsiveAt = event.timestamp;
141
+ if (type === 'webcontents.responsive')
142
+ this.lastResponsiveAt = event.timestamp;
143
+ this.persist(event);
144
+ return event;
145
+ }
146
+ snapshot(window = null) {
147
+ const current = safeStat(this.logPath);
148
+ return {
149
+ ok: true,
150
+ process: {
151
+ pid: process.pid,
152
+ platform: process.platform,
153
+ versions: buildVersions(),
154
+ uptimeSeconds: Math.round(process.uptime()),
155
+ },
156
+ log: {
157
+ path: this.logPath,
158
+ directory: this.logDir,
159
+ exists: Boolean(current),
160
+ bytes: current?.size || 0,
161
+ maxCurrentBytes: this.maxCurrentBytes,
162
+ retainedChunks: this.retainedChunks(),
163
+ lastWriteError: this.lastWriteError,
164
+ },
165
+ window,
166
+ lastEvents: [...this.events],
167
+ lastRenderProcessGone: this.lastRenderProcessGone,
168
+ lastChildProcessGone: this.lastChildProcessGone,
169
+ lastUnresponsiveAt: this.lastUnresponsiveAt,
170
+ lastResponsiveAt: this.lastResponsiveAt,
171
+ capturedAt: this.now().toISOString(),
172
+ };
173
+ }
174
+ persist(event) {
175
+ try {
176
+ node_fs_1.default.mkdirSync(this.logDir, { recursive: true });
177
+ const line = `${JSON.stringify(event)}\n`;
178
+ const current = safeStat(this.logPath);
179
+ if (current && current.size > 0 && current.size + Buffer.byteLength(line, 'utf8') > this.maxCurrentBytes) {
180
+ this.rotateCurrentLog();
181
+ }
182
+ node_fs_1.default.appendFileSync(this.logPath, line, 'utf8');
183
+ this.lastWriteError = null;
184
+ }
185
+ catch (error) {
186
+ this.lastWriteError = error instanceof Error ? boundedString(error.message, 500) : boundedString(String(error), 500);
187
+ }
188
+ }
189
+ rotateCurrentLog() {
190
+ if (!node_fs_1.default.existsSync(this.logPath))
191
+ return;
192
+ const timestamp = this.now().toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z');
193
+ const chunkPath = node_path_1.default.join(this.logDir, `hub-main-${timestamp}-${safeFileSegment(this.runtimeId)}-${String(this.rotationSequence).padStart(4, '0')}.log`);
194
+ this.rotationSequence += 1;
195
+ node_fs_1.default.renameSync(this.logPath, chunkPath);
196
+ this.pruneRetainedChunks();
197
+ }
198
+ retainedChunks() {
199
+ try {
200
+ return node_fs_1.default.readdirSync(this.logDir)
201
+ .filter((name) => /^hub-main-.+\.log$/.test(name))
202
+ .map((name) => {
203
+ const filePath = node_path_1.default.join(this.logDir, name);
204
+ const stat = node_fs_1.default.statSync(filePath);
205
+ return { path: filePath, bytes: stat.size, modifiedAt: stat.mtime.toISOString() };
206
+ })
207
+ .sort((a, b) => b.modifiedAt.localeCompare(a.modifiedAt));
208
+ }
209
+ catch {
210
+ return [];
211
+ }
212
+ }
213
+ pruneRetainedChunks() {
214
+ const chunks = this.retainedChunks();
215
+ let retainedBytes = 0;
216
+ chunks.forEach((chunk, index) => {
217
+ retainedBytes += chunk.bytes;
218
+ if (index < this.maxRetainedChunks && retainedBytes <= this.maxRetainedBytes)
219
+ return;
220
+ try {
221
+ node_fs_1.default.unlinkSync(chunk.path);
222
+ }
223
+ catch {
224
+ // Best effort pruning; endpoint still reports remaining chunks.
225
+ }
226
+ });
227
+ }
228
+ }
229
+ exports.HubMainDiagnostics = HubMainDiagnostics;
230
+ let singleton = null;
231
+ let consoleTeeInstalled = false;
232
+ let inConsoleTee = false;
233
+ function getHubMainDiagnostics() {
234
+ if (!singleton)
235
+ singleton = new HubMainDiagnostics();
236
+ return singleton;
237
+ }
238
+ function resetHubMainDiagnosticsForTest(options = {}) {
239
+ singleton = new HubMainDiagnostics(options);
240
+ return singleton;
241
+ }
242
+ function recordHubMainDiagnostic(type, details) {
243
+ return getHubMainDiagnostics().record(type, details);
244
+ }
245
+ function getHubMainDiagnosticsSnapshot(window = null) {
246
+ return getHubMainDiagnostics().snapshot(window);
247
+ }
248
+ function installHubMainConsoleTee() {
249
+ if (consoleTeeInstalled)
250
+ return;
251
+ consoleTeeInstalled = true;
252
+ const originals = {
253
+ log: console.log.bind(console),
254
+ warn: console.warn.bind(console),
255
+ error: console.error.bind(console),
256
+ };
257
+ const wrap = (level, type) => {
258
+ return (...args) => {
259
+ originals[level](...args);
260
+ if (inConsoleTee)
261
+ return;
262
+ inConsoleTee = true;
263
+ try {
264
+ getHubMainDiagnostics().record(type, { message: node_util_1.default.format(...args) });
265
+ }
266
+ catch (error) {
267
+ originals.warn('[fraim] hub main diagnostic console tee failed:', error);
268
+ }
269
+ finally {
270
+ inConsoleTee = false;
271
+ }
272
+ };
273
+ };
274
+ console.log = wrap('log', 'log.info');
275
+ console.warn = wrap('warn', 'log.warn');
276
+ console.error = wrap('error', 'log.error');
277
+ getHubMainDiagnostics().record('hub.log_tee_installed');
278
+ }
@@ -67,6 +67,17 @@ class RestartRecoveryPolicy {
67
67
  if (['stopped', 'done', 'awaiting_review', 'awaiting_user'].includes(pauseReason)) {
68
68
  return { action: 'skip', reason: `pause_${pauseReason}` };
69
69
  }
70
+ // Issue #1634: `activeRunExists` is direct, in-process proof this run is not
71
+ // orphaned — it must outrank every "does this LOOK orphaned" heuristic below.
72
+ // A freshly-fired scheduled/webhook run legitimately has no sessionId yet (the
73
+ // host CLI hasn't reported one) for its entire lifetime if it is short-lived;
74
+ // checking `missing_session` first force-failed a run that was actively
75
+ // executing in this same process, the instant its status became observably
76
+ // 'running' (issue #1634's own fix for that visibility gap is what turned this
77
+ // from a narrow, mostly-unreachable window into a routinely-hit one).
78
+ if (options.activeRunExists) {
79
+ return { action: 'defer', reason: 'active_run_exists' };
80
+ }
70
81
  if (!conversation.sessionId || typeof conversation.sessionId !== 'string' || !conversation.sessionId.trim()) {
71
82
  return { action: 'skip', reason: 'missing_session' };
72
83
  }
@@ -81,9 +92,6 @@ class RestartRecoveryPolicy {
81
92
  if (conversation.reviewHandoff?.reviewRequired) {
82
93
  return { action: 'skip', reason: 'awaiting_review' };
83
94
  }
84
- if (options.activeRunExists) {
85
- return { action: 'defer', reason: 'active_run_exists' };
86
- }
87
95
  // Issue #1159: `activeRunExists` only sees this process's run registry, so it
88
96
  // cannot tell that a *different* live Hub owns this run. Two Hubs on one
89
97
  // machine is the normal case here: the desktop Hub plus any Hub a job starts
@@ -69,6 +69,7 @@ const manager_turns_1 = require("./manager-turns");
69
69
  const preferences_1 = require("./preferences");
70
70
  const conversation_store_1 = require("./conversation-store");
71
71
  const raw_event_log_store_1 = require("./raw-event-log-store");
72
+ const hub_main_diagnostics_1 = require("./hub-main-diagnostics");
72
73
  const run_working_directory_1 = require("./run-working-directory");
73
74
  const conversation_search_1 = require("./conversation-search");
74
75
  const conversation_search_index_1 = require("./conversation-search-index");
@@ -3005,6 +3006,7 @@ class AiHubServer {
3005
3006
  userDataDir: process.env.FRAIM_BROWSER_USER_DATA_DIR || undefined,
3006
3007
  explicitPath: process.env.FRAIM_BROWSER_PATH || undefined,
3007
3008
  });
3009
+ this.hubMainDiagnostics = options.hubMainDiagnostics || { snapshot: () => (0, hub_main_diagnostics_1.getHubMainDiagnosticsSnapshot)(null) };
3008
3010
  this.hostRuntime = options.hostRuntime || (process.env.FRAIM_AI_HUB_FAKE_HOST === '1' ? new hosts_1.FakeHostRuntime() : new hosts_1.CliHostRuntime());
3009
3011
  // Issue #701 / #749: the AI Hub is a loopback companion that runs on user machines and
3010
3012
  // never touches a database. Persona and manager-team state resolve from the hosted server
@@ -5008,6 +5010,9 @@ class AiHubServer {
5008
5010
  'from the Hub job catalog, not the per-run tracking UUID returned by get_fraim_job\'s "Job ID" field.',
5009
5011
  };
5010
5012
  }
5013
+ isAdhocPromptJobId(jobId) {
5014
+ return typeof jobId === 'string' && jobId.trim() === 'adhoc-prompt';
5015
+ }
5011
5016
  applySeekMentoringSignalToRun(run, signal) {
5012
5017
  // Issue #732: promote using the stable jobName slug, not the per-call UUID
5013
5018
  // jobId (resolveHubJob would never match a UUID, leaving a freeform run
@@ -6122,6 +6127,17 @@ class AiHubServer {
6122
6127
  this.app.get('/api/ai-hub/pid', (_req, res) => {
6123
6128
  return res.json({ pid: process.pid });
6124
6129
  });
6130
+ this.app.get('/api/ai-hub/debug/window-state', (_req, res) => {
6131
+ try {
6132
+ (0, hub_main_diagnostics_1.recordHubMainDiagnostic)('debug.window_state_requested');
6133
+ return res.json(this.hubMainDiagnostics.snapshot());
6134
+ }
6135
+ catch (error) {
6136
+ const message = error instanceof Error ? error.message : String(error);
6137
+ (0, hub_main_diagnostics_1.recordHubMainDiagnostic)('debug.window_state_failed', { error: message });
6138
+ return res.status(500).json({ error: message });
6139
+ }
6140
+ });
6125
6141
  // Issue #1415/#1379: the update badge's click handler POSTs here instead of just telling the
6126
6142
  // user to quit and relaunch by hand. `restartToLatest` (the Electron shell's
6127
6143
  // restartToLatestNow) responds before it actually tears the process down, so the client can
@@ -6485,10 +6501,25 @@ class AiHubServer {
6485
6501
  return res.status(404).json({ error: 'Configured agent not found or cannot be deleted.' });
6486
6502
  return res.json({ ok: true });
6487
6503
  });
6488
- this.app.post('/api/ai-hub/configured-agents/:id/check', (req, res) => {
6504
+ this.app.post('/api/ai-hub/configured-agents/:id/check', async (req, res) => {
6489
6505
  if (!this.requireTrustedHubOrigin(req, res))
6490
6506
  return;
6491
- const employees = this.hostRuntime.detectEmployees();
6507
+ // Issue #1618: this is the manager's explicit "check now" action, so it must reflect
6508
+ // reality immediately rather than serving the up-to-5-minute-stale detection cache -
6509
+ // the same reasoning installAgentAndRefreshDetection() already applies after install
6510
+ // (see the comment above that function, server.ts:~2430).
6511
+ (0, hosts_1.invalidateEmployeeDetectionCache)();
6512
+ // Security review (implement-security-review, issue #1618): prefer the non-blocking
6513
+ // parallel probe here for the same reason bootstrapResponse() does (server.ts:~3939) -
6514
+ // invalidating the cache on every click means this route now pays the full re-probe
6515
+ // cost every time instead of at most once per TTL window, so the synchronous form
6516
+ // would let a manager repeatedly clicking Check block the single-threaded Hub event
6517
+ // loop for ~1-3s per click, reintroducing the exact issue-#1010 unresponsiveness this
6518
+ // route previously avoided only by accident (via the cache). Falls back to the sync
6519
+ // form for HostRuntime stubs that do not implement the async variant.
6520
+ const employees = this.hostRuntime.detectEmployeesAsync
6521
+ ? await this.hostRuntime.detectEmployeesAsync()
6522
+ : this.hostRuntime.detectEmployees();
6492
6523
  const agent = this.configuredAgentsForCurrentMachine(employees).find((entry) => entry.id === req.params.id);
6493
6524
  if (!agent)
6494
6525
  return res.status(404).json({ error: 'Configured agent not found.' });
@@ -7496,9 +7527,15 @@ class AiHubServer {
7496
7527
  // Issue #1477 R1/R2: reject a jobId that doesn't resolve to a known catalog entry
7497
7528
  // (e.g. the per-run tracking UUID from get_fraim_job, mistaken for this field in
7498
7529
  // the reported repro) before the deployment is ever persisted.
7499
- if (!this.resolveHubJob(resolvedProjectPath, jobId)) {
7530
+ const isAdhocPrompt = this.isAdhocPromptJobId(jobId);
7531
+ if (!isAdhocPrompt && !this.resolveHubJob(resolvedProjectPath, jobId)) {
7500
7532
  return res.status(400).json(this.invalidScheduleJobIdError(jobId));
7501
7533
  }
7534
+ // Issue #1610 R3: adhoc-prompt has no fixed task — the instructions field is its
7535
+ // sole driver. An adhoc-prompt schedule with no instructions would silently do nothing.
7536
+ if (isAdhocPrompt && !(typeof instructions === 'string' && instructions.trim())) {
7537
+ return res.status(400).json({ error: 'Ad-hoc assignments require a non-empty instructions field.' });
7538
+ }
7502
7539
  const normalizedConversationId = typeof conversationId === 'string' && conversationId.trim()
7503
7540
  ? conversationId.trim()
7504
7541
  : undefined;
@@ -7598,9 +7635,19 @@ class AiHubServer {
7598
7635
  return res.status(404).json({ error: 'Deployment not found.' });
7599
7636
  // Issue #1477 R3: same catalog validation as create, applied only when jobId is
7600
7637
  // actually part of this update.
7601
- if (jobId !== undefined && !this.resolveHubJob(resolvedProjectPath !== undefined ? resolvedProjectPath : existing.projectPath, jobId)) {
7638
+ if (jobId !== undefined && !this.isAdhocPromptJobId(jobId) && !this.resolveHubJob(resolvedProjectPath !== undefined ? resolvedProjectPath : existing.projectPath, jobId)) {
7602
7639
  return res.status(400).json(this.invalidScheduleJobIdError(jobId));
7603
7640
  }
7641
+ // Issue #1610 R4: guard the same invariant as POST — an adhoc-prompt deployment
7642
+ // must always have non-empty instructions. Compute the effective post-update values
7643
+ // before writing so we can reject before any state change.
7644
+ const effectiveJobId = jobId !== undefined ? jobId : existing.jobId;
7645
+ const effectiveInstructions = instructions !== undefined
7646
+ ? (typeof instructions === 'string' ? instructions.trim() : undefined)
7647
+ : existing.instructions;
7648
+ if (effectiveJobId === 'adhoc-prompt' && !effectiveInstructions) {
7649
+ return res.status(400).json({ error: 'Ad-hoc assignments require a non-empty instructions field.' });
7650
+ }
7604
7651
  const nextHostId = hostId !== undefined && validHosts.includes(hostId) ? hostId : existing.hostId;
7605
7652
  const nextConfiguredAgentId = configuredAgentId !== undefined
7606
7653
  ? (typeof configuredAgentId === 'string' && configuredAgentId.trim() ? configuredAgentId.trim() : undefined)
@@ -8354,6 +8401,13 @@ class AiHubServer {
8354
8401
  // can call runRegistry.update without "Run not found" throws.
8355
8402
  this.runRegistry.create(run, {});
8356
8403
  this.deploymentStore.update(deployment.id, (d) => { d.activeRunId = run.id; });
8404
+ // Issue #1634 (Defect B): persist status: 'running' synchronously, before the
8405
+ // host process is launched. Without this, the persisted conversation is only
8406
+ // ever written on the first stream event (onEvent) or at exit (onExit) — a
8407
+ // job fast enough to finish before its first stream event lands can go
8408
+ // straight from one 'completed' state to the next, with 'running' never
8409
+ // observably written for any poll to find.
8410
+ this.persistRunConversation(run, run.conversationId || run.id);
8357
8411
  const handlers = {
8358
8412
  onEvent: (event, channel) => {
8359
8413
  this.runRegistry.update(run.id, (current) => {
@@ -8376,19 +8430,17 @@ class AiHubServer {
8376
8430
  if (updated)
8377
8431
  this.scheduleRunConversationPersistence(updated, updated.conversationId || updated.id);
8378
8432
  },
8379
- onExit: (exitCode) => {
8380
- this.runRegistry.update(run.id, (r) => {
8381
- r.exitCode = exitCode;
8382
- r.status = exitCode === 0 ? 'completed' : 'failed';
8383
- r.events.push((0, hosts_1.createHubEvent)('system', `Run exited with code ${exitCode ?? 'unknown'}.${hostErrorSuffix(r)}`));
8384
- pushTerminalFailureToThread(r, exitCode);
8385
- });
8386
- const updated = this.runRegistry.get(run.id);
8387
- if (updated)
8388
- this.persistRunConversation(updated, updated.conversationId || updated.id);
8433
+ // Issue #1634 (Defect A): route through the shared handleRunExit()/classifyExit()
8434
+ // chokepoint (issue #904), matching every other run-exit call site in this file
8435
+ // (server.ts:4678, 4992, 7396, 7632, 7726, 9315 as of this change). The previous
8436
+ // bespoke handler below set status/exitCode directly and never called
8437
+ // classifyExit(), so a deployment-triggered exit's pauseReason was never computed
8438
+ // — the recurringPark latch set mid-run (via the shared onEvent -> recordHostEvent
8439
+ // path above) was silently discarded, and a compaction/background-task exit was
8440
+ // force-terminated instead of auto-continuing like a manager-started run.
8441
+ onExit: (exitCode) => this.handleRunExit(run.id, exitCode, () => {
8389
8442
  this.deploymentStore.update(deployment.id, (d) => { d.activeRunId = undefined; });
8390
- this.runRegistry.dispose(run.id);
8391
- },
8443
+ }),
8392
8444
  };
8393
8445
  const childLaunchContext = this.withHubRunIdEnv(launchContext, run.id);
8394
8446
  const child = existingHostSession
@@ -72,7 +72,7 @@ ${buildDeferredToolBootstrapSection(profile)}1. **Confirm FRAIM activation**:
72
72
  If local FRAIM job stubs are present in the workspace, inspect those first and match the request locally. Also inspect \`fraim/personalized-employee/jobs/\` for local overrides or repo-specific jobs. If local files are missing or you cannot inspect workspace files, call \`list_fraim_jobs()\` to view the full catalog, including any proxy-discoverable personalized jobs.
73
73
 
74
74
  3. **Find the match**:
75
- If the user names an exact FRAIM job, call \`get_fraim_job({ job: "<job-name>" })\` directly. Otherwise, match the user's request to a FRAIM job from the local stub catalog, \`fraim/personalized-employee/jobs/\`, or the full \`list_fraim_jobs()\` response. If no exact or high-confidence job match exists, say that no FRAIM job matches and continue with normal tools or ask one concise clarification. Do not pick the nearest catalog job.
75
+ If the user names an exact FRAIM job, call \`get_fraim_job({ job: "<job-name>" })\` directly. Otherwise, match the user's request to a FRAIM job from the local stub catalog, \`fraim/personalized-employee/jobs/\`, or the full \`list_fraim_jobs()\` response. If no exact or high-confidence job match exists, ask once: "No catalog job matches. Would you like to run this as an ad-hoc task?" On confirmation, call \`get_fraim_job({ job: "adhoc-prompt" })\` and execute it with the user's instructions as the task input do not pick the nearest catalog job. Do not ask again if the user already provided instructions.
76
76
 
77
77
  4. **Load the full content**:
78
78
  - For jobs, call \`get_fraim_job({ job: "<matched-job-name>" })\`.
@@ -125,6 +125,10 @@ function ensureUserLevelDependencies(userFraimDir) {
125
125
  if (missing.length === 0) {
126
126
  return;
127
127
  }
128
+ if (process.env.FRAIM_SKIP_USER_LEVEL_DEP_INSTALL === '1') {
129
+ console.log(chalk_1.default.yellow(`TEST_MODE: skipping user-level runtime dependency install (${missing.join(', ')}).`));
130
+ return;
131
+ }
128
132
  console.log(chalk_1.default.blue(`📦 Installing user-level runtime dependencies (${missing.join(', ')})...`));
129
133
  try {
130
134
  (0, child_process_1.execSync)('npm install --no-audit --no-fund --no-save --no-package-lock --omit=dev', {
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.PERSONA_CAPABILITY_BUNDLES = exports.FREE_JOBS = exports.GENERIC_WORKER_PERSONA_KEY = void 0;
3
+ exports.UNOWNED_EXEMPT_JOBS = exports.PERSONA_CAPABILITY_BUNDLES = exports.FREE_JOBS = exports.GENERIC_WORKER_PERSONA_KEY = void 0;
4
4
  exports.isFreeJob = isFreeJob;
5
5
  exports.getPersonaCapabilityBundle = getPersonaCapabilityBundle;
6
6
  exports.getProtectedPersonaForJob = getProtectedPersonaForJob;
@@ -315,6 +315,17 @@ const GENERIC_WORKER_OWNED_JOBS = new Set([
315
315
  'organization-onboarding',
316
316
  'organizational-learning-synthesis',
317
317
  ]);
318
+ // Jobs intentionally left without a named persona owner. They resolve to null
319
+ // from getProtectedPersonaForJob (runs ungated; Hub attributes them to the
320
+ // DEFAULT_UNASSIGNED_PERSONA_KEY/MANdy — same behavior as today's adhoc runs).
321
+ // validate-job-ownership exempts these from the "every job must have an owner"
322
+ // assertion so the validator still catches accidentally unowned jobs.
323
+ //
324
+ // Issue #1610: adhoc-prompt is a manager-directed fallback job, not tied to any
325
+ // specialist hire, matching the existing no-employee attribution of adhoc runs.
326
+ exports.UNOWNED_EXEMPT_JOBS = new Set([
327
+ 'adhoc-prompt',
328
+ ]);
318
329
  function getPersonaCapabilityBundle(personaKey) {
319
330
  return exports.PERSONA_CAPABILITY_BUNDLES[personaKey];
320
331
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fraim-hub",
3
- "version": "2.0.310",
3
+ "version": "2.0.312",
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.310",
214
+ "fraim": "2.0.312",
215
215
  "mongodb": "^7.0.0",
216
216
  "node-cron": "4.2.1",
217
217
  "node-edge-tts": "^1.2.10",
@@ -1145,7 +1145,7 @@
1145
1145
  <p class="dep-trig-note">Runs when an external system POSTs to the inbound URL, generated after you add the assignment.</p>
1146
1146
  </div>
1147
1147
  <div class="hm-field">
1148
- <label for="dep-instructions">Instructions <span class="dep-optional">(optional)</span></label>
1148
+ <label for="dep-instructions">Instructions <span class="dep-optional" id="dep-inst-optional-label">(optional)</span></label>
1149
1149
  <textarea id="dep-instructions" rows="2" placeholder="Optional message sent to the agent at the start of each run"></textarea>
1150
1150
  </div>
1151
1151
  <div class="dep-modal-actions">