cyrus-edge-worker 0.2.51 → 0.2.53

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.
Files changed (39) hide show
  1. package/dist/ChatSessionHandler.d.ts +32 -1
  2. package/dist/ChatSessionHandler.d.ts.map +1 -1
  3. package/dist/ChatSessionHandler.js +30 -0
  4. package/dist/ChatSessionHandler.js.map +1 -1
  5. package/dist/ConfigManager.d.ts.map +1 -1
  6. package/dist/ConfigManager.js +12 -2
  7. package/dist/ConfigManager.js.map +1 -1
  8. package/dist/EdgeWorker.d.ts +82 -0
  9. package/dist/EdgeWorker.d.ts.map +1 -1
  10. package/dist/EdgeWorker.js +298 -28
  11. package/dist/EdgeWorker.js.map +1 -1
  12. package/dist/McpConfigService.d.ts +8 -4
  13. package/dist/McpConfigService.d.ts.map +1 -1
  14. package/dist/McpConfigService.js +12 -5
  15. package/dist/McpConfigService.js.map +1 -1
  16. package/dist/RunnerConfigBuilder.d.ts +61 -17
  17. package/dist/RunnerConfigBuilder.d.ts.map +1 -1
  18. package/dist/RunnerConfigBuilder.js +109 -42
  19. package/dist/RunnerConfigBuilder.js.map +1 -1
  20. package/dist/SkillsPluginResolver.d.ts +52 -5
  21. package/dist/SkillsPluginResolver.d.ts.map +1 -1
  22. package/dist/SkillsPluginResolver.js +130 -17
  23. package/dist/SkillsPluginResolver.js.map +1 -1
  24. package/dist/SlackChatAdapter.d.ts.map +1 -1
  25. package/dist/SlackChatAdapter.js +1 -0
  26. package/dist/SlackChatAdapter.js.map +1 -1
  27. package/dist/ToolPermissionResolver.d.ts +41 -18
  28. package/dist/ToolPermissionResolver.d.ts.map +1 -1
  29. package/dist/ToolPermissionResolver.js +77 -47
  30. package/dist/ToolPermissionResolver.js.map +1 -1
  31. package/dist/hooks/IntentToAddHook.d.ts +56 -0
  32. package/dist/hooks/IntentToAddHook.d.ts.map +1 -0
  33. package/dist/hooks/IntentToAddHook.js +150 -0
  34. package/dist/hooks/IntentToAddHook.js.map +1 -0
  35. package/dist/prompts/failureModePromptAddendum.d.ts +26 -0
  36. package/dist/prompts/failureModePromptAddendum.d.ts.map +1 -0
  37. package/dist/prompts/failureModePromptAddendum.js +67 -0
  38. package/dist/prompts/failureModePromptAddendum.js.map +1 -0
  39. package/package.json +14 -14
@@ -14,7 +14,7 @@ import { GeminiRunner } from "cyrus-gemini-runner";
14
14
  import { extractCommentAuthor, extractCommentBody, extractCommentId, extractCommentUrl, extractPRBaseBranchRef, extractPRBranchRef, extractPRNumber, extractPRTitle, extractRepoFullName, extractRepoName, extractRepoOwner, extractSessionKey, GitHubAppTokenProvider, GitHubCommentService, GitHubEventTransport, isCommentOnPullRequest, isIssueCommentPayload, isPullRequestReviewCommentPayload, isPullRequestReviewPayload, stripMention, } from "cyrus-github-event-transport";
15
15
  import { extractDiscussionId, extractSessionKey as extractGitLabSessionKey, extractMRBaseBranchRef, extractMRBranchRef, extractMRIid, extractMRTitle, extractNoteAuthor, extractNoteBody, extractNoteId, extractNoteUrl, extractProjectId, extractProjectPath, GitLabCommentService, GitLabEventTransport, isNoteOnMergeRequest, stripMention as stripGitLabMention, } from "cyrus-gitlab-event-transport";
16
16
  import { LinearEventTransport, LinearIssueTrackerService, } from "cyrus-linear-event-transport";
17
- import { createCyrusToolsServer, } from "cyrus-mcp-tools";
17
+ import { createCyrusToolsServer, createFetchFailureModesClient, } from "cyrus-mcp-tools";
18
18
  import { SlackEventTransport, } from "cyrus-slack-event-transport";
19
19
  import { Sessions, streamableHttp } from "fastify-mcp";
20
20
  import { ActivityPoster } from "./ActivityPoster.js";
@@ -34,7 +34,7 @@ import { RepositoryRouter, } from "./RepositoryRouter.js";
34
34
  import { RunnerConfigBuilder } from "./RunnerConfigBuilder.js";
35
35
  import { RunnerSelectionService } from "./RunnerSelectionService.js";
36
36
  import { SharedApplicationServer } from "./SharedApplicationServer.js";
37
- import { SkillsPluginResolver } from "./SkillsPluginResolver.js";
37
+ import { SkillsPluginResolver, } from "./SkillsPluginResolver.js";
38
38
  import { SlackChatAdapter } from "./SlackChatAdapter.js";
39
39
  import { LinearActivitySink } from "./sinks/LinearActivitySink.js";
40
40
  import { ToolPermissionResolver } from "./ToolPermissionResolver.js";
@@ -158,8 +158,22 @@ export class EdgeWorker extends EventEmitter {
158
158
  }
159
159
  // Initialize GitHub comment service for posting replies to GitHub PRs
160
160
  this.gitHubCommentService = new GitHubCommentService();
161
- // Initialize GitLab comment service for posting replies to GitLab MRs
162
- this.gitLabCommentService = new GitLabCommentService();
161
+ // Initialize GitLab comment service for posting replies to GitLab MRs.
162
+ // For Self-Managed GitLab the API base URL must be derived from the
163
+ // configured repos' gitlabUrl host; otherwise the service falls back to
164
+ // gitlab.com and 404s on every reply. Picks the first configured
165
+ // GitLab repo's host (single GitLab host per Cyrus instance).
166
+ const firstGitlabRepo = config.repositories.find((r) => r.gitlabUrl);
167
+ let gitlabApiBaseUrl;
168
+ if (firstGitlabRepo?.gitlabUrl) {
169
+ try {
170
+ gitlabApiBaseUrl = new URL(firstGitlabRepo.gitlabUrl).origin;
171
+ }
172
+ catch {
173
+ // malformed gitlabUrl — leave undefined and fall through to default
174
+ }
175
+ }
176
+ this.gitLabCommentService = new GitLabCommentService(gitlabApiBaseUrl ? { apiBaseUrl: gitlabApiBaseUrl } : undefined);
163
177
  // Initialize global session registry (centralized session storage)
164
178
  this.globalSessionRegistry = new GlobalSessionRegistry();
165
179
  // Initialize repository router with dependencies
@@ -234,7 +248,7 @@ export class EdgeWorker extends EventEmitter {
234
248
  this.logger.error(`No repository found for child session ${childSessionId}`);
235
249
  return;
236
250
  }
237
- await this.handleResumeParentSession(parentSessionId, prompt, childSessionId, repo, this.agentSessionManager);
251
+ await this.handleResumeParentSession(parentSessionId, prompt, childSessionId);
238
252
  });
239
253
  // Initialize repositories with path resolution
240
254
  for (const repo of config.repositories) {
@@ -632,6 +646,9 @@ export class EdgeWorker extends EventEmitter {
632
646
  fallbackModel: this.getDefaultFallbackModelForRunner(runnerType),
633
647
  });
634
648
  },
649
+ // Live read so hot-reloaded config (`setConfig`) picks up new
650
+ // per-platform MCP paths without rebuilding the handler.
651
+ getPlatformMcpConfigOverrides: () => this.config.slackMcpConfigs,
635
652
  onWebhookStart: () => {
636
653
  this.activeWebhookCount++;
637
654
  },
@@ -868,9 +885,11 @@ export class EdgeWorker extends EventEmitter {
868
885
  const systemPrompt = isPullRequestReview
869
886
  ? this.buildGitHubChangeRequestSystemPrompt(event, branchRef, taskInstructions)
870
887
  : this.buildGitHubSystemPrompt(event, branchRef, taskInstructions);
871
- // Build allowed tools and directories
872
- // Exclude Slack MCP tools from GitHub sessions
873
- const allowedTools = this.buildAllowedTools(repository).filter((t) => t !== "mcp__slack");
888
+ // Build allowed tools using the GitHub platform resolver, which honors
889
+ // `githubAllowedTools` on the workspace config and falls back to
890
+ // `GITHUB_DEFAULT_ALLOWED_TOOLS` (which intentionally omits
891
+ // `mcp__slack` — no subtractive filtering needed).
892
+ const allowedTools = this.toolPermissionResolver.buildGithubAllowedTools(repository);
874
893
  const disallowedTools = this.buildDisallowedTools(repository);
875
894
  const allowedDirectories = [repository.repositoryPath];
876
895
  // Create agent runner using the standard config builder
@@ -878,7 +897,9 @@ export class EdgeWorker extends EventEmitter {
878
897
  undefined, // labels
879
898
  undefined, // issueDescription
880
899
  200, // maxTurns
881
- { excludeSlackMcp: true });
900
+ undefined, // linearWorkspaceId
901
+ undefined, // skillContext
902
+ "github");
882
903
  const runner = this.createRunnerForType(runnerType, runnerConfig);
883
904
  // Store the runner in the session manager
884
905
  agentSessionManager.addAgentRunner(githubSessionId, runner);
@@ -1341,9 +1362,10 @@ ${taskSection}`;
1341
1362
  const systemPrompt = isMergeRequestEvent
1342
1363
  ? this.buildGitLabChangeRequestSystemPrompt(event, branchRef, taskInstructions)
1343
1364
  : this.buildGitLabSystemPrompt(event, branchRef, taskInstructions);
1344
- // Build allowed tools and directories
1345
- // Exclude Slack MCP tools from GitLab sessions
1346
- const allowedTools = this.buildAllowedTools(repository).filter((t) => t !== "mcp__slack");
1365
+ // Build allowed tools using the GitHub platform resolver — GitLab and
1366
+ // GitHub share the same PR-targeted, single-repo intent, so they use
1367
+ // the same `githubAllowedTools` knob and the same `GITHUB_*` default.
1368
+ const allowedTools = this.toolPermissionResolver.buildGithubAllowedTools(repository);
1347
1369
  const disallowedTools = this.buildDisallowedTools(repository);
1348
1370
  const allowedDirectories = [repository.repositoryPath];
1349
1371
  // Create agent runner using the standard config builder
@@ -1351,7 +1373,9 @@ ${taskSection}`;
1351
1373
  undefined, // labels
1352
1374
  undefined, // issueDescription
1353
1375
  200, // maxTurns
1354
- { excludeSlackMcp: true });
1376
+ undefined, // linearWorkspaceId
1377
+ undefined, // skillContext
1378
+ "gitlab");
1355
1379
  const runner = this.createRunnerForType(runnerType, runnerConfig);
1356
1380
  // Store the runner in the session manager
1357
1381
  agentSessionManager.addAgentRunner(gitlabSessionId, runner);
@@ -1588,6 +1612,62 @@ ${taskSection}`;
1588
1612
  }
1589
1613
  return "idle";
1590
1614
  }
1615
+ /**
1616
+ * Test-only: dispatch a synthetic Slack webhook event through the chat
1617
+ * session handler. Used by the F1 test harness to exercise the Slack →
1618
+ * ClaudeRunner code path end-to-end without a real Slack signature.
1619
+ */
1620
+ async dispatchChatTestEvent(event) {
1621
+ if (!this.chatSessionHandler) {
1622
+ throw new Error("chatSessionHandler not initialized");
1623
+ }
1624
+ await this.chatSessionHandler.handleEvent(event);
1625
+ }
1626
+ /**
1627
+ * Public accessor for the shared Fastify-based application server.
1628
+ * Used by F1 to register test-only routes alongside production webhook routes.
1629
+ */
1630
+ getSharedApplicationServer() {
1631
+ return this.sharedApplicationServer;
1632
+ }
1633
+ /**
1634
+ * Test-only: list active chat threads (threadKey → sessionId).
1635
+ */
1636
+ listChatThreads() {
1637
+ if (!this.chatSessionHandler)
1638
+ return [];
1639
+ return this.chatSessionHandler.listThreads();
1640
+ }
1641
+ /**
1642
+ * Test-only: fetch the last assistant text reply for a chat thread.
1643
+ * Returns null when the thread or runner is unknown, or no assistant
1644
+ * message has been produced yet.
1645
+ */
1646
+ getChatThreadLastReply(threadKey) {
1647
+ if (!this.chatSessionHandler)
1648
+ return null;
1649
+ const runner = this.chatSessionHandler.getRunnerForThread(threadKey);
1650
+ if (!runner)
1651
+ return null;
1652
+ const messages = runner.getMessages();
1653
+ const lastAssistant = [...messages]
1654
+ .reverse()
1655
+ .find((m) => m.type === "assistant");
1656
+ let text = "";
1657
+ if (lastAssistant &&
1658
+ lastAssistant.type === "assistant" &&
1659
+ "message" in lastAssistant) {
1660
+ const msg = lastAssistant;
1661
+ const block = msg.message.content?.find((b) => b.type === "text" && b.text);
1662
+ if (block?.text)
1663
+ text = block.text;
1664
+ }
1665
+ return {
1666
+ text,
1667
+ isRunning: runner.isRunning(),
1668
+ messageCount: messages.length,
1669
+ };
1670
+ }
1591
1671
  /**
1592
1672
  * Stop the edge worker
1593
1673
  */
@@ -1752,7 +1832,7 @@ ${taskSection}`;
1752
1832
  * This is the core logic used by the resume parent session callback
1753
1833
  * Extracted to reduce duplication between constructor and addNewRepositories
1754
1834
  */
1755
- async handleResumeParentSession(parentSessionId, prompt, childSessionId, _childRepo, childAgentSessionManager) {
1835
+ async handleResumeParentSession(parentSessionId, prompt, childSessionId) {
1756
1836
  const log = this.logger.withContext({ sessionId: parentSessionId });
1757
1837
  log.info(`Child session completed, resuming parent session ${parentSessionId}`);
1758
1838
  // Find parent session from the single session manager
@@ -1771,8 +1851,7 @@ ${taskSection}`;
1771
1851
  const parentWorkspaceId = requireLinearWorkspaceId(parentRepo);
1772
1852
  log.debug(`Found parent session - Issue: ${parentSession.issueId}, Workspace: ${parentSession.workspace.path}`);
1773
1853
  // Get the child session to access its workspace path
1774
- // Child session is in the child's manager (passed in from the callback)
1775
- const childSession = childAgentSessionManager.getSession(childSessionId);
1854
+ const childSession = this.agentSessionManager.getSession(childSessionId);
1776
1855
  const childWorkspaceDirs = [];
1777
1856
  if (childSession) {
1778
1857
  childWorkspaceDirs.push(childSession.workspace.path);
@@ -2928,8 +3007,7 @@ ${taskSection}`;
2928
3007
  labels, // Pass labels for runner selection and model override
2929
3008
  fullIssue.description || undefined, // Description tags can override label selectors
2930
3009
  undefined, // maxTurns
2931
- undefined, // mcpOptions
2932
- linearWorkspaceId);
3010
+ linearWorkspaceId, this.buildSkillSessionContext(primaryRepo, fullIssue));
2933
3011
  log.debug(`Label-based runner selection for new session: ${runnerType} (session ${sessionId})`);
2934
3012
  const runner = this.createRunnerForType(runnerType, runnerConfig);
2935
3013
  // Store runner by comment ID
@@ -3375,6 +3453,28 @@ ${taskSection}`;
3375
3453
  async fetchIssueLabels(issue) {
3376
3454
  return this.promptBuilder.fetchIssueLabels(issue);
3377
3455
  }
3456
+ /**
3457
+ * Build the session context used to evaluate per-skill scope restrictions.
3458
+ *
3459
+ * Skill scopes (persisted in `scope.json` sidecars by the config-updater)
3460
+ * match against:
3461
+ * - the active repository's Cyrus config ID,
3462
+ * - the Linear team that owns the issue, and
3463
+ * - the Linear label IDs attached to the issue.
3464
+ */
3465
+ buildSkillSessionContext(repository, fullIssue) {
3466
+ const context = {
3467
+ repositoryId: repository.id,
3468
+ };
3469
+ if (fullIssue?.teamId) {
3470
+ context.linearTeamId = fullIssue.teamId;
3471
+ }
3472
+ if (Array.isArray(fullIssue?.labelIds) &&
3473
+ (fullIssue?.labelIds?.length ?? 0) > 0) {
3474
+ context.linearLabelIds = [...(fullIssue?.labelIds ?? [])];
3475
+ }
3476
+ return context;
3477
+ }
3378
3478
  /**
3379
3479
  * Resolve default model for a given runner from config with sensible built-in defaults.
3380
3480
  * Supports legacy config keys for backwards compatibility.
@@ -3654,8 +3754,132 @@ ${taskSection}`;
3654
3754
  this.cyrusToolsMcpRegistered = true;
3655
3755
  console.log(`✅ Cyrus tools MCP endpoint registered at ${this.cyrusToolsMcpEndpoint}`);
3656
3756
  }
3657
- createCyrusToolsOptions(parentSessionId) {
3757
+ failureModesClient = null;
3758
+ /**
3759
+ * Lazily build the HTTP client used by `log_failure_mode` to POST to
3760
+ * cyrus-hosted. Uses `CYRUS_APP_URL` (the same env var the remote
3761
+ * session-store client reads, see top of this file) so preview
3762
+ * environments and prod share a single way to point at a control
3763
+ * plane. Returns null when either the URL or the `CYRUS_API_KEY` are
3764
+ * missing — in that mode the tool is simply not registered, so
3765
+ * customer-mode CLI users without a control plane don't see a broken
3766
+ * tool.
3767
+ */
3768
+ getFailureModesClient() {
3769
+ if (this.failureModesClient)
3770
+ return this.failureModesClient;
3771
+ const apiKey = process.env.CYRUS_API_KEY?.trim();
3772
+ const baseUrl = process.env.CYRUS_APP_URL?.trim();
3773
+ if (!apiKey || !baseUrl)
3774
+ return null;
3775
+ this.failureModesClient = createFetchFailureModesClient({
3776
+ baseUrl,
3777
+ apiKey,
3778
+ });
3779
+ return this.failureModesClient;
3780
+ }
3781
+ /**
3782
+ * Resolve a working-directory string to the agent session id that owns
3783
+ * that workspace. The `log_failure_mode` MCP tool calls this with the
3784
+ * agent's reported `cwd`. We normalize and compare against each known
3785
+ * session's `workspace.path` (and any sub-repo paths the session opens).
3786
+ */
3787
+ /**
3788
+ * Resolve a working-directory string to the rich session bundle a
3789
+ * Cyrus team member needs to triage a failure-mode report: the
3790
+ * internal session id (for dedup), the runner session id + runner
3791
+ * type (so triage can pull the Claude/Gemini/Codex/Cursor transcript),
3792
+ * the Linear AgentSession + source-issue identifiers (so triage can
3793
+ * jump to the customer thread), and the workspace path (for repro).
3794
+ *
3795
+ * Returns null only when no session matches. We prefer an exact
3796
+ * workspace-path or sub-repo-path match; if neither hits, we fall
3797
+ * back to a prefix match for nested cwds (e.g. shells in a subdir).
3798
+ */
3799
+ /**
3800
+ * Aggregator over every place active sessions live in this process.
3801
+ * Today: the primary AgentSessionManager (issue sessions) and the
3802
+ * ChatSessionHandler's private one (Slack / GitHub-PR-chat / future
3803
+ * chat platforms). New session origins should be added here so
3804
+ * downstream consumers (currently just resolveSessionFromCwd) keep
3805
+ * working without modification — single open extension point (OCP),
3806
+ * single responsibility (SRP: this method's only job is "where do
3807
+ * sessions live?", separate from "how do we match one by cwd?").
3808
+ */
3809
+ getAllKnownSessions() {
3810
+ return [
3811
+ ...this.agentSessionManager.getAllSessions(),
3812
+ ...(this.chatSessionHandler?.getAllChatSessions() ?? []),
3813
+ ];
3814
+ }
3815
+ resolveSessionFromCwd(cwd) {
3816
+ if (!cwd)
3817
+ return null;
3818
+ const normalize = (p) => p.replace(/\/+$/, "");
3819
+ const target = normalize(cwd);
3820
+ const sessions = this.getAllKnownSessions();
3821
+ const exact = sessions.find((session) => {
3822
+ if (normalize(session.workspace?.path ?? "") === target)
3823
+ return true;
3824
+ const repoPaths = session.workspace?.repoPaths;
3825
+ if (repoPaths) {
3826
+ for (const p of Object.values(repoPaths)) {
3827
+ if (typeof p === "string" && normalize(p) === target)
3828
+ return true;
3829
+ }
3830
+ }
3831
+ return false;
3832
+ });
3833
+ const prefix = exact
3834
+ ? undefined
3835
+ : sessions.find((session) => {
3836
+ const root = normalize(session.workspace?.path ?? "");
3837
+ return root && target.startsWith(`${root}/`);
3838
+ });
3839
+ const session = exact ?? prefix;
3840
+ if (!session)
3841
+ return null;
3842
+ const runnerType = session.claudeSessionId
3843
+ ? "claude"
3844
+ : session.geminiSessionId
3845
+ ? "gemini"
3846
+ : session.codexSessionId
3847
+ ? "codex"
3848
+ : session.cursorSessionId
3849
+ ? "cursor"
3850
+ : null;
3851
+ const runnerSessionId = session.claudeSessionId ??
3852
+ session.geminiSessionId ??
3853
+ session.codexSessionId ??
3854
+ session.cursorSessionId ??
3855
+ null;
3856
+ const sessionSource = session.id.startsWith("github-")
3857
+ ? "github"
3858
+ : session.id.startsWith("gitlab-")
3859
+ ? "gitlab"
3860
+ : session.id.startsWith("slack-")
3861
+ ? "slack"
3862
+ : (session.issueContext?.trackerId ?? "linear");
3863
+ // For Linear-source sessions, `session.id` is already the Linear
3864
+ // AgentSession id (they're literally the same UUID — the v3 rename
3865
+ // from `linearAgentActivitySessionId` to `id` kept the value). So we
3866
+ // don't surface a separate `linearAgentSessionId` — the server keys
3867
+ // dedup on `session_id` and that *is* the Linear AgentSession id when
3868
+ // `session_source === 'linear'`.
3658
3869
  return {
3870
+ sessionId: session.id,
3871
+ runnerSessionId,
3872
+ runnerType,
3873
+ sourceIssueIdentifier: session.issueContext?.issueIdentifier ??
3874
+ session.issue?.identifier ??
3875
+ null,
3876
+ workspacePath: session.workspace?.path ?? null,
3877
+ sessionSource,
3878
+ };
3879
+ }
3880
+ createCyrusToolsOptions(parentSessionId) {
3881
+ const failureModesClient = this.getFailureModesClient();
3882
+ const options = {
3659
3883
  parentSessionId,
3660
3884
  onSessionCreated: (childSessionId, parentId) => {
3661
3885
  this.handleChildSessionMapping(childSessionId, parentId);
@@ -3664,6 +3888,13 @@ ${taskSection}`;
3664
3888
  return this.handleFeedbackDeliveryToChildSession(childSessionId, message);
3665
3889
  },
3666
3890
  };
3891
+ if (failureModesClient) {
3892
+ options.failureModes = {
3893
+ resolveSessionFromCwd: (cwd) => this.resolveSessionFromCwd(cwd),
3894
+ httpClient: failureModesClient,
3895
+ };
3896
+ }
3897
+ return options;
3667
3898
  }
3668
3899
  handleChildSessionMapping(childSessionId, parentSessionId) {
3669
3900
  console.log(`[EdgeWorker] Agent session created: ${childSessionId}, mapping to parent ${parentSessionId}`);
@@ -3846,8 +4077,12 @@ ${taskSection}`;
3846
4077
  const sharedInstructions = await this.loadSharedInstructions();
3847
4078
  systemPrompt = sharedInstructions;
3848
4079
  }
3849
- // 3. Append skills guidance — instruct the agent to use skills based on context
3850
- systemPrompt += await this.skillsPluginResolver.buildSkillsGuidance();
4080
+ // 3. Append skills guidance — instruct the agent to use skills based on context.
4081
+ // Skills hidden by per-skill scope (repo / Linear team / Linear label) are
4082
+ // omitted from the guidance so the model doesn't reference skills it
4083
+ // cannot invoke.
4084
+ const skillsContext = this.buildSkillSessionContext(repositories[0], input.fullIssue);
4085
+ systemPrompt += await this.skillsPluginResolver.buildSkillsGuidance(undefined, skillsContext);
3851
4086
  // 4. Append agent context — dynamic values for skills to reference
3852
4087
  systemPrompt += this.buildAgentContextBlock();
3853
4088
  // 5. Build issue context using appropriate builder
@@ -4004,12 +4239,25 @@ ${input.userComment}
4004
4239
  * Delegates to RunnerConfigBuilder for shared config assembly.
4005
4240
  * @returns Object containing the runner config and runner type to use
4006
4241
  */
4007
- async buildAgentRunnerConfig(session, repository, sessionId, systemPrompt, allowedTools, allowedDirectories, disallowedTools, resumeSessionId, labels, issueDescription, maxTurns, mcpOptions, linearWorkspaceId) {
4242
+ async buildAgentRunnerConfig(session, repository, sessionId, systemPrompt, allowedTools, allowedDirectories, disallowedTools, resumeSessionId, labels, issueDescription, maxTurns, linearWorkspaceId, skillContext,
4243
+ /**
4244
+ * Which platform initiated the session — drives which
4245
+ * `EdgeWorkerConfig.<platform>McpConfigs` override list applies.
4246
+ * Defaults to `"linear"` (the pre-platform-aware behavior).
4247
+ */
4248
+ sessionPlatform = "linear") {
4008
4249
  const log = this.logger.withContext({
4009
4250
  sessionId,
4010
4251
  platform: session.issueContext?.trackerId,
4011
4252
  issueIdentifier: session.issueContext?.issueIdentifier,
4012
4253
  });
4254
+ // Resolve plugins once so we can also derive the per-session scoped
4255
+ // skill allow-list from the same filesystem snapshot.
4256
+ const plugins = await this.skillsPluginResolver.resolve();
4257
+ const resolvedSkillContext = skillContext ?? {
4258
+ repositoryId: repository.id,
4259
+ };
4260
+ const allowedSkillNames = await this.skillsPluginResolver.discoverSkillNames(plugins, resolvedSkillContext);
4013
4261
  const result = this.runnerConfigBuilder.buildIssueConfig({
4014
4262
  session,
4015
4263
  repository,
@@ -4022,11 +4270,21 @@ ${input.userComment}
4022
4270
  labels,
4023
4271
  issueDescription,
4024
4272
  maxTurns,
4025
- mcpOptions,
4273
+ // Per-platform MCP config paths — GitHub + GitLab share the
4274
+ // `githubMcpConfigs` knob (single-repo PR contexts both); Linear
4275
+ // gets `linearMcpConfigs`. Not a blanket override: the builder
4276
+ // uses `repository.mcpConfigPath` when this repo has its own
4277
+ // `allowedTools` override (so the repo's permission rules and
4278
+ // MCP server set travel as a unit), and only falls through to
4279
+ // this list when the repo inherits the platform allow-list.
4280
+ platformMcpConfigOverrides: sessionPlatform === "linear"
4281
+ ? this.config.linearMcpConfigs
4282
+ : this.config.githubMcpConfigs,
4026
4283
  linearWorkspaceId,
4027
4284
  cyrusHome: this.cyrusHome,
4028
4285
  logger: log,
4029
- plugins: await this.skillsPluginResolver.resolve(),
4286
+ plugins,
4287
+ skills: allowedSkillNames,
4030
4288
  sandboxSettings: this.sdkSandboxSettings ?? undefined,
4031
4289
  egressCaCertPath: this.egressCaCertPath ?? undefined,
4032
4290
  onMessage: (message) => {
@@ -4220,8 +4478,21 @@ ${input.userComment}
4220
4478
  // Build MCP config for this session (same as the live runner would use)
4221
4479
  const linearWorkspaceId = requireLinearWorkspaceId(repo);
4222
4480
  const mcpConfig = this.mcpConfigService.buildMcpConfig(repo.id, linearWorkspaceId, session.id);
4223
- // Merge any file-based MCP configs (reuses shared normalization)
4224
- const mcpConfigPath = this.mcpConfigService.buildMergedMcpConfigPath(repo);
4481
+ // Merge any file-based MCP configs (reuses shared normalization).
4482
+ // Warmup paths reconstruct Linear-triggered issue sessions:
4483
+ // if the repo has its own `allowedTools` override its
4484
+ // mcpConfigPath stays scoped to that repo, otherwise the
4485
+ // team-level `linearMcpConfigs` list applies. Same coupling
4486
+ // the live `buildIssueConfig` path uses.
4487
+ const repoHasAllowedToolsOverride = Array.isArray(repo.allowedTools) && repo.allowedTools.length > 0;
4488
+ const mcpConfigPath = repoHasAllowedToolsOverride
4489
+ ? this.mcpConfigService.buildMergedMcpConfigPath(repo)
4490
+ : this.config.linearMcpConfigs &&
4491
+ this.config.linearMcpConfigs.length > 0
4492
+ ? this.config.linearMcpConfigs.length === 1
4493
+ ? this.config.linearMcpConfigs[0]
4494
+ : [...this.config.linearMcpConfigs]
4495
+ : undefined;
4225
4496
  let mcpServers = { ...mcpConfig };
4226
4497
  if (mcpConfigPath) {
4227
4498
  const paths = Array.isArray(mcpConfigPath)
@@ -4514,8 +4785,7 @@ ${input.userComment}
4514
4785
  const { config: runnerConfig, runnerType } = await this.buildAgentRunnerConfig(session, repository, sessionId, systemPrompt, allowedTools, allowedDirectories, disallowedTools, resumeSessionId, labels, // Always pass labels to preserve model override
4515
4786
  fullIssue.description || undefined, // Description tags can override label selectors
4516
4787
  maxTurns, // Pass maxTurns if specified
4517
- undefined, // mcpOptions
4518
- resolvedWorkspaceId);
4788
+ resolvedWorkspaceId, this.buildSkillSessionContext(repository, fullIssue));
4519
4789
  // Create the appropriate runner based on session state
4520
4790
  const runner = this.createRunnerForType(runnerType, runnerConfig);
4521
4791
  // Store runner