fraim-hub 2.0.311 → 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.
- package/dist/src/ai-hub/hosts.js +71 -12
- package/dist/src/ai-hub/restart-recovery-policy.js +11 -3
- package/dist/src/ai-hub/server.js +38 -14
- package/dist/src/cli/setup/ide-invocation-surfaces.js +1 -1
- package/dist/src/cli/setup/user-level-sync.js +4 -0
- package/dist/src/config/persona-capability-bundles.js +12 -1
- package/package.json +2 -2
- package/public/ai-hub/index.html +1 -1
- package/public/ai-hub/script.js +244 -25
- package/public/ai-hub/styles.css +3 -0
package/dist/src/ai-hub/hosts.js
CHANGED
|
@@ -2501,15 +2501,13 @@ class CliHostRuntime {
|
|
|
2501
2501
|
return isEmployeeDetectionRefreshing();
|
|
2502
2502
|
}
|
|
2503
2503
|
startRun(hostId, projectPath, message, handlers, sessionId, launchContext) {
|
|
2504
|
-
|
|
2505
|
-
// stay outside the continuation queue entirely.
|
|
2506
|
-
return this.spawn(hostId, (0, configured_agents_1.decorateHostPlanWithConfiguredAgent)(buildStartPlan(hostId, message, sessionId), launchContext?.agent, launchContext?.env), projectPath, handlers);
|
|
2504
|
+
return this.spawnStartAndRegister(hostId, projectPath, handlers, sessionId, (0, configured_agents_1.decorateHostPlanWithConfiguredAgent)(buildStartPlan(hostId, message, sessionId), launchContext?.agent, launchContext?.env));
|
|
2507
2505
|
}
|
|
2508
2506
|
continueRun(hostId, projectPath, sessionId, message, handlers, launchContext, deliveryIntent) {
|
|
2509
2507
|
return this.guardedContinue(hostId, sessionId, { projectPath, message, handlers, launchContext, deliveryIntent });
|
|
2510
2508
|
}
|
|
2511
2509
|
startDirectRun(hostId, message, projectPath, handlers, sessionId, launchContext) {
|
|
2512
|
-
return this.
|
|
2510
|
+
return this.spawnStartAndRegister(hostId, projectPath, handlers, sessionId, (0, configured_agents_1.decorateHostPlanWithConfiguredAgent)(buildDirectStartPlan(hostId, message, sessionId), launchContext?.agent, launchContext?.env));
|
|
2513
2511
|
}
|
|
2514
2512
|
continueDirectRun(hostId, sessionId, message, projectPath, handlers, launchContext, deliveryIntent) {
|
|
2515
2513
|
// R10: continueDirectRun is protected by the same per-hostId::sessionId
|
|
@@ -2538,7 +2536,7 @@ class CliHostRuntime {
|
|
|
2538
2536
|
if (active.escalationTimer != null)
|
|
2539
2537
|
clearTimeout(active.escalationTimer);
|
|
2540
2538
|
active.pending.splice(0);
|
|
2541
|
-
this.
|
|
2539
|
+
this.clearActiveRun(active);
|
|
2542
2540
|
if (active.child.pid == null)
|
|
2543
2541
|
return false;
|
|
2544
2542
|
try {
|
|
@@ -2559,7 +2557,7 @@ class CliHostRuntime {
|
|
|
2559
2557
|
const key = `${hostId}::${sessionId}`;
|
|
2560
2558
|
const active = this.activeContinueRuns.get(key);
|
|
2561
2559
|
if (!active) {
|
|
2562
|
-
return this.spawnAndRegister(
|
|
2560
|
+
return this.spawnAndRegister(hostId, sessionId, entry);
|
|
2563
2561
|
}
|
|
2564
2562
|
// R3/R31: an ordinary follow-up queues behind existing work; a course
|
|
2565
2563
|
// correction (deliveryIntent 'stop') jumps to the front of the queue and
|
|
@@ -2579,11 +2577,49 @@ class CliHostRuntime {
|
|
|
2579
2577
|
// through `entry.handlers` once it is actually dequeued and spawned.
|
|
2580
2578
|
return active.child;
|
|
2581
2579
|
}
|
|
2582
|
-
|
|
2580
|
+
spawnStartAndRegister(hostId, projectPath, handlers, sessionId, plan) {
|
|
2581
|
+
const deferredSessionIds = [];
|
|
2582
|
+
let runEntry = null;
|
|
2583
|
+
const registerSessionId = (value) => {
|
|
2584
|
+
const discoveredSessionId = typeof value === 'string' ? value.trim() : '';
|
|
2585
|
+
if (!discoveredSessionId)
|
|
2586
|
+
return;
|
|
2587
|
+
if (!runEntry) {
|
|
2588
|
+
deferredSessionIds.push(discoveredSessionId);
|
|
2589
|
+
return;
|
|
2590
|
+
}
|
|
2591
|
+
this.registerActiveRun(hostId, discoveredSessionId, runEntry);
|
|
2592
|
+
};
|
|
2593
|
+
const wrappedHandlers = {
|
|
2594
|
+
...handlers,
|
|
2595
|
+
onEvent: (event, channel) => {
|
|
2596
|
+
registerSessionId(event.sessionId);
|
|
2597
|
+
handlers.onEvent(event, channel);
|
|
2598
|
+
},
|
|
2599
|
+
};
|
|
2600
|
+
const child = this.spawn(hostId, plan, projectPath, wrappedHandlers);
|
|
2601
|
+
runEntry = { child, pending: [], handlers: wrappedHandlers };
|
|
2602
|
+
if (sessionId)
|
|
2603
|
+
registerSessionId(sessionId);
|
|
2604
|
+
for (const discoveredSessionId of deferredSessionIds) {
|
|
2605
|
+
registerSessionId(discoveredSessionId);
|
|
2606
|
+
}
|
|
2607
|
+
child.once('close', () => {
|
|
2608
|
+
if (!runEntry)
|
|
2609
|
+
return;
|
|
2610
|
+
if (runEntry.escalationTimer != null) {
|
|
2611
|
+
clearTimeout(runEntry.escalationTimer);
|
|
2612
|
+
runEntry.escalationTimer = undefined;
|
|
2613
|
+
}
|
|
2614
|
+
this.dequeueNext(hostId, runEntry);
|
|
2615
|
+
});
|
|
2616
|
+
return child;
|
|
2617
|
+
}
|
|
2618
|
+
spawnAndRegister(hostId, sessionId, entry) {
|
|
2583
2619
|
const plan = (0, configured_agents_1.decorateHostPlanWithConfiguredAgent)(entry.direct ? buildDirectContinuePlan(hostId, sessionId, entry.message) : buildContinuePlan(hostId, sessionId, entry.message), entry.launchContext?.agent, entry.launchContext?.env);
|
|
2584
2620
|
const child = this.spawn(hostId, plan, entry.projectPath, entry.handlers);
|
|
2585
2621
|
const runEntry = { child, pending: [], handlers: entry.handlers };
|
|
2586
|
-
this.
|
|
2622
|
+
this.registerActiveRun(hostId, sessionId, runEntry);
|
|
2587
2623
|
child.once('close', () => {
|
|
2588
2624
|
// Issue #1570: a redirect kill that eventually takes (just slower than
|
|
2589
2625
|
// the escalation window) must not leave its retry timer dangling past
|
|
@@ -2592,10 +2628,30 @@ class CliHostRuntime {
|
|
|
2592
2628
|
clearTimeout(runEntry.escalationTimer);
|
|
2593
2629
|
runEntry.escalationTimer = undefined;
|
|
2594
2630
|
}
|
|
2595
|
-
this.dequeueNext(
|
|
2631
|
+
this.dequeueNext(hostId, runEntry);
|
|
2596
2632
|
});
|
|
2597
2633
|
return child;
|
|
2598
2634
|
}
|
|
2635
|
+
registerActiveRun(hostId, sessionId, runEntry) {
|
|
2636
|
+
const key = `${hostId}::${sessionId}`;
|
|
2637
|
+
const existing = this.activeContinueRuns.get(key);
|
|
2638
|
+
if (existing && existing !== runEntry)
|
|
2639
|
+
return;
|
|
2640
|
+
if (!runEntry.keys)
|
|
2641
|
+
runEntry.keys = new Set();
|
|
2642
|
+
runEntry.keys.add(key);
|
|
2643
|
+
runEntry.sessionId = sessionId;
|
|
2644
|
+
this.activeContinueRuns.set(key, runEntry);
|
|
2645
|
+
}
|
|
2646
|
+
clearActiveRun(runEntry) {
|
|
2647
|
+
const keys = runEntry.keys ? Array.from(runEntry.keys) : [];
|
|
2648
|
+
for (const key of keys) {
|
|
2649
|
+
if (this.activeContinueRuns.get(key) === runEntry) {
|
|
2650
|
+
this.activeContinueRuns.delete(key);
|
|
2651
|
+
}
|
|
2652
|
+
}
|
|
2653
|
+
runEntry.keys?.clear();
|
|
2654
|
+
}
|
|
2599
2655
|
// Issue #1570 (Defect 1): the redirect kill was previously fire-and-forget
|
|
2600
2656
|
// — `killTree` was called once, with no way to know whether it actually
|
|
2601
2657
|
// took, and no ceiling on how long the queued correction could sit behind
|
|
@@ -2660,11 +2716,14 @@ class CliHostRuntime {
|
|
|
2660
2716
|
// batch uses the LAST entry's projectPath/handlers/launchContext unless a
|
|
2661
2717
|
// 'stop' entry is present, in which case that correction's context wins —
|
|
2662
2718
|
// it is the manager's most recent, most urgent instruction.
|
|
2663
|
-
dequeueNext(
|
|
2664
|
-
|
|
2719
|
+
dequeueNext(hostId, runEntry) {
|
|
2720
|
+
const sessionId = runEntry.sessionId;
|
|
2721
|
+
this.clearActiveRun(runEntry);
|
|
2665
2722
|
const batch = runEntry.pending.splice(0);
|
|
2666
2723
|
if (!batch.length)
|
|
2667
2724
|
return;
|
|
2725
|
+
if (!sessionId)
|
|
2726
|
+
return;
|
|
2668
2727
|
const combinedMessage = batch.map((e) => e.message).join('\n\n');
|
|
2669
2728
|
const primary = batch.find((e) => e.deliveryIntent === 'stop') ?? batch[batch.length - 1];
|
|
2670
2729
|
try {
|
|
@@ -2674,7 +2733,7 @@ class CliHostRuntime {
|
|
|
2674
2733
|
// R19: a synchronous spawn failure must not leave the queue permanently
|
|
2675
2734
|
// stuck behind this key — clear it so the next continueRun call spawns
|
|
2676
2735
|
// fresh instead of queueing behind a dead entry.
|
|
2677
|
-
this.
|
|
2736
|
+
this.clearActiveRun(runEntry);
|
|
2678
2737
|
}
|
|
2679
2738
|
}
|
|
2680
2739
|
}
|
|
@@ -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
|
|
@@ -5010,6 +5010,9 @@ class AiHubServer {
|
|
|
5010
5010
|
'from the Hub job catalog, not the per-run tracking UUID returned by get_fraim_job\'s "Job ID" field.',
|
|
5011
5011
|
};
|
|
5012
5012
|
}
|
|
5013
|
+
isAdhocPromptJobId(jobId) {
|
|
5014
|
+
return typeof jobId === 'string' && jobId.trim() === 'adhoc-prompt';
|
|
5015
|
+
}
|
|
5013
5016
|
applySeekMentoringSignalToRun(run, signal) {
|
|
5014
5017
|
// Issue #732: promote using the stable jobName slug, not the per-call UUID
|
|
5015
5018
|
// jobId (resolveHubJob would never match a UUID, leaving a freeform run
|
|
@@ -7524,9 +7527,15 @@ class AiHubServer {
|
|
|
7524
7527
|
// Issue #1477 R1/R2: reject a jobId that doesn't resolve to a known catalog entry
|
|
7525
7528
|
// (e.g. the per-run tracking UUID from get_fraim_job, mistaken for this field in
|
|
7526
7529
|
// the reported repro) before the deployment is ever persisted.
|
|
7527
|
-
|
|
7530
|
+
const isAdhocPrompt = this.isAdhocPromptJobId(jobId);
|
|
7531
|
+
if (!isAdhocPrompt && !this.resolveHubJob(resolvedProjectPath, jobId)) {
|
|
7528
7532
|
return res.status(400).json(this.invalidScheduleJobIdError(jobId));
|
|
7529
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
|
+
}
|
|
7530
7539
|
const normalizedConversationId = typeof conversationId === 'string' && conversationId.trim()
|
|
7531
7540
|
? conversationId.trim()
|
|
7532
7541
|
: undefined;
|
|
@@ -7626,9 +7635,19 @@ class AiHubServer {
|
|
|
7626
7635
|
return res.status(404).json({ error: 'Deployment not found.' });
|
|
7627
7636
|
// Issue #1477 R3: same catalog validation as create, applied only when jobId is
|
|
7628
7637
|
// actually part of this update.
|
|
7629
|
-
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)) {
|
|
7630
7639
|
return res.status(400).json(this.invalidScheduleJobIdError(jobId));
|
|
7631
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
|
+
}
|
|
7632
7651
|
const nextHostId = hostId !== undefined && validHosts.includes(hostId) ? hostId : existing.hostId;
|
|
7633
7652
|
const nextConfiguredAgentId = configuredAgentId !== undefined
|
|
7634
7653
|
? (typeof configuredAgentId === 'string' && configuredAgentId.trim() ? configuredAgentId.trim() : undefined)
|
|
@@ -8382,6 +8401,13 @@ class AiHubServer {
|
|
|
8382
8401
|
// can call runRegistry.update without "Run not found" throws.
|
|
8383
8402
|
this.runRegistry.create(run, {});
|
|
8384
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);
|
|
8385
8411
|
const handlers = {
|
|
8386
8412
|
onEvent: (event, channel) => {
|
|
8387
8413
|
this.runRegistry.update(run.id, (current) => {
|
|
@@ -8404,19 +8430,17 @@ class AiHubServer {
|
|
|
8404
8430
|
if (updated)
|
|
8405
8431
|
this.scheduleRunConversationPersistence(updated, updated.conversationId || updated.id);
|
|
8406
8432
|
},
|
|
8407
|
-
|
|
8408
|
-
|
|
8409
|
-
|
|
8410
|
-
|
|
8411
|
-
|
|
8412
|
-
|
|
8413
|
-
|
|
8414
|
-
|
|
8415
|
-
|
|
8416
|
-
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, () => {
|
|
8417
8442
|
this.deploymentStore.update(deployment.id, (d) => { d.activeRunId = undefined; });
|
|
8418
|
-
|
|
8419
|
-
},
|
|
8443
|
+
}),
|
|
8420
8444
|
};
|
|
8421
8445
|
const childLaunchContext = this.withHubRunIdEnv(launchContext, run.id);
|
|
8422
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,
|
|
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.
|
|
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.
|
|
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",
|
package/public/ai-hub/index.html
CHANGED
|
@@ -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">
|
package/public/ai-hub/script.js
CHANGED
|
@@ -1234,7 +1234,17 @@ const SERVER_OWNED_CONV_FIELDS = ['messages', 'events', 'artifacts', 'run', 'del
|
|
|
1234
1234
|
// It is listed in CLIENT_ONLY_CONV_FIELDS so `cleanConversationHeader()` strips
|
|
1235
1235
|
// any inbound value. That is what preserves issue #913: a marker arriving over
|
|
1236
1236
|
// the wire is never trusted, only one this client set after a real fetch.
|
|
1237
|
-
|
|
1237
|
+
//
|
|
1238
|
+
// Issue #1634: `_bodyFetchedRunId`/`_bodyFetchedLastUpdatedAt` record the
|
|
1239
|
+
// runId/lastUpdatedAt that were true at the moment THIS CLIENT's currently-held
|
|
1240
|
+
// message/event content was last made current (a full body fetch, or a live-run
|
|
1241
|
+
// poll fold — see mergeConversationBody/foldRunIntoConversation). The 30s header
|
|
1242
|
+
// poll (bgRefreshConversations/hydrateProjectConversationHeaders) merges fresh
|
|
1243
|
+
// runId/lastUpdatedAt directly into the SAME conversation object findConversation()
|
|
1244
|
+
// returns — there is no separate header copy — so comparing those fields against
|
|
1245
|
+
// this fetch-time snapshot is what lets conversationBodyIsStale() detect that a
|
|
1246
|
+
// scheduled/webhook fire replaced the run out from under an already-open tab.
|
|
1247
|
+
const CLIENT_ONLY_CONV_FIELDS = ['_bodyLoaded', '_bodyFetched', '_bodyFetchedRunId', '_bodyFetchedLastUpdatedAt', '_stopping'];
|
|
1238
1248
|
function slimConversationForPersist(conv) {
|
|
1239
1249
|
if (!conv || typeof conv !== 'object') return conv;
|
|
1240
1250
|
const slim = { ...conv };
|
|
@@ -1310,9 +1320,30 @@ function mergeConversationBody(existing, body) {
|
|
|
1310
1320
|
// Issue #1090: the body request came back, so this conversation is hydrated
|
|
1311
1321
|
// regardless of which fields the response happened to contain.
|
|
1312
1322
|
merged._bodyFetched = true;
|
|
1323
|
+
// Issue #1634: stamp the runId/lastUpdatedAt this fetch reflects, so a later
|
|
1324
|
+
// header-poll-observed change can be detected as staleness (see
|
|
1325
|
+
// conversationBodyIsStale / CLIENT_ONLY_CONV_FIELDS comment above).
|
|
1326
|
+
merged._bodyFetchedRunId = body.runId;
|
|
1327
|
+
merged._bodyFetchedLastUpdatedAt = body.lastUpdatedAt;
|
|
1313
1328
|
return merged;
|
|
1314
1329
|
}
|
|
1315
1330
|
|
|
1331
|
+
// Issue #1634 (Defect C): `conversationHasBody()` only answers "was a body ever
|
|
1332
|
+
// fetched" — true forever after the first fetch (issue #1090), even once a
|
|
1333
|
+
// scheduled/webhook fire replaces the run underneath an already-open tab. This
|
|
1334
|
+
// answers the separate question "is the body we're holding still current",
|
|
1335
|
+
// by comparing the runId/lastUpdatedAt in effect when we last made the body
|
|
1336
|
+
// current (mergeConversationBody / foldRunIntoConversation) against the same
|
|
1337
|
+
// object's own runId/lastUpdatedAt fields, which the 30s header poll keeps
|
|
1338
|
+
// fresh in place (mergeConversationHeader merges into the SAME object
|
|
1339
|
+
// findConversation() returns — there is no separate header copy to compare
|
|
1340
|
+
// against).
|
|
1341
|
+
function conversationBodyIsStale(existing) {
|
|
1342
|
+
if (!existing || existing._bodyFetchedRunId === undefined) return false;
|
|
1343
|
+
if (existing.runId !== existing._bodyFetchedRunId) return true;
|
|
1344
|
+
return timestampMillis(existing.lastUpdatedAt) > timestampMillis(existing._bodyFetchedLastUpdatedAt);
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1316
1347
|
function mergeConversationListHeaders(existingList, headers) {
|
|
1317
1348
|
const existingById = new Map((existingList || []).map((conv) => [conv.id, conv]));
|
|
1318
1349
|
return (headers || []).map((header) => mergeConversationHeader(existingById.get(header.id), header));
|
|
@@ -1346,7 +1377,7 @@ function clearConversationBodyPendingFor(conv, fallbackProjectPath, id) {
|
|
|
1346
1377
|
async function hydrateConversationBody(projectPath, id, options = {}) {
|
|
1347
1378
|
if (!id) return null;
|
|
1348
1379
|
const existing = findConversation(id);
|
|
1349
|
-
if (!options.force && conversationHasBody(existing)) {
|
|
1380
|
+
if (!options.force && conversationHasBody(existing) && !conversationBodyIsStale(existing)) {
|
|
1350
1381
|
clearConversationBodyPendingFor(existing, projectPath, id);
|
|
1351
1382
|
return existing;
|
|
1352
1383
|
}
|
|
@@ -1361,6 +1392,24 @@ async function hydrateConversationBody(projectPath, id, options = {}) {
|
|
|
1361
1392
|
if (!body) return null;
|
|
1362
1393
|
normalizeGeminiConversationMessages(body);
|
|
1363
1394
|
const current = findConversation(body.id);
|
|
1395
|
+
// A body hydrate can be in flight while a live run poll or continue POST
|
|
1396
|
+
// folds a newer run snapshot into the same conversation. Do not let the
|
|
1397
|
+
// slower, older body response replace fresh messages/events with the
|
|
1398
|
+
// persisted copy it started from.
|
|
1399
|
+
const currentUpdatedAt = timestampMillis(current && current.lastUpdatedAt);
|
|
1400
|
+
const bodyUpdatedAt = timestampMillis(body.lastUpdatedAt);
|
|
1401
|
+
if (
|
|
1402
|
+
!options.force
|
|
1403
|
+
&& current
|
|
1404
|
+
&& current.runId === body.runId
|
|
1405
|
+
&& conversationHasBody(current)
|
|
1406
|
+
&& currentUpdatedAt
|
|
1407
|
+
&& bodyUpdatedAt
|
|
1408
|
+
&& currentUpdatedAt > bodyUpdatedAt
|
|
1409
|
+
) {
|
|
1410
|
+
clearConversationBodyPendingFor(current, projectPath, current.id);
|
|
1411
|
+
return current;
|
|
1412
|
+
}
|
|
1364
1413
|
const merged = mergeConversationBody(current, body);
|
|
1365
1414
|
const bucket = convBucketKey(merged);
|
|
1366
1415
|
const list = (state.conversations[bucket] || []).slice();
|
|
@@ -1398,7 +1447,15 @@ async function rehydrateConversationAfterRunNotFound(conv) {
|
|
|
1398
1447
|
|
|
1399
1448
|
function ensureActiveConversationBody() {
|
|
1400
1449
|
const conv = activeConversation();
|
|
1401
|
-
if (!conv
|
|
1450
|
+
if (!conv) return;
|
|
1451
|
+
// Issue #1634: this own early-return used to check only conversationHasBody(),
|
|
1452
|
+
// which short-circuits BEFORE hydrateConversationBody()'s own staleness check
|
|
1453
|
+
// ever runs — the exact call path the 30s background poll uses for an
|
|
1454
|
+
// already-open, never-switched-away-from conversation (bgRefreshConversations
|
|
1455
|
+
// -> ensureActiveConversationBody). Without this, a scheduled/webhook fire
|
|
1456
|
+
// against the tab a manager is actively looking at would never be picked up
|
|
1457
|
+
// by that poll at all, regardless of the hydrateConversationBody fix below.
|
|
1458
|
+
if (conversationHasBody(conv) && !conversationBodyIsStale(conv)) return;
|
|
1402
1459
|
const bucket = convBucketKey(conv);
|
|
1403
1460
|
state.conversationBodiesPending = bucket;
|
|
1404
1461
|
hydrateConversationBody(bucket, conv.id).catch((error) =>
|
|
@@ -3833,6 +3890,9 @@ function renderActive() {
|
|
|
3833
3890
|
pendingAnchorShownAt = null;
|
|
3834
3891
|
pendingAnchorGraceActiveLastTick = false;
|
|
3835
3892
|
userScrolledAwayDuringPendingAnchor = false;
|
|
3893
|
+
pendingAnchorForceStartedAt = 0;
|
|
3894
|
+
window.__fraimPendingAnchorUserScroll = false;
|
|
3895
|
+
delete els['messages'].dataset.pendingAnchorUserScroll;
|
|
3836
3896
|
// A pending coaching job is scoped to the active conversation — discard it
|
|
3837
3897
|
// whenever the user switches to a different conversation.
|
|
3838
3898
|
clearPendingCoachingJob();
|
|
@@ -3853,7 +3913,14 @@ function renderActive() {
|
|
|
3853
3913
|
// existing rows don't re-animate. If for some reason the data shrunk
|
|
3854
3914
|
// (server revoked a message), fall back to a full re-render.
|
|
3855
3915
|
const bodyReady = conversationHasBody(conv);
|
|
3856
|
-
|
|
3916
|
+
// Issue #1634: call unconditionally, not just when the body was never
|
|
3917
|
+
// fetched. ensureActiveConversationBody() now does its own staleness check
|
|
3918
|
+
// internally (conversationBodyIsStale) and is a cheap no-op when the body is
|
|
3919
|
+
// both present and current; this is the render-path trigger that actually
|
|
3920
|
+
// fires on every poll-driven re-render for the active conversation, so the
|
|
3921
|
+
// fix only reaches this call site if it isn't gated behind the old
|
|
3922
|
+
// conversationHasBody()-only condition.
|
|
3923
|
+
ensureActiveConversationBody();
|
|
3857
3924
|
const messages = bodyReady ? (conv.messages || []) : [];
|
|
3858
3925
|
// Issue #820: while the two-phase hydrate is still fetching bodies, show a loading indicator
|
|
3859
3926
|
// for the active conversation instead of an empty transcript. A dedicated node (kept separate
|
|
@@ -3913,6 +3980,14 @@ function renderActive() {
|
|
|
3913
3980
|
// genuinely new messages, not every row a messagesMutated full rebuild just
|
|
3914
3981
|
// re-appended (e.g. an existing message's badge disappearing).
|
|
3915
3982
|
const priorRenderedMessageCount = renderedMessageCount;
|
|
3983
|
+
const hadPendingRowsBeforeRebuild = !!els['messages'].querySelector('.message[data-pending="true"]');
|
|
3984
|
+
if (
|
|
3985
|
+
hadPendingRowsBeforeRebuild
|
|
3986
|
+
&& els['messages'].scrollTop <= 20
|
|
3987
|
+
&& els['messages'].scrollHeight - els['messages'].clientHeight > 80
|
|
3988
|
+
) {
|
|
3989
|
+
userScrolledAwayDuringPendingAnchor = true;
|
|
3990
|
+
}
|
|
3916
3991
|
if (messages.length < renderedMessageCount || messagesMutated) {
|
|
3917
3992
|
els['messages'].innerHTML = '';
|
|
3918
3993
|
renderedMessageCount = 0;
|
|
@@ -5520,7 +5595,7 @@ function pendingBacklogScrollTop(host) {
|
|
|
5520
5595
|
if (!firstPending) return host.scrollHeight;
|
|
5521
5596
|
const backlogTop = firstPending.getBoundingClientRect().top - host.getBoundingClientRect().top + host.scrollTop;
|
|
5522
5597
|
const maxScrollTop = host.scrollHeight - host.clientHeight;
|
|
5523
|
-
return Math.max(0, Math.min(backlogTop, maxScrollTop));
|
|
5598
|
+
return Math.max(0, Math.min(backlogTop - 16, maxScrollTop));
|
|
5524
5599
|
}
|
|
5525
5600
|
|
|
5526
5601
|
// Issue #1570: the #1249 anchor above never released. Once nearBottom went
|
|
@@ -5539,6 +5614,8 @@ let pendingAnchorShownAt = null;
|
|
|
5539
5614
|
let pendingAnchorGraceActiveLastTick = false;
|
|
5540
5615
|
let pendingAnchorScrollListenerHost = null;
|
|
5541
5616
|
let userScrolledAwayDuringPendingAnchor = false;
|
|
5617
|
+
let pendingAnchorProgrammaticScrollUntil = 0;
|
|
5618
|
+
let pendingAnchorForceStartedAt = 0;
|
|
5542
5619
|
|
|
5543
5620
|
// A manual scroll away from the anchor must be detected from a real user
|
|
5544
5621
|
// gesture, not from the 'scroll' event a programmatic assignment ALSO fires —
|
|
@@ -5556,10 +5633,32 @@ let userScrolledAwayDuringPendingAnchor = false;
|
|
|
5556
5633
|
function ensurePendingAnchorScrollListener(host) {
|
|
5557
5634
|
if (pendingAnchorScrollListenerHost === host) return;
|
|
5558
5635
|
pendingAnchorScrollListenerHost = host;
|
|
5559
|
-
const markUserScroll = () => {
|
|
5560
|
-
|
|
5561
|
-
|
|
5636
|
+
const markUserScroll = () => {
|
|
5637
|
+
userScrolledAwayDuringPendingAnchor = true;
|
|
5638
|
+
host.dataset.pendingAnchorUserScroll = 'true';
|
|
5639
|
+
window.__fraimPendingAnchorUserScroll = true;
|
|
5640
|
+
};
|
|
5641
|
+
host.addEventListener('wheel', markUserScroll, { passive: true, capture: true });
|
|
5642
|
+
host.addEventListener('touchmove', markUserScroll, { passive: true, capture: true });
|
|
5643
|
+
window.addEventListener('wheel', markUserScroll, { passive: true, capture: true });
|
|
5644
|
+
window.addEventListener('touchmove', markUserScroll, { passive: true, capture: true });
|
|
5562
5645
|
host.addEventListener('mousedown', (event) => { if (event.target === host) markUserScroll(); });
|
|
5646
|
+
host.addEventListener('scroll', () => {
|
|
5647
|
+
if (pendingAnchorShownAt === null) return;
|
|
5648
|
+
if (Date.now() <= pendingAnchorProgrammaticScrollUntil) return;
|
|
5649
|
+
const pendingRow = host.querySelector('.message[data-pending="true"]');
|
|
5650
|
+
if (!pendingRow) return;
|
|
5651
|
+
if (host.scrollTop <= 20 && host.scrollHeight - host.clientHeight > 80) markUserScroll();
|
|
5652
|
+
}, { passive: true });
|
|
5653
|
+
}
|
|
5654
|
+
|
|
5655
|
+
function setThreadScrollTop(host, top) {
|
|
5656
|
+
pendingAnchorProgrammaticScrollUntil = Date.now() + 200;
|
|
5657
|
+
host.scrollTop = top;
|
|
5658
|
+
}
|
|
5659
|
+
|
|
5660
|
+
function pendingAnchorUserScrolled(host) {
|
|
5661
|
+
return userScrolledAwayDuringPendingAnchor || host.dataset.pendingAnchorUserScroll === 'true' || window.__fraimPendingAnchorUserScroll === true;
|
|
5563
5662
|
}
|
|
5564
5663
|
|
|
5565
5664
|
function scrollThreadAfterViewportSync(conv, shouldScrollForUpdate, forceBottom) {
|
|
@@ -5569,19 +5668,56 @@ function scrollThreadAfterViewportSync(conv, shouldScrollForUpdate, forceBottom)
|
|
|
5569
5668
|
if (!host) return;
|
|
5570
5669
|
if (latest.status === 'running') {
|
|
5571
5670
|
ensurePendingAnchorScrollListener(host);
|
|
5671
|
+
if (!forceBottom && pendingAnchorUserScrolled(host)) {
|
|
5672
|
+
host.scrollTop = 0;
|
|
5673
|
+
return;
|
|
5674
|
+
}
|
|
5572
5675
|
const pendingRow = host.querySelector('.message[data-pending="true"]');
|
|
5573
5676
|
// forceBottom is true exactly when a NEW pending (queued/redirecting)
|
|
5574
5677
|
// message just arrived (see the caller's hasNewPendingDeliveryMessage) —
|
|
5575
5678
|
// that's the moment to (re)start the grace window and trust the manager's
|
|
5576
5679
|
// scroll position again, not every tick a pending row happens to exist.
|
|
5577
5680
|
if (forceBottom) {
|
|
5578
|
-
|
|
5579
|
-
|
|
5681
|
+
const now = Date.now();
|
|
5682
|
+
const deferredSameForce = pendingAnchorForceStartedAt > 0 && (now - pendingAnchorForceStartedAt) < 250;
|
|
5683
|
+
if (
|
|
5684
|
+
deferredSameForce
|
|
5685
|
+
&& (
|
|
5686
|
+
pendingAnchorUserScrolled(host)
|
|
5687
|
+
|| (
|
|
5688
|
+
now > pendingAnchorProgrammaticScrollUntil
|
|
5689
|
+
&& host.scrollTop <= 20
|
|
5690
|
+
&& host.scrollHeight - host.clientHeight > 80
|
|
5691
|
+
)
|
|
5692
|
+
)
|
|
5693
|
+
) {
|
|
5694
|
+
userScrolledAwayDuringPendingAnchor = true;
|
|
5695
|
+
host.dataset.pendingAnchorUserScroll = 'true';
|
|
5696
|
+
window.__fraimPendingAnchorUserScroll = true;
|
|
5697
|
+
} else if (!deferredSameForce) {
|
|
5698
|
+
userScrolledAwayDuringPendingAnchor = false;
|
|
5699
|
+
delete host.dataset.pendingAnchorUserScroll;
|
|
5700
|
+
window.__fraimPendingAnchorUserScroll = false;
|
|
5701
|
+
pendingAnchorForceStartedAt = now;
|
|
5702
|
+
}
|
|
5703
|
+
pendingAnchorShownAt = now;
|
|
5580
5704
|
} else if (!pendingRow) {
|
|
5581
5705
|
pendingAnchorShownAt = null;
|
|
5582
5706
|
}
|
|
5583
5707
|
const anchorGraceActive = !!pendingRow && pendingAnchorShownAt !== null &&
|
|
5584
5708
|
(Date.now() - pendingAnchorShownAt) < PENDING_ANCHOR_GRACE_MS;
|
|
5709
|
+
if (
|
|
5710
|
+
pendingRow
|
|
5711
|
+
&& pendingAnchorShownAt !== null
|
|
5712
|
+
&& !forceBottom
|
|
5713
|
+
&& Date.now() > pendingAnchorProgrammaticScrollUntil
|
|
5714
|
+
&& host.scrollTop <= 20
|
|
5715
|
+
&& host.scrollHeight - host.clientHeight > 80
|
|
5716
|
+
) {
|
|
5717
|
+
userScrolledAwayDuringPendingAnchor = true;
|
|
5718
|
+
host.dataset.pendingAnchorUserScroll = 'true';
|
|
5719
|
+
window.__fraimPendingAnchorUserScroll = true;
|
|
5720
|
+
}
|
|
5585
5721
|
// Fires exactly once, on the tick where the anchor stops being active —
|
|
5586
5722
|
// either the pending badge just cleared, or the grace window for a
|
|
5587
5723
|
// still-unresolved pending row (e.g. Defect 1's kill still in flight)
|
|
@@ -5595,12 +5731,22 @@ function scrollThreadAfterViewportSync(conv, shouldScrollForUpdate, forceBottom)
|
|
|
5595
5731
|
// #936: re-evaluate nearBottom at call time so deferred invocations respect
|
|
5596
5732
|
// any scroll the user made between the render tick and this callback.
|
|
5597
5733
|
const nearBottom = host.scrollHeight - host.scrollTop - host.clientHeight < 80;
|
|
5598
|
-
if (catchUpToBottom &&
|
|
5734
|
+
if (catchUpToBottom && host.scrollTop <= 20 && host.scrollHeight - host.clientHeight > 80) {
|
|
5735
|
+
userScrolledAwayDuringPendingAnchor = true;
|
|
5736
|
+
host.dataset.pendingAnchorUserScroll = 'true';
|
|
5737
|
+
window.__fraimPendingAnchorUserScroll = true;
|
|
5738
|
+
}
|
|
5739
|
+
if (catchUpToBottom && pendingAnchorUserScrolled(host)) return;
|
|
5740
|
+
if (forceBottom && pendingAnchorUserScrolled(host)) return;
|
|
5741
|
+
if (anchorGraceActive && !pendingAnchorUserScrolled(host)) {
|
|
5742
|
+
setThreadScrollTop(host, pendingBacklogScrollTop(host));
|
|
5743
|
+
return;
|
|
5744
|
+
}
|
|
5599
5745
|
// Issue #1249: a message the manager just sent while the agent was
|
|
5600
5746
|
// mid-turn (queued or redirecting) must be visible without the manager
|
|
5601
5747
|
// having to scroll, even if they had scrolled up to review history.
|
|
5602
5748
|
if (nearBottom || forceBottom || catchUpToBottom) {
|
|
5603
|
-
host
|
|
5749
|
+
setThreadScrollTop(host, anchorGraceActive ? pendingBacklogScrollTop(host) : host.scrollHeight);
|
|
5604
5750
|
}
|
|
5605
5751
|
return;
|
|
5606
5752
|
} else if (shouldScrollForUpdate) {
|
|
@@ -7593,13 +7739,21 @@ function renderCpRows(searchText) {
|
|
|
7593
7739
|
}
|
|
7594
7740
|
}
|
|
7595
7741
|
|
|
7596
|
-
//
|
|
7597
|
-
//
|
|
7598
|
-
//
|
|
7599
|
-
|
|
7742
|
+
// Issue #1610 R13: pinned "Ad-hoc" row in the catalog list when a non-empty
|
|
7743
|
+
// search yields zero catalog matches. Clicking calls startAdhoc with the
|
|
7744
|
+
// current search text so the user's query seeds the freeform instructions.
|
|
7745
|
+
const adhocRows = q && catalogJobs.length === 0
|
|
7746
|
+
? [{ type: 'adhoc', job: { id: 'adhoc-prompt', title: 'Ad-hoc', intent: 'run as custom instructions' }, instructions: '' }]
|
|
7747
|
+
: [];
|
|
7748
|
+
|
|
7749
|
+
// Build flat row list: recent first, then adhoc (if shown), then catalog jobs,
|
|
7750
|
+
// then teach entries last. Teach entries go after the catalog jobs so the first
|
|
7751
|
+
// runnable job stays the default keyboard selection (ArrowDown+Enter runs a job,
|
|
7752
|
+
// not a teach flow). The flat order must match the render order below so
|
|
7600
7753
|
// click/keyboard indices line up.
|
|
7601
7754
|
state.cpRows = [
|
|
7602
7755
|
...recentRows,
|
|
7756
|
+
...adhocRows,
|
|
7603
7757
|
...catalogJobs.map((j) => ({ type: 'job', job: j, instructions: '' })),
|
|
7604
7758
|
...teachRows,
|
|
7605
7759
|
];
|
|
@@ -7616,16 +7770,20 @@ function renderCpRows(searchText) {
|
|
|
7616
7770
|
});
|
|
7617
7771
|
}
|
|
7618
7772
|
|
|
7619
|
-
// Render catalog section:
|
|
7620
|
-
// flatIndex continues from recentRows so it
|
|
7773
|
+
// Render catalog section: adhoc pinned row first (when shown), then catalog
|
|
7774
|
+
// jobs, then teach entries last. flatIndex continues from recentRows so it
|
|
7775
|
+
// matches state.cpRows order.
|
|
7621
7776
|
const catalogList = document.getElementById('cp-catalog-list');
|
|
7622
7777
|
if (catalogList) {
|
|
7623
7778
|
catalogList.innerHTML = '';
|
|
7779
|
+
adhocRows.forEach((row, i) => {
|
|
7780
|
+
catalogList.appendChild(buildCpRow(row, recentRows.length + i));
|
|
7781
|
+
});
|
|
7624
7782
|
catalogJobs.forEach((job, i) => {
|
|
7625
|
-
catalogList.appendChild(buildCpRow({ type: 'job', job, instructions: '' }, recentRows.length + i));
|
|
7783
|
+
catalogList.appendChild(buildCpRow({ type: 'job', job, instructions: '' }, recentRows.length + adhocRows.length + i));
|
|
7626
7784
|
});
|
|
7627
7785
|
teachRows.forEach((row, i) => {
|
|
7628
|
-
catalogList.appendChild(buildCpRow(row, recentRows.length + catalogJobs.length + i));
|
|
7786
|
+
catalogList.appendChild(buildCpRow(row, recentRows.length + adhocRows.length + catalogJobs.length + i));
|
|
7629
7787
|
});
|
|
7630
7788
|
}
|
|
7631
7789
|
|
|
@@ -7684,7 +7842,7 @@ function buildCpRow(row, flatIndex) {
|
|
|
7684
7842
|
|
|
7685
7843
|
const icon = document.createElement('span');
|
|
7686
7844
|
icon.className = 'cp-row-icon';
|
|
7687
|
-
icon.textContent = row.type === 'recent' ? '🕐' : row.type === 'teach' ? '🎓' : '📋';
|
|
7845
|
+
icon.textContent = row.type === 'recent' ? '🕐' : row.type === 'teach' ? '🎓' : row.type === 'adhoc' ? '✨' : '📋';
|
|
7688
7846
|
|
|
7689
7847
|
const body = document.createElement('span');
|
|
7690
7848
|
body.className = 'cp-row-body';
|
|
@@ -7711,7 +7869,7 @@ function buildCpRow(row, flatIndex) {
|
|
|
7711
7869
|
// #1340: this is the actual "+ Delegate Job" catalog (openModal → openPalette →
|
|
7712
7870
|
// renderCpRows → buildCpRow) — the row never had a visualize affordance at all.
|
|
7713
7871
|
// Teach rows carry a synthetic, non-catalog job object, so skip them.
|
|
7714
|
-
if (row.type !== 'teach') {
|
|
7872
|
+
if (row.type !== 'teach' && row.type !== 'adhoc') {
|
|
7715
7873
|
el.appendChild(tfCreateJobVizControl(row.job));
|
|
7716
7874
|
}
|
|
7717
7875
|
if (row.job.requiredPersonaKey) {
|
|
@@ -7745,6 +7903,15 @@ function renderCpHighlight() {
|
|
|
7745
7903
|
function selectCpRow(index) {
|
|
7746
7904
|
const row = state.cpRows[index];
|
|
7747
7905
|
if (!row) return;
|
|
7906
|
+
// Issue #1610 R13: adhoc row launches freeform directly with the current search text.
|
|
7907
|
+
// Close the palette first so the step-2 modal opens on a clean slate.
|
|
7908
|
+
if (row.type === 'adhoc') {
|
|
7909
|
+
const search = document.getElementById('cp-search');
|
|
7910
|
+
const searchText = search ? search.value.trim() : '';
|
|
7911
|
+
closePalette();
|
|
7912
|
+
startAdhoc(searchText);
|
|
7913
|
+
return;
|
|
7914
|
+
}
|
|
7748
7915
|
state.cpSelectedJob = row.job;
|
|
7749
7916
|
state.cpHighlightIndex = index;
|
|
7750
7917
|
renderCpHighlight();
|
|
@@ -8808,6 +8975,12 @@ function foldRunIntoConversation(conv, run) {
|
|
|
8808
8975
|
// can derive the correct pill without heuristics.
|
|
8809
8976
|
if (run.pauseReason !== undefined) conv.pauseReason = run.pauseReason;
|
|
8810
8977
|
conv.lastUpdatedAt = Date.now();
|
|
8978
|
+
// Issue #1634: this fold just made conv's messages/events current for run.id,
|
|
8979
|
+
// as of the lastUpdatedAt stamped above — record that so a subsequent
|
|
8980
|
+
// hydrateConversationBody() call (e.g. on switch-away/switch-back while this
|
|
8981
|
+
// same run is still live) sees the content as fresh and does not re-fetch.
|
|
8982
|
+
conv._bodyFetchedRunId = run.id;
|
|
8983
|
+
conv._bodyFetchedLastUpdatedAt = conv.lastUpdatedAt;
|
|
8811
8984
|
}
|
|
8812
8985
|
|
|
8813
8986
|
// Issue #442: fold the Direct (B) side run into the conversation's compareRun slot.
|
|
@@ -12870,6 +13043,21 @@ function buildDeploymentRow(dep) {
|
|
|
12870
13043
|
title.className = 'dep-row-title';
|
|
12871
13044
|
title.textContent = dep.label;
|
|
12872
13045
|
body.appendChild(title);
|
|
13046
|
+
// Issue #1610 R10: for adhoc-prompt deployments show the job name (resolved from
|
|
13047
|
+
// catalog metadata) and the first 50 chars of instructions as a subtitle.
|
|
13048
|
+
if (dep.jobId === 'adhoc-prompt') {
|
|
13049
|
+
const depJobInfo = (state.bootstrap?.jobs ?? []).find((j) => j.id === dep.jobId);
|
|
13050
|
+
const jobLabel = document.createElement('span');
|
|
13051
|
+
jobLabel.className = 'dep-row-job-label';
|
|
13052
|
+
jobLabel.textContent = (depJobInfo && depJobInfo.title) || dep.jobId;
|
|
13053
|
+
body.appendChild(jobLabel);
|
|
13054
|
+
if (dep.instructions) {
|
|
13055
|
+
const sub = document.createElement('span');
|
|
13056
|
+
sub.className = 'dep-row-sub';
|
|
13057
|
+
sub.textContent = dep.instructions.slice(0, 50);
|
|
13058
|
+
body.appendChild(sub);
|
|
13059
|
+
}
|
|
13060
|
+
}
|
|
12873
13061
|
const meta = document.createElement('span');
|
|
12874
13062
|
meta.className = 'assign-row-meta';
|
|
12875
13063
|
const badge = document.createElement('span');
|
|
@@ -13040,6 +13228,23 @@ function populateAgentSelect(sel, currentHostId) {
|
|
|
13040
13228
|
if (pick) sel.value = pick;
|
|
13041
13229
|
}
|
|
13042
13230
|
|
|
13231
|
+
// Issue #1610 R8: sync the instructions label text and placeholder when the job
|
|
13232
|
+
// selection changes. adhoc-prompt requires instructions; all other jobs treat
|
|
13233
|
+
// them as optional context.
|
|
13234
|
+
function updateDepInstructionsLabel() {
|
|
13235
|
+
const jobSel = document.getElementById('dep-job');
|
|
13236
|
+
const labelEl = document.getElementById('dep-inst-optional-label');
|
|
13237
|
+
const textarea = document.getElementById('dep-instructions');
|
|
13238
|
+
const isAdhoc = jobSel && jobSel.value === 'adhoc-prompt';
|
|
13239
|
+
if (labelEl) labelEl.textContent = isAdhoc ? '(required)' : '(optional)';
|
|
13240
|
+
if (textarea) {
|
|
13241
|
+
textarea.placeholder = isAdhoc
|
|
13242
|
+
? 'Describe what to do each run…'
|
|
13243
|
+
: 'Optional message sent to the agent at the start of each run';
|
|
13244
|
+
textarea.setAttribute('aria-required', isAdhoc ? 'true' : 'false');
|
|
13245
|
+
}
|
|
13246
|
+
}
|
|
13247
|
+
|
|
13043
13248
|
// #693 R2: open the single consolidated assignment modal. `dep` present = edit;
|
|
13044
13249
|
// its .type locks the segmented control. New assignments default to Scheduled.
|
|
13045
13250
|
function openDeploymentModal(dep) {
|
|
@@ -13049,13 +13254,24 @@ function openDeploymentModal(dep) {
|
|
|
13049
13254
|
|
|
13050
13255
|
const jobSel = document.getElementById('dep-job');
|
|
13051
13256
|
jobSel.innerHTML = '';
|
|
13052
|
-
|
|
13053
|
-
|
|
13257
|
+
const catalogJobs = jobs.filter((j) => j && j.id !== 'adhoc-prompt');
|
|
13258
|
+
const deploymentJobs = sortJobsByTitle([
|
|
13259
|
+
{ id: 'adhoc-prompt', title: 'Ad-hoc (custom instructions)' },
|
|
13260
|
+
...catalogJobs,
|
|
13261
|
+
]);
|
|
13262
|
+
// Issue #1451: sort alphabetically by the displayed title so the New
|
|
13263
|
+
// Assignment job list is scannable. Ad-hoc remains available, but it is not
|
|
13264
|
+
// pinned above catalog jobs because it requires instructions before saving.
|
|
13265
|
+
for (const j of deploymentJobs) {
|
|
13054
13266
|
const opt = document.createElement('option');
|
|
13055
13267
|
opt.value = j.id; opt.textContent = j.title;
|
|
13056
13268
|
jobSel.appendChild(opt);
|
|
13057
13269
|
}
|
|
13058
|
-
|
|
13270
|
+
const defaultCatalogJob = sortJobsByTitle(catalogJobs)[0];
|
|
13271
|
+
const selectedJobId = dep?.jobId || defaultCatalogJob?.id || 'adhoc-prompt';
|
|
13272
|
+
if (selectedJobId) jobSel.value = selectedJobId;
|
|
13273
|
+
updateDepInstructionsLabel();
|
|
13274
|
+
jobSel.onchange = updateDepInstructionsLabel;
|
|
13059
13275
|
|
|
13060
13276
|
populateAgentSelect(document.getElementById('dep-agent'), dep?.configuredAgentId || dep?.hostId || 'claude');
|
|
13061
13277
|
document.getElementById('dep-label').value = dep?.label ?? '';
|
|
@@ -13103,6 +13319,9 @@ async function saveDeployment() {
|
|
|
13103
13319
|
const instructions = document.getElementById('dep-instructions').value.trim();
|
|
13104
13320
|
const isEdit = _editingDepId !== null;
|
|
13105
13321
|
if (!label) { errEl.textContent = 'Name is required.'; errEl.hidden = false; return; }
|
|
13322
|
+
// Issue #1610 R9: adhoc-prompt has no fixed task — block save before any network
|
|
13323
|
+
// request so the user gets immediate feedback rather than a server 400.
|
|
13324
|
+
if (jobId === 'adhoc-prompt' && !instructions) { errEl.textContent = 'Instructions are required for ad-hoc assignments.'; errEl.hidden = false; return; }
|
|
13106
13325
|
|
|
13107
13326
|
if (_depType === 'scheduled') {
|
|
13108
13327
|
const isCustom = _activeSchPreset === 'custom';
|
package/public/ai-hub/styles.css
CHANGED
|
@@ -6000,6 +6000,9 @@ img.eh-av { object-fit: cover; background: var(--surface); }
|
|
|
6000
6000
|
}
|
|
6001
6001
|
.dep-row:hover { background: var(--soft); }
|
|
6002
6002
|
.dep-row-title { font-size: 13px; font-weight: 400; font-style: italic; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
6003
|
+
/* Issue #1610 R10: ad-hoc job label and instructions subtitle in assignment rows. */
|
|
6004
|
+
.dep-row-job-label { display: block; font-size: 11px; font-weight: 600; color: var(--accent); margin-top: 1px; }
|
|
6005
|
+
.dep-row-sub { display: block; font-size: 11px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 1px; }
|
|
6003
6006
|
.assign-row-meta { display: flex; gap: 8px; align-items: center; margin-top: 3px; font-size: 11px; color: var(--muted); }
|
|
6004
6007
|
.assign-badge { font-size: 10px; font-weight: 700; letter-spacing: .02em; padding: 1px 7px; border-radius: 999px; }
|
|
6005
6008
|
.assign-badge--scheduled { background: rgba(0,113,227,.10); color: var(--accent); }
|