fraim-hub 2.0.239 → 2.0.241
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
CHANGED
|
@@ -734,7 +734,8 @@ function resolveHostInvocation(plan) {
|
|
|
734
734
|
// cannot drift on how the managed-agent bin directories are put on PATH.
|
|
735
735
|
const versionProbeEnv = () => ({
|
|
736
736
|
...process.env,
|
|
737
|
-
PATH: (0, managed_agent_paths_1.buildPathWithManagedAgentBins)(process.env.PATH),
|
|
737
|
+
PATH: (0, managed_agent_paths_1.buildPathWithManagedAgentBins)(process.env.PATH ?? process.env.Path),
|
|
738
|
+
Path: (0, managed_agent_paths_1.buildPathWithManagedAgentBins)(process.env.PATH ?? process.env.Path),
|
|
738
739
|
});
|
|
739
740
|
const availableByVersionProbe = (command) => {
|
|
740
741
|
const invocation = resolveHostInvocation({ command, args: ['--version'] });
|
|
@@ -806,10 +807,20 @@ const EMPLOYEE_DETECTION_TTL_MS = 5 * 60 * 1000;
|
|
|
806
807
|
let employeeDetectionTtlMs = EMPLOYEE_DETECTION_TTL_MS;
|
|
807
808
|
let cachedEmployees = null;
|
|
808
809
|
let cachedEmployeesAtMs = 0;
|
|
810
|
+
let cachedEmployeesContext = null;
|
|
809
811
|
let inFlightDetection = null;
|
|
812
|
+
function employeeDetectionContext() {
|
|
813
|
+
return JSON.stringify({
|
|
814
|
+
PATH: process.env.PATH || '',
|
|
815
|
+
Path: process.env.Path || '',
|
|
816
|
+
FRAIM_USER_DIR: process.env.FRAIM_USER_DIR || '',
|
|
817
|
+
});
|
|
818
|
+
}
|
|
810
819
|
function cachedEmployeesIfFresh() {
|
|
811
820
|
if (!cachedEmployees)
|
|
812
821
|
return null;
|
|
822
|
+
if (cachedEmployeesContext !== employeeDetectionContext())
|
|
823
|
+
return null;
|
|
813
824
|
if (Date.now() - cachedEmployeesAtMs > employeeDetectionTtlMs)
|
|
814
825
|
return null;
|
|
815
826
|
return cachedEmployees;
|
|
@@ -831,6 +842,7 @@ function cachedEmployeesIfFresh() {
|
|
|
831
842
|
// TTL lapses. That is recoverable and self-correcting, and is preferable to freezing every
|
|
832
843
|
// cold start for 2.2s. Installs performed THROUGH the Hub invalidate explicitly.
|
|
833
844
|
const AGENT_AVAILABILITY_FILE = 'hub-agent-availability.json';
|
|
845
|
+
const AGENT_AVAILABILITY_SCHEMA_VERSION = 2;
|
|
834
846
|
// Overridable so tests do not mutate the developer's real ~/.fraim state. These guards run
|
|
835
847
|
// in the smoke suite now, and invalidation DELETES this file, so pointing them at a temp
|
|
836
848
|
// path keeps a frequent test run from repeatedly clearing real agent-availability data.
|
|
@@ -850,6 +862,10 @@ function loadPersistedEmployees() {
|
|
|
850
862
|
try {
|
|
851
863
|
const raw = fs_1.default.readFileSync(agentAvailabilityFilePath(), 'utf8');
|
|
852
864
|
const parsed = JSON.parse(raw);
|
|
865
|
+
if (parsed.schemaVersion !== AGENT_AVAILABILITY_SCHEMA_VERSION)
|
|
866
|
+
return null;
|
|
867
|
+
if (parsed.detectionContext !== employeeDetectionContext())
|
|
868
|
+
return null;
|
|
853
869
|
if (!Array.isArray(parsed.employees) || parsed.employees.length === 0)
|
|
854
870
|
return null;
|
|
855
871
|
// Only accept entries that still match the current known agent ids, so a stale file
|
|
@@ -880,6 +896,7 @@ function loadPersistedEmployees() {
|
|
|
880
896
|
function adoptPersistedEmployees(persisted) {
|
|
881
897
|
cachedEmployees = persisted.employees;
|
|
882
898
|
cachedEmployeesAtMs = persisted.detectedAtMs;
|
|
899
|
+
cachedEmployeesContext = employeeDetectionContext();
|
|
883
900
|
if (Date.now() - persisted.detectedAtMs > employeeDetectionTtlMs) {
|
|
884
901
|
void detectEmployeesAsync({ force: true }).catch(() => undefined);
|
|
885
902
|
}
|
|
@@ -889,7 +906,12 @@ function persistEmployees(employees) {
|
|
|
889
906
|
try {
|
|
890
907
|
const file = agentAvailabilityFilePath();
|
|
891
908
|
fs_1.default.mkdirSync(path_1.default.dirname(file), { recursive: true });
|
|
892
|
-
fs_1.default.writeFileSync(file, JSON.stringify({
|
|
909
|
+
fs_1.default.writeFileSync(file, JSON.stringify({
|
|
910
|
+
employees,
|
|
911
|
+
detectedAt: new Date().toISOString(),
|
|
912
|
+
detectionContext: employeeDetectionContext(),
|
|
913
|
+
schemaVersion: AGENT_AVAILABILITY_SCHEMA_VERSION,
|
|
914
|
+
}, null, 2), 'utf8');
|
|
893
915
|
}
|
|
894
916
|
catch {
|
|
895
917
|
// Best effort only: failing to persist must never break detection.
|
|
@@ -898,6 +920,7 @@ function persistEmployees(employees) {
|
|
|
898
920
|
function storeDetectedEmployees(employees) {
|
|
899
921
|
cachedEmployees = employees;
|
|
900
922
|
cachedEmployeesAtMs = Date.now();
|
|
923
|
+
cachedEmployeesContext = employeeDetectionContext();
|
|
901
924
|
persistEmployees(employees);
|
|
902
925
|
return employees;
|
|
903
926
|
}
|
|
@@ -909,6 +932,7 @@ function storeDetectedEmployees(employees) {
|
|
|
909
932
|
function invalidateEmployeeDetectionCache() {
|
|
910
933
|
cachedEmployees = null;
|
|
911
934
|
cachedEmployeesAtMs = 0;
|
|
935
|
+
cachedEmployeesContext = null;
|
|
912
936
|
inFlightDetection = null;
|
|
913
937
|
try {
|
|
914
938
|
fs_1.default.rmSync(agentAvailabilityFilePath(), { force: true });
|
|
@@ -53,9 +53,10 @@ function buildSameJobContinueMessage(coaching) {
|
|
|
53
53
|
? `Continue the active FRAIM job with this manager coaching:\n\n${remainder}`
|
|
54
54
|
: 'Continue the active FRAIM job using the current session context.';
|
|
55
55
|
}
|
|
56
|
-
function buildManagerMessage(employeeId, jobId, kind, instructions,
|
|
56
|
+
function buildManagerMessage(employeeId, jobId, kind, instructions, options) {
|
|
57
|
+
const stubPath = options?.stubPath;
|
|
57
58
|
const trimmed = String(instructions || '').trim();
|
|
58
|
-
const explicit = extractExplicitFraimInvocation(trimmed);
|
|
59
|
+
const explicit = options?.ignoreEmbeddedInvocation ? null : extractExplicitFraimInvocation(trimmed);
|
|
59
60
|
const effectiveJobId = explicit?.jobId || jobId;
|
|
60
61
|
const invocation = fraimInvocationFor(employeeId, effectiveJobId);
|
|
61
62
|
if (!invocation && effectiveJobId === '__freeform__' && kind === 'start') {
|
|
@@ -341,6 +341,26 @@ function scheduledFireBucketMs(cronExpr, nowMs = Date.now()) {
|
|
|
341
341
|
const bucketMs = fieldCount === 6 ? 1000 : 60 * 1000;
|
|
342
342
|
return Math.floor(nowMs / bucketMs) * bucketMs;
|
|
343
343
|
}
|
|
344
|
+
function exactSixFieldCronDateMs(cronExpr, nowMs = Date.now()) {
|
|
345
|
+
const fields = cronExpr.trim().split(/\s+/).filter(Boolean);
|
|
346
|
+
if (fields.length !== 6 || fields[5] !== '*')
|
|
347
|
+
return null;
|
|
348
|
+
const [second, minute, hour, day, month] = fields.slice(0, 5).map((field) => Number(field));
|
|
349
|
+
if (![second, minute, hour, day, month].every(Number.isInteger))
|
|
350
|
+
return null;
|
|
351
|
+
const now = new Date(nowMs);
|
|
352
|
+
const candidate = new Date(now.getFullYear(), month - 1, day, hour, minute, second, 0);
|
|
353
|
+
if (candidate.getMonth() !== month - 1 ||
|
|
354
|
+
candidate.getDate() !== day ||
|
|
355
|
+
candidate.getHours() !== hour ||
|
|
356
|
+
candidate.getMinutes() !== minute ||
|
|
357
|
+
candidate.getSeconds() !== second)
|
|
358
|
+
return null;
|
|
359
|
+
if (candidate.getTime() >= nowMs - SCHEDULED_FIRE_LEASE_TTL_MS)
|
|
360
|
+
return candidate.getTime();
|
|
361
|
+
const nextYear = new Date(now.getFullYear() + 1, month - 1, day, hour, minute, second, 0);
|
|
362
|
+
return nextYear.getTime();
|
|
363
|
+
}
|
|
344
364
|
class DeploymentStore {
|
|
345
365
|
constructor(filePath) {
|
|
346
366
|
this.filePath = filePath ?? path_1.default.join(getUserHubDir(), 'hub-deployments.json');
|
|
@@ -729,6 +749,9 @@ function normalizeDelegationLedger(raw) {
|
|
|
729
749
|
status,
|
|
730
750
|
personaKey: cleanNullableString((rawTask.personaKey || rawTask.persona)),
|
|
731
751
|
jobId: cleanNullableString((rawTask.jobId || rawTask.job || rawTask.job_id)),
|
|
752
|
+
// Issue #1021: accept the same alias tolerance the rest of this normalizer applies,
|
|
753
|
+
// and coerce a numeric issue to a string so `#${issueNumber}` never renders `[object Object]`.
|
|
754
|
+
issueNumber: cleanNullableString((rawTask.issueNumber ?? rawTask.issue_number ?? rawTask.issue)?.toString()),
|
|
732
755
|
reviewJobId: cleanNullableString(rawTask.reviewJobId),
|
|
733
756
|
reviewType: cleanNullableString(rawTask.reviewType),
|
|
734
757
|
instructions: cleanString((rawTask.instructions || rawTask.briefing)) || undefined,
|
|
@@ -1635,6 +1658,7 @@ class AiHubServer {
|
|
|
1635
1658
|
// never touches a database. Persona and manager-team state resolve from the hosted server
|
|
1636
1659
|
// through the remote gateway; the hosted server is the sole owner of DB access.
|
|
1637
1660
|
this.remoteGateway = options.remoteGateway ?? new remote_hub_gateway_1.HttpHubRemoteGateway();
|
|
1661
|
+
this.deploymentStoreProvided = Boolean(options.deploymentStore);
|
|
1638
1662
|
this.deploymentStore = options.deploymentStore ?? new DeploymentStore();
|
|
1639
1663
|
this.hostConfigStore = options.hostConfigStore ?? new HostConfigStore();
|
|
1640
1664
|
this.app.use(express_1.default.json({ limit: '10mb' }));
|
|
@@ -1846,8 +1870,12 @@ class AiHubServer {
|
|
|
1846
1870
|
void (0, hosts_1.detectEmployeesAsync)({ force: true }).catch((error) => {
|
|
1847
1871
|
console.warn('[ai-hub] agent availability priming failed:', error?.message || error);
|
|
1848
1872
|
});
|
|
1849
|
-
// Issue #578: rehydrate active scheduled deployments from disk.
|
|
1850
|
-
|
|
1873
|
+
// Issue #578: rehydrate active scheduled deployments from disk. Test and preview
|
|
1874
|
+
// servers that inject a fake host but not a deployment store should not run the
|
|
1875
|
+
// user's real scheduled deployments from the default store.
|
|
1876
|
+
if (this.deploymentStoreProvided || this.hostRuntime instanceof hosts_1.CliHostRuntime) {
|
|
1877
|
+
this.rehydrateScheduledDeployments();
|
|
1878
|
+
}
|
|
1851
1879
|
// Start HTTPS server when a cert bundle and port are provided.
|
|
1852
1880
|
// Word Online requires HTTPS; the HTTPS server shares the same Express app
|
|
1853
1881
|
// so all routes (including /word-taskpane/*) are available over both protocols.
|
|
@@ -2686,7 +2714,17 @@ class AiHubServer {
|
|
|
2686
2714
|
continue;
|
|
2687
2715
|
if (task.status && task.status !== 'planned')
|
|
2688
2716
|
continue;
|
|
2689
|
-
|
|
2717
|
+
// Issue #1021: a task that names no job is a manager-visible defect, not a
|
|
2718
|
+
// silent skip. The unresolvable-job path below already blocks with a reason;
|
|
2719
|
+
// this makes the no-job-at-all case behave identically instead of vanishing.
|
|
2720
|
+
// Dependency waiting stays a silent continue — that task is pending, not broken.
|
|
2721
|
+
if (!task.jobId) {
|
|
2722
|
+
this.markDelegationTaskBlocked(managerRun, task, `Delegation blocked: task "${task.taskId}" does not name a job.`);
|
|
2723
|
+
started.add(task.taskId);
|
|
2724
|
+
managerRun.orchestratedDelegationTaskIds.push(task.taskId);
|
|
2725
|
+
continue;
|
|
2726
|
+
}
|
|
2727
|
+
if (!this.delegationDependenciesSatisfied(ledger, task.dependsOn || []))
|
|
2690
2728
|
continue;
|
|
2691
2729
|
if (!task.personaKey)
|
|
2692
2730
|
task.personaKey = getProtectedPersonaForHubJob(task.jobId);
|
|
@@ -2695,6 +2733,21 @@ class AiHubServer {
|
|
|
2695
2733
|
managerRun.orchestratedDelegationTaskIds.push(task.taskId);
|
|
2696
2734
|
}
|
|
2697
2735
|
}
|
|
2736
|
+
/**
|
|
2737
|
+
* Issue #1021: one place that marks a delegation task blocked. Extracted so the
|
|
2738
|
+
* no-job and unresolvable-job paths cannot drift apart — two hand-written copies
|
|
2739
|
+
* of this transition is how the original inconsistency arose.
|
|
2740
|
+
*
|
|
2741
|
+
* Sets a status and a one-line reason only. Per issue #826 a protocol defect must
|
|
2742
|
+
* not surface as manager decision UI, so this deliberately creates no review
|
|
2743
|
+
* controls and no correction affordance.
|
|
2744
|
+
*/
|
|
2745
|
+
markDelegationTaskBlocked(managerRun, task, reason) {
|
|
2746
|
+
task.status = 'blocked';
|
|
2747
|
+
task.latestSummary = reason;
|
|
2748
|
+
managerRun.events.push((0, hosts_1.createHubEvent)('system', reason));
|
|
2749
|
+
this.persistRunConversation(managerRun, managerRun.conversationId || managerRun.id);
|
|
2750
|
+
}
|
|
2698
2751
|
delegationDependenciesSatisfied(ledger, dependsOn) {
|
|
2699
2752
|
if (!dependsOn.length)
|
|
2700
2753
|
return true;
|
|
@@ -2713,27 +2766,40 @@ class AiHubServer {
|
|
|
2713
2766
|
task.personaKey = getProtectedPersonaForHubJob(task.jobId);
|
|
2714
2767
|
const resolvedJob = this.resolveHubJob(managerRun.projectPath, task.jobId);
|
|
2715
2768
|
if (!resolvedJob) {
|
|
2716
|
-
task.
|
|
2717
|
-
task.latestSummary = `Delegation blocked: job "${task.jobId}" is not available in this project.`;
|
|
2718
|
-
managerRun.events.push((0, hosts_1.createHubEvent)('system', task.latestSummary));
|
|
2719
|
-
this.persistRunConversation(managerRun, managerRun.conversationId || managerRun.id);
|
|
2769
|
+
this.markDelegationTaskBlocked(managerRun, task, `Delegation blocked: job "${task.jobId}" is not available in this project.`);
|
|
2720
2770
|
return;
|
|
2721
2771
|
}
|
|
2722
2772
|
const childConversationId = task.conversationId || `${managerRun.id}-${task.taskId}`;
|
|
2723
2773
|
const now = new Date().toISOString();
|
|
2724
|
-
|
|
2725
|
-
|
|
2726
|
-
|
|
2774
|
+
// Issue #1021: this brief adds context the job cannot know and subtracts nothing.
|
|
2775
|
+
// It previously carried "This is a delegated subtask, not an end-to-end FRAIM
|
|
2776
|
+
// submission workflow ... Do not create evidence docs, open or update PRs, update
|
|
2777
|
+
// GitHub issues" plus a "submit a concise summary back to Mandy" substitute for the
|
|
2778
|
+
// job's real submit phase. Both are gone: the child runs its named job in full, and
|
|
2779
|
+
// delegation changes who reviews the output, not what the output is.
|
|
2780
|
+
//
|
|
2781
|
+
// Nothing is added in their place. The Hub does not restate what the job already
|
|
2782
|
+
// instructs (#563), and it does not tell the child who reviews it — routing is
|
|
2783
|
+
// handled entirely by notifyManagerOfDelegatedChild and, on the client, by
|
|
2784
|
+
// syncManagedDelegationAccess. The retained "Mandy is your manager" line is the only
|
|
2785
|
+
// one the child's behavior actually depends on: it stops the child blocking on a human.
|
|
2786
|
+
const contextLines = [
|
|
2727
2787
|
task.personaKey
|
|
2728
2788
|
? `You are working as ${task.personaKey} for the manager job.`
|
|
2729
2789
|
: 'You are working as the specialist assigned by this delegated job.',
|
|
2730
2790
|
`Parent objective: ${ledger.objective}.`,
|
|
2731
|
-
|
|
2732
|
-
|
|
2733
|
-
|
|
2791
|
+
// The issue the child works, carried as ledger data rather than left to prose, so
|
|
2792
|
+
// set-up-workspace can provision the right branch and the job can label the issue.
|
|
2793
|
+
task.issueNumber ? `Issue: #${task.issueNumber}` : null,
|
|
2794
|
+
task.reviewJobId ? `Manager review route: this output should be reviewed using ${task.reviewJobId}.` : null,
|
|
2734
2795
|
'Do not ask the human for coaching; Mandy is your manager for this workstream.',
|
|
2796
|
+
].filter((line) => Boolean(line));
|
|
2797
|
+
const childInstructions = [
|
|
2798
|
+
task.instructions || task.latestSummary || `Complete the delegated workstream: ${task.title}.`,
|
|
2799
|
+
'',
|
|
2800
|
+
...contextLines,
|
|
2735
2801
|
].join('\n');
|
|
2736
|
-
const prepared = this.prepareStartPayload(managerRun.projectPath, managerRun.hostId, task.jobId, childInstructions);
|
|
2802
|
+
const prepared = this.prepareStartPayload(managerRun.projectPath, managerRun.hostId, task.jobId, childInstructions, { ignoreEmbeddedInvocation: true });
|
|
2737
2803
|
const childRun = {
|
|
2738
2804
|
id: (0, crypto_1.randomUUID)(),
|
|
2739
2805
|
conversationId: childConversationId,
|
|
@@ -3067,8 +3133,15 @@ class AiHubServer {
|
|
|
3067
3133
|
// Lightweight markdown → .docx. Shared by the GET (file path) and POST (inline
|
|
3068
3134
|
// content) export routes so a conversational deliverable with no on-disk file
|
|
3069
3135
|
// can still be downloaded for Word annotation.
|
|
3070
|
-
prepareStartPayload(projectPath, hostId, selectedJobId, instructions
|
|
3071
|
-
|
|
3136
|
+
prepareStartPayload(projectPath, hostId, selectedJobId, instructions,
|
|
3137
|
+
// Issue #1021: an explicit invocation inside `instructions` normally wins, which is
|
|
3138
|
+
// correct when a human typed `/fraim <job>` into the coach box. For an
|
|
3139
|
+
// orchestration-initiated launch there is no human typing: the instructions are
|
|
3140
|
+
// agent-generated prose, and the delegation ledger is the only authority on which
|
|
3141
|
+
// job runs. Without this, a stray `/fraim other-job` in Mandy's brief would silently
|
|
3142
|
+
// launch a different job than the ledger declares and stamp it onto childRun.jobId.
|
|
3143
|
+
options) {
|
|
3144
|
+
const explicit = options?.ignoreEmbeddedInvocation ? null : (0, manager_turns_1.extractExplicitFraimInvocation)(instructions);
|
|
3072
3145
|
const resolvedJobId = explicit?.jobId || selectedJobId;
|
|
3073
3146
|
if (!resolvedJobId) {
|
|
3074
3147
|
throw new Error('Choose a FRAIM job before starting a run, or start with /fraim <job-id>.');
|
|
@@ -3082,11 +3155,12 @@ class AiHubServer {
|
|
|
3082
3155
|
// available (env published at boot).
|
|
3083
3156
|
const browserNote = (0, managed_browser_1.buildBrowserContextNote)(process.env.FRAIM_BROWSER_CDP_ENDPOINT, process.env.FRAIM_HUB_BASE_URL);
|
|
3084
3157
|
const styleNote = (0, manager_turns_1.buildCommunicationStyleNote)();
|
|
3158
|
+
const ignoreEmbedded = options?.ignoreEmbeddedInvocation === true;
|
|
3085
3159
|
if (resolvedJobId === '__freeform__') {
|
|
3086
|
-
const display = (0, manager_turns_1.buildManagerMessage)(hostId, resolvedJobId, 'start', instructions);
|
|
3160
|
+
const display = (0, manager_turns_1.buildManagerMessage)(hostId, resolvedJobId, 'start', instructions, { ignoreEmbeddedInvocation: ignoreEmbedded });
|
|
3087
3161
|
return {
|
|
3088
3162
|
jobId: resolvedJobId,
|
|
3089
|
-
message: (0, manager_turns_1.buildManagerMessage)(hostId, resolvedJobId, 'start', instructions) + browserNote + styleNote,
|
|
3163
|
+
message: (0, manager_turns_1.buildManagerMessage)(hostId, resolvedJobId, 'start', instructions, { ignoreEmbeddedInvocation: ignoreEmbedded }) + browserNote + styleNote,
|
|
3090
3164
|
display,
|
|
3091
3165
|
};
|
|
3092
3166
|
}
|
|
@@ -3094,10 +3168,10 @@ class AiHubServer {
|
|
|
3094
3168
|
const absoluteStubPath = resolvedJob?.stubPath
|
|
3095
3169
|
? [projectPath, resolvedJob.stubPath].join('/').replace(/\\/g, '/').replace(/\/+/g, '/')
|
|
3096
3170
|
: undefined;
|
|
3097
|
-
const display = (0, manager_turns_1.buildManagerMessage)(hostId, resolvedJobId, 'start', instructions);
|
|
3171
|
+
const display = (0, manager_turns_1.buildManagerMessage)(hostId, resolvedJobId, 'start', instructions, { ignoreEmbeddedInvocation: ignoreEmbedded });
|
|
3098
3172
|
return {
|
|
3099
3173
|
jobId: resolvedJobId,
|
|
3100
|
-
message: (0, manager_turns_1.buildManagerMessage)(hostId, resolvedJobId, 'start', instructions, absoluteStubPath) + browserNote + styleNote,
|
|
3174
|
+
message: (0, manager_turns_1.buildManagerMessage)(hostId, resolvedJobId, 'start', instructions, { stubPath: absoluteStubPath, ignoreEmbeddedInvocation: ignoreEmbedded }) + browserNote + styleNote,
|
|
3101
3175
|
display,
|
|
3102
3176
|
};
|
|
3103
3177
|
}
|
|
@@ -4560,12 +4634,17 @@ class AiHubServer {
|
|
|
4560
4634
|
if (!run) {
|
|
4561
4635
|
return res.status(404).json({ error: 'Run not found.' });
|
|
4562
4636
|
}
|
|
4563
|
-
|
|
4564
|
-
|
|
4637
|
+
// When sessionId is null the host session is gone (agent crashed, Hub restart with no
|
|
4638
|
+
// resume path, etc.). A non-managed direct run must still accept coaching: fall back to a
|
|
4639
|
+
// fresh startRun with the conversation's handoff context + the manager's coaching text,
|
|
4640
|
+
// exactly as the agent-switch path does. Managed delegation children are still blocked —
|
|
4641
|
+
// their coaching flows through the parent manager run, not through the human.
|
|
4642
|
+
if (!run.sessionId && !run.humanCoachingDisabled && run.status === 'running') {
|
|
4643
|
+
return res.status(409).json({ error: 'This run has not established a host session yet. Wait a moment and try again.' });
|
|
4565
4644
|
}
|
|
4566
4645
|
const instructions = (req.body.instructions || '').trim();
|
|
4567
4646
|
const coachingJobId = req.body.coachingJobId?.trim() || undefined;
|
|
4568
|
-
// When coachingJobId is present (user picked a manager template via the UI),
|
|
4647
|
+
// When coachingJobId is present (user picked a manager coaching template via the UI),
|
|
4569
4648
|
// it overrides the run's own jobId in the invocation. The server always adds
|
|
4570
4649
|
// the correct $fraim / /fraim prefix — the UI never passes raw invocation syntax.
|
|
4571
4650
|
const prepared = instructions
|
|
@@ -4577,6 +4656,70 @@ class AiHubServer {
|
|
|
4577
4656
|
if (!message) {
|
|
4578
4657
|
return res.status(400).json({ error: 'Coach your employee before sending the next turn.' });
|
|
4579
4658
|
}
|
|
4659
|
+
// No resumable session — start fresh using a handoff prompt so the agent
|
|
4660
|
+
// picks up from the preserved conversation state + manager coaching.
|
|
4661
|
+
if (!run.sessionId) {
|
|
4662
|
+
if (run.humanCoachingDisabled) {
|
|
4663
|
+
return res.status(403).json({ error: 'Human coaching is disabled for this delegated workstream. Coach the parent manager run instead.' });
|
|
4664
|
+
}
|
|
4665
|
+
const conversation = this.conversationStore.loadConversation((0, conversation_store_1.conversationScopeKey)(run.scope, run.projectPath), run.conversationId || run.id);
|
|
4666
|
+
const handoffSummary = conversation
|
|
4667
|
+
? buildAgentSwitchHandoffSummary(conversation)
|
|
4668
|
+
: null;
|
|
4669
|
+
const freshPayload = handoffSummary
|
|
4670
|
+
? this.prepareStartPayload(run.projectPath, run.hostId, run.jobId, renderAgentSwitchHandoffPrompt(handoffSummary, prepared.display || message))
|
|
4671
|
+
: this.prepareStartPayload(run.projectPath, run.hostId, run.jobId, prepared.display || message);
|
|
4672
|
+
const reviewApprovalSystemEventTextFresh = buildReviewApprovalSystemEventText(prepared.display || message);
|
|
4673
|
+
this.runRegistry.update(run.id, (current) => {
|
|
4674
|
+
current.status = 'running';
|
|
4675
|
+
current.sessionId = undefined;
|
|
4676
|
+
current.messages.push((0, hosts_1.createHubMessage)('manager', prepared.display || message));
|
|
4677
|
+
if (reviewApprovalSystemEventTextFresh)
|
|
4678
|
+
current.events.push((0, hosts_1.createHubEvent)('system', reviewApprovalSystemEventTextFresh));
|
|
4679
|
+
current.events.push((0, hosts_1.createHubEvent)('system', 'No resumable session — starting a fresh agent turn from conversation context.'));
|
|
4680
|
+
});
|
|
4681
|
+
const startedFresh = this.runRegistry.get(run.id);
|
|
4682
|
+
if (startedFresh)
|
|
4683
|
+
this.persistRunConversation(startedFresh, startedFresh.conversationId || startedFresh.id);
|
|
4684
|
+
this.runRegistry.create(run, {});
|
|
4685
|
+
const freshLaunch = this.resolveLaunchAgent(run.configuredAgentId, run.hostId);
|
|
4686
|
+
const freshChild = this.hostRuntime.startRun(run.hostId, run.projectPath, freshPayload.message, {
|
|
4687
|
+
onEvent: (event, channel) => {
|
|
4688
|
+
this.runRegistry.update(run.id, (current) => {
|
|
4689
|
+
if (event.sessionId) {
|
|
4690
|
+
current.sessionId = event.sessionId;
|
|
4691
|
+
current.resumeCommand = (0, hosts_1.buildInteractiveResumeCommand)(run.hostId, event.sessionId);
|
|
4692
|
+
}
|
|
4693
|
+
appendHostMessage(current, run.hostId, event, channel);
|
|
4694
|
+
if (event.raw) {
|
|
4695
|
+
current.events.push((0, hosts_1.createHubEvent)(channel, event.raw));
|
|
4696
|
+
applyReviewProjection(current, event.raw);
|
|
4697
|
+
}
|
|
4698
|
+
if (event.agentIdentity)
|
|
4699
|
+
applyAgentIdentitySignal(current, event.agentIdentity);
|
|
4700
|
+
if (event.fraimJob)
|
|
4701
|
+
this.applyFraimJobSignalToRun(current, event.fraimJob);
|
|
4702
|
+
if (event.seekMentoring)
|
|
4703
|
+
this.applySeekMentoringSignalToRun(current, event.seekMentoring);
|
|
4704
|
+
if (event.usage)
|
|
4705
|
+
applyUsageSignal(current, event.usage);
|
|
4706
|
+
});
|
|
4707
|
+
const updated = this.runRegistry.get(run.id);
|
|
4708
|
+
if (updated) {
|
|
4709
|
+
this.maybeStartDelegatedChildRuns(updated);
|
|
4710
|
+
this.scheduleRunConversationPersistence(updated, updated.conversationId || updated.id);
|
|
4711
|
+
}
|
|
4712
|
+
},
|
|
4713
|
+
onExit: (exitCode) => {
|
|
4714
|
+
this.handleRunExit(run.id, exitCode, (updated) => {
|
|
4715
|
+
this.maybeStartDelegatedChildRuns(updated);
|
|
4716
|
+
});
|
|
4717
|
+
},
|
|
4718
|
+
}, startSessionSeedForHost(run.hostId, run.id), freshLaunch.launchContext);
|
|
4719
|
+
this.runRegistry.attachChildIfRunning(run.id, freshChild);
|
|
4720
|
+
const refreshedFresh = this.runRegistry.get(run.id);
|
|
4721
|
+
return res.json(refreshedFresh ? this.enrichRunForResponse(refreshedFresh) : refreshedFresh);
|
|
4722
|
+
}
|
|
4580
4723
|
const reviewApprovalSystemEventText = buildReviewApprovalSystemEventText(prepared.display || message);
|
|
4581
4724
|
this.runRegistry.update(run.id, (current) => {
|
|
4582
4725
|
current.status = 'running';
|
|
@@ -5394,9 +5537,8 @@ class AiHubServer {
|
|
|
5394
5537
|
console.warn(`[ai-hub] invalid cronExpr for deployment ${deployment.id}: ${deployment.cronExpr}`);
|
|
5395
5538
|
return;
|
|
5396
5539
|
}
|
|
5397
|
-
const
|
|
5540
|
+
const fireScheduledDeployment = async (fireTimeMs = scheduledFireBucketMs(deployment.cronExpr || '')) => {
|
|
5398
5541
|
try {
|
|
5399
|
-
const fireTimeMs = scheduledFireBucketMs(deployment.cronExpr || '');
|
|
5400
5542
|
if (!this.deploymentStore.claimScheduledFire(deployment.id, fireTimeMs)) {
|
|
5401
5543
|
console.log(`[ai-hub] scheduled deployment ${deployment.id} skipped - scheduled fire already claimed`);
|
|
5402
5544
|
return;
|
|
@@ -5406,8 +5548,34 @@ class AiHubServer {
|
|
|
5406
5548
|
catch (err) {
|
|
5407
5549
|
console.warn(`[ai-hub] scheduled deployment ${deployment.id} fire failed:`, err);
|
|
5408
5550
|
}
|
|
5551
|
+
};
|
|
5552
|
+
const task = cron.schedule(deployment.cronExpr, () => { void fireScheduledDeployment(); });
|
|
5553
|
+
const exactFireMs = exactSixFieldCronDateMs(deployment.cronExpr);
|
|
5554
|
+
const exactFireNearDue = exactFireMs !== null && exactFireMs - Date.now() <= 5_000;
|
|
5555
|
+
if (exactFireNearDue) {
|
|
5556
|
+
setTimeout(() => { void fireScheduledDeployment(exactFireMs); }, 1_000);
|
|
5557
|
+
}
|
|
5558
|
+
const exactTimer = exactFireMs !== null && !exactFireNearDue
|
|
5559
|
+
? setTimeout(() => { void fireScheduledDeployment(exactFireMs); }, Math.max(0, exactFireMs - Date.now()))
|
|
5560
|
+
: null;
|
|
5561
|
+
const exactInterval = exactFireMs !== null && !exactFireNearDue
|
|
5562
|
+
? setInterval(() => {
|
|
5563
|
+
if (Date.now() < exactFireMs)
|
|
5564
|
+
return;
|
|
5565
|
+
if (exactInterval)
|
|
5566
|
+
clearInterval(exactInterval);
|
|
5567
|
+
void fireScheduledDeployment(exactFireMs);
|
|
5568
|
+
}, 250)
|
|
5569
|
+
: null;
|
|
5570
|
+
this.cronHandles.set(deployment.id, {
|
|
5571
|
+
stop: () => {
|
|
5572
|
+
task.stop();
|
|
5573
|
+
if (exactTimer)
|
|
5574
|
+
clearTimeout(exactTimer);
|
|
5575
|
+
if (exactInterval)
|
|
5576
|
+
clearInterval(exactInterval);
|
|
5577
|
+
},
|
|
5409
5578
|
});
|
|
5410
|
-
this.cronHandles.set(deployment.id, task);
|
|
5411
5579
|
}
|
|
5412
5580
|
catch (err) {
|
|
5413
5581
|
console.warn('[ai-hub] node-cron not available — scheduled deployments require node-cron:', err);
|
|
@@ -117,7 +117,7 @@ exports.PERSONA_CAPABILITY_BUNDLES = {
|
|
|
117
117
|
personaKey: 'ashley',
|
|
118
118
|
bundleId: 'persona-ashley-core',
|
|
119
119
|
catalogMetadata: buildCatalogMetadata('ashley', ['chief-of-staff-briefing', 'executive-assistant', 'analyze-transcript']),
|
|
120
|
-
protectedJobs: ['chief-of-staff-briefing', 'calendar-triage', 'meeting-preparation', 'executive-assistant', 'send-newsletter', 'send-thank-you-notes', 'analyze-transcript'],
|
|
120
|
+
protectedJobs: ['chief-of-staff-briefing', 'calendar-triage', 'meeting-preparation', 'executive-assistant', 'send-newsletter', 'send-thank-you-notes', 'analyze-transcript', 'travel-planning', 'travel-disruption-rebooking'],
|
|
121
121
|
protectedAliases: ['executive-assistant', 'operations-assistant'],
|
|
122
122
|
defaultHireMode: 'job',
|
|
123
123
|
lockCopy: 'Hire AshLey to unlock executive-assistant work for this request.'
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fraim-hub",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.241",
|
|
4
4
|
"description": "FRAIM Hub local companion package.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"fraim-hub": "bin/fraim-hub.js",
|
|
@@ -161,7 +161,7 @@
|
|
|
161
161
|
"electron": "^41.2.2",
|
|
162
162
|
"electron-updater": "^6.8.9",
|
|
163
163
|
"express": "^5.2.1",
|
|
164
|
-
"fraim": "2.0.
|
|
164
|
+
"fraim": "2.0.241",
|
|
165
165
|
"mongodb": "^7.0.0",
|
|
166
166
|
"node-cron": "4.2.1",
|
|
167
167
|
"node-edge-tts": "^1.2.10",
|
package/public/ai-hub/script.js
CHANGED
|
@@ -1242,7 +1242,14 @@ function panelStateFor(convId) {
|
|
|
1242
1242
|
}
|
|
1243
1243
|
|
|
1244
1244
|
function defaultCoachOpen(conv) {
|
|
1245
|
-
|
|
1245
|
+
// Issue #1021 (R11): an oversight run collapses the coach panel by default so the
|
|
1246
|
+
// delegation boards get the vertical space. But the review actions live inside that
|
|
1247
|
+
// panel, so once Mandy submits, the human had to expand a collapsed panel to find
|
|
1248
|
+
// Approve while every other employee shows it immediately. Parity is the whole point
|
|
1249
|
+
// of this issue: when the run is actually awaiting a decision, the decision is visible.
|
|
1250
|
+
if (isManagerOversightConversation(conv)) {
|
|
1251
|
+
return typeof convAwaitingReview === 'function' ? convAwaitingReview(conv) : false;
|
|
1252
|
+
}
|
|
1246
1253
|
return true;
|
|
1247
1254
|
}
|
|
1248
1255
|
|
|
@@ -3649,6 +3656,32 @@ function delegatedTaskArtifacts(task, childConv) {
|
|
|
3649
3656
|
return task.artifacts || [];
|
|
3650
3657
|
}
|
|
3651
3658
|
|
|
3659
|
+
// Issue #1021: a delegated child's pull request is its review surface. The URL is
|
|
3660
|
+
// AI-derived (it originates in an agent-emitted review handoff), so it must pass the
|
|
3661
|
+
// shared safeHttpUrl gate before it can become an href — the same trust boundary
|
|
3662
|
+
// window-open-decision.ts enforces for the desktop shell. Never hand-roll this check.
|
|
3663
|
+
function delegatedTaskPullRequestTarget(task, childConv) {
|
|
3664
|
+
const target = childConv?.reviewHandoff?.reviewTarget || task?.reviewHandoff?.reviewTarget;
|
|
3665
|
+
if (!target || target.type !== 'pull_request') return null;
|
|
3666
|
+
const url = safeHttpUrl(target.url);
|
|
3667
|
+
return url ? { target, url } : null;
|
|
3668
|
+
}
|
|
3669
|
+
|
|
3670
|
+
// Label for a child's pull request chip. Prefers the normalized `prNumber` the review
|
|
3671
|
+
// handoff already carries, matching the existing `PR #${target.prNumber}` convention used
|
|
3672
|
+
// by the delivery-action summary, rather than re-deriving it. Falls back to parsing the
|
|
3673
|
+
// URL for a provider that did not supply a number, then to the target's own label.
|
|
3674
|
+
function pullRequestChipLabel(target, url) {
|
|
3675
|
+
const prNumber = target && typeof target.prNumber === 'number' && Number.isFinite(target.prNumber)
|
|
3676
|
+
? target.prNumber
|
|
3677
|
+
: null;
|
|
3678
|
+
if (prNumber) return `Pull request #${prNumber}`;
|
|
3679
|
+
const match = /\/(?:pull|pull-requests|merge_requests)\/(\d+)/.exec(String(url || ''));
|
|
3680
|
+
if (match) return `Pull request #${match[1]}`;
|
|
3681
|
+
const label = target && typeof target.label === 'string' ? target.label.trim() : '';
|
|
3682
|
+
return label || 'Pull request';
|
|
3683
|
+
}
|
|
3684
|
+
|
|
3652
3685
|
function renderDelegationLedger(conv) {
|
|
3653
3686
|
const host = ensureDelegationHost();
|
|
3654
3687
|
if (!host) return;
|
|
@@ -3732,7 +3765,26 @@ function renderDelegationLedger(conv) {
|
|
|
3732
3765
|
if (detail.textContent) row.appendChild(detail);
|
|
3733
3766
|
row.appendChild(summary);
|
|
3734
3767
|
|
|
3768
|
+
// Issue #1021: a child that submits a pull request carries `artifacts: []` by
|
|
3769
|
+
// contract (a PR target IS the whole review surface), so the artifact strip below
|
|
3770
|
+
// was empty and the row fell through to "no file artifact reported" — actively
|
|
3771
|
+
// wrong once delegated children open real PRs. Surface the PR as the child's
|
|
3772
|
+
// review surface in the same strip, using the same component class.
|
|
3773
|
+
const childPr = delegatedTaskPullRequestTarget(task, childConv);
|
|
3735
3774
|
const artifacts = delegatedTaskArtifacts(task, childConv);
|
|
3775
|
+
if (childPr) {
|
|
3776
|
+
const prStrip = document.createElement('div');
|
|
3777
|
+
prStrip.className = 'delegation-artifacts';
|
|
3778
|
+
const link = document.createElement('a');
|
|
3779
|
+
link.className = 'delegation-artifact';
|
|
3780
|
+
link.href = childPr.url;
|
|
3781
|
+
link.target = '_blank';
|
|
3782
|
+
link.rel = 'noopener noreferrer';
|
|
3783
|
+
link.textContent = pullRequestChipLabel(childPr.target, childPr.url);
|
|
3784
|
+
link.title = childPr.url;
|
|
3785
|
+
prStrip.appendChild(link);
|
|
3786
|
+
row.appendChild(prStrip);
|
|
3787
|
+
}
|
|
3736
3788
|
if (artifacts.length) {
|
|
3737
3789
|
const artifactStrip = document.createElement('div');
|
|
3738
3790
|
artifactStrip.className = 'delegation-artifacts';
|
|
@@ -3752,7 +3804,7 @@ function renderDelegationLedger(conv) {
|
|
|
3752
3804
|
artifactStrip.appendChild(chip);
|
|
3753
3805
|
}
|
|
3754
3806
|
row.appendChild(artifactStrip);
|
|
3755
|
-
} else if (childConv?.status === 'completed' || taskStatus === 'submitted' || taskStatus === 'reviewed') {
|
|
3807
|
+
} else if (!childPr && (childConv?.status === 'completed' || taskStatus === 'submitted' || taskStatus === 'reviewed')) {
|
|
3756
3808
|
const inline = document.createElement('div');
|
|
3757
3809
|
inline.className = 'delegation-review-note';
|
|
3758
3810
|
inline.textContent = 'Inline deliverable; no file artifact reported';
|
|
@@ -6841,10 +6893,11 @@ async function continueRun(text, options) {
|
|
|
6841
6893
|
body: JSON.stringify({ instructions: text, ...(coachingJobId ? { coachingJobId } : {}) }),
|
|
6842
6894
|
});
|
|
6843
6895
|
} catch (e) {
|
|
6896
|
+
const isNotFound = /not found/i.test((e && e.message) || '');
|
|
6844
6897
|
// #521: the Hub run is in-memory and is lost on a server restart, but the
|
|
6845
6898
|
// agent session persists on disk. If the run is gone and we have a sessionId,
|
|
6846
6899
|
// resume the conversation rather than failing — carries it forward intact.
|
|
6847
|
-
if (conv.sessionId &&
|
|
6900
|
+
if (conv.sessionId && isNotFound) {
|
|
6848
6901
|
run = await requestJson('/api/ai-hub/runs/resume', {
|
|
6849
6902
|
method: 'POST',
|
|
6850
6903
|
headers: { 'Content-Type': 'application/json' },
|
|
@@ -6862,6 +6915,26 @@ async function continueRun(text, options) {
|
|
|
6862
6915
|
}),
|
|
6863
6916
|
});
|
|
6864
6917
|
conv.runId = run.id; // bind the conversation to the freshly-resumed run
|
|
6918
|
+
} else if (!conv.sessionId && (isNotFound || /409/.test((e && e.message) || ''))) {
|
|
6919
|
+
// Run is gone from the registry AND there is no resumable session (agent crashed
|
|
6920
|
+
// or Hub restarted after a failed run). Start a completely fresh run bound to
|
|
6921
|
+
// this conversation so the manager's coaching is not lost.
|
|
6922
|
+
run = await requestJson('/api/ai-hub/runs', {
|
|
6923
|
+
method: 'POST',
|
|
6924
|
+
headers: { 'Content-Type': 'application/json' },
|
|
6925
|
+
body: JSON.stringify({
|
|
6926
|
+
projectPath: state.projectPath,
|
|
6927
|
+
hostId: baseHostIdForAgent(conversationAgentName(conv) || state.selectedEmployeeId || 'claude'),
|
|
6928
|
+
configuredAgentId: conversationAgentName(conv) || state.selectedEmployeeId || 'claude',
|
|
6929
|
+
jobId: conv.jobId,
|
|
6930
|
+
jobTitle: conv.jobTitle || conv.jobId,
|
|
6931
|
+
conversationId: conv.id,
|
|
6932
|
+
conversationTitle: conv.title,
|
|
6933
|
+
instructions: text,
|
|
6934
|
+
...(coachingJobId ? { coachingJobId } : {}),
|
|
6935
|
+
}),
|
|
6936
|
+
});
|
|
6937
|
+
conv.runId = run.id; // bind the conversation to the new run
|
|
6865
6938
|
} else {
|
|
6866
6939
|
throw e;
|
|
6867
6940
|
}
|
package/public/ai-hub/styles.css
CHANGED
|
@@ -1277,6 +1277,17 @@ img.conv-employee-avatar {
|
|
|
1277
1277
|
.delegation-artifact:hover {
|
|
1278
1278
|
background: color-mix(in srgb, var(--accent) 16%, transparent);
|
|
1279
1279
|
}
|
|
1280
|
+
/* Issue #1021: a child's pull request renders as an <a> in this strip, so the chip is
|
|
1281
|
+
now keyboard-navigable where it was previously only a <button> relying on the UA
|
|
1282
|
+
ring. Match the focus-visible convention used across this stylesheet. */
|
|
1283
|
+
.delegation-artifact:focus-visible,
|
|
1284
|
+
.delegation-view-work:focus-visible {
|
|
1285
|
+
outline: 2px solid var(--accent-strong);
|
|
1286
|
+
outline-offset: 2px;
|
|
1287
|
+
}
|
|
1288
|
+
.delegation-artifact {
|
|
1289
|
+
text-decoration: none;
|
|
1290
|
+
}
|
|
1280
1291
|
.delegation-review-note {
|
|
1281
1292
|
background: var(--state-working-soft);
|
|
1282
1293
|
color: var(--state-working);
|