cyrus-edge-worker 0.2.49 → 0.2.50

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 (43) hide show
  1. package/dist/ActivityPoster.d.ts +1 -0
  2. package/dist/ActivityPoster.d.ts.map +1 -1
  3. package/dist/ActivityPoster.js +11 -0
  4. package/dist/ActivityPoster.js.map +1 -1
  5. package/dist/AgentSessionManager.d.ts +27 -1
  6. package/dist/AgentSessionManager.d.ts.map +1 -1
  7. package/dist/AgentSessionManager.js +110 -28
  8. package/dist/AgentSessionManager.js.map +1 -1
  9. package/dist/AttachmentService.d.ts +1 -1
  10. package/dist/AttachmentService.d.ts.map +1 -1
  11. package/dist/AttachmentService.js +13 -1
  12. package/dist/AttachmentService.js.map +1 -1
  13. package/dist/ChatSessionHandler.d.ts +13 -1
  14. package/dist/ChatSessionHandler.d.ts.map +1 -1
  15. package/dist/ChatSessionHandler.js +93 -30
  16. package/dist/ChatSessionHandler.js.map +1 -1
  17. package/dist/ConfigManager.d.ts.map +1 -1
  18. package/dist/ConfigManager.js +5 -0
  19. package/dist/ConfigManager.js.map +1 -1
  20. package/dist/EdgeWorker.d.ts +69 -0
  21. package/dist/EdgeWorker.d.ts.map +1 -1
  22. package/dist/EdgeWorker.js +522 -18
  23. package/dist/EdgeWorker.js.map +1 -1
  24. package/dist/EgressProxy.d.ts.map +1 -1
  25. package/dist/EgressProxy.js +14 -9
  26. package/dist/EgressProxy.js.map +1 -1
  27. package/dist/McpConfigService.d.ts +1 -1
  28. package/dist/McpConfigService.d.ts.map +1 -1
  29. package/dist/McpConfigService.js +1 -1
  30. package/dist/McpConfigService.js.map +1 -1
  31. package/dist/RunnerConfigBuilder.d.ts +13 -4
  32. package/dist/RunnerConfigBuilder.d.ts.map +1 -1
  33. package/dist/RunnerConfigBuilder.js +111 -21
  34. package/dist/RunnerConfigBuilder.js.map +1 -1
  35. package/dist/RunnerSelectionService.js +2 -2
  36. package/dist/RunnerSelectionService.js.map +1 -1
  37. package/dist/SharedApplicationServer.js +1 -1
  38. package/dist/SharedApplicationServer.js.map +1 -1
  39. package/dist/hooks/PrMarkerHook.d.ts +58 -0
  40. package/dist/hooks/PrMarkerHook.d.ts.map +1 -0
  41. package/dist/hooks/PrMarkerHook.js +149 -0
  42. package/dist/hooks/PrMarkerHook.js.map +1 -0
  43. package/package.json +15 -14
@@ -1,13 +1,14 @@
1
1
  import { AsyncLocalStorage } from "node:async_hooks";
2
2
  import { execSync } from "node:child_process";
3
3
  import { EventEmitter } from "node:events";
4
+ import { existsSync, readFileSync } from "node:fs";
4
5
  import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
5
6
  import { basename, join } from "node:path";
6
7
  import { LinearClient } from "@linear/sdk";
7
- import { ClaudeRunner } from "cyrus-claude-runner";
8
+ import { buildBaseSessionEnv, ClaudeRunner, HttpSessionStore, normalizeMcpHttpTransport, } from "cyrus-claude-runner";
8
9
  import { CodexRunner } from "cyrus-codex-runner";
9
10
  import { ConfigUpdater } from "cyrus-config-updater";
10
- import { CLIIssueTrackerService, CLIRPCServer, createLogger, DEFAULT_PROXY_URL, isAgentSessionCreatedWebhook, isAgentSessionPromptedWebhook, isContentUpdateMessage, isIssueAssignedWebhook, isIssueCommentMentionWebhook, isIssueDeletedWebhook, isIssueNewCommentWebhook, isIssueStateChangeMessage, isIssueStateChangeWebhook, isIssueTitleOrDescriptionUpdateWebhook, isIssueUnassignedWebhook, isSessionStartMessage, isStopSignalMessage, isUnassignMessage, isUserPromptMessage, PersistenceManager, requireLinearWorkspaceId, resolvePath, WebhookIpValidator, } from "cyrus-core";
11
+ import { CLIIssueTrackerService, CLIRPCServer, createLogger, DEFAULT_PROXY_URL, isAgentSessionCreatedWebhook, isAgentSessionPromptedWebhook, isContentUpdateMessage, isIssueAssignedWebhook, isIssueCommentMentionWebhook, isIssueDeletedWebhook, isIssueNewCommentWebhook, isIssueStateChangeMessage, isIssueStateChangeWebhook, isIssueStateIdUpdateWebhook, isIssueTitleOrDescriptionUpdateWebhook, isIssueUnassignedWebhook, isSessionStartMessage, isStopSignalMessage, isUnassignMessage, isUserPromptMessage, PersistenceManager, requireLinearWorkspaceId, resolvePath, WebhookIpValidator, } from "cyrus-core";
11
12
  import { CursorRunner } from "cyrus-cursor-runner";
12
13
  import { GeminiRunner } from "cyrus-gemini-runner";
13
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";
@@ -50,6 +51,8 @@ export class EdgeWorker extends EventEmitter {
50
51
  agentSessionManager; // Single instance managing all agent sessions across repositories
51
52
  activitySinks = new Map(); // Maps Linear workspace ID to activity sink (one per workspace, mirrors issueTrackers)
52
53
  sessionRepositories = new Map(); // Maps session ID to repository ID
54
+ lastStopTimeBySession = new Map(); // Maps session ID to timestamp of last stop signal (for double-stop detection)
55
+ warmInstances = new Map(); // Pre-warmed Claude sessions keyed by agentSessionId
53
56
  issueTrackers = new Map(); // one issue tracker per Linear workspace (keyed by linearWorkspaceId)
54
57
  linearEventTransport = null; // Single event transport for webhook delivery
55
58
  gitHubEventTransport = null; // GitHub event transport for forwarded GitHub webhooks
@@ -98,18 +101,61 @@ export class EdgeWorker extends EventEmitter {
98
101
  sdkSandboxSettings = null;
99
102
  /** CA cert path for MITM TLS termination (passed per-session env, not process.env) */
100
103
  egressCaCertPath = null;
104
+ /**
105
+ * Remote SessionStore that mirrors Claude SDK transcripts to the Cyrus
106
+ * hosted control plane. Enabled when all three of `CYRUS_APP_URL`,
107
+ * `CYRUS_API_KEY`, and `CYRUS_TEAM_ID` are set — used by any Claude
108
+ * runner spawned from this worker so transcripts survive ephemeral
109
+ * worktrees and are resumable from any host.
110
+ */
111
+ claudeSessionStore = null;
101
112
  /**
102
113
  * Tracks recently processed issue-update webhook keys to prevent
103
114
  * duplicate deliveries from Linear's at-least-once delivery.
104
115
  * Key format: `${createdAt}:${issueId}`
105
116
  */
106
117
  processedIssueUpdateKeys = new Set();
118
+ /**
119
+ * Sessions parked due to blocked-by dependencies.
120
+ * Key: Linear issue ID (the blocked issue)
121
+ * Value: All data needed to replay initializeAgentRunner when unblocked
122
+ */
123
+ parkedSessions = new Map();
107
124
  constructor(config) {
108
125
  super();
109
126
  this.config = config;
110
127
  this.cyrusHome = config.cyrusHome;
111
128
  this.logger = createLogger({ component: "EdgeWorker" });
112
129
  this.persistenceManager = new PersistenceManager(join(this.cyrusHome, "state"));
130
+ // Mirror Claude SDK session transcripts to the hosted control plane
131
+ // when CYRUS_APP_URL (destination), CYRUS_API_KEY (proof of team
132
+ // ownership), and CYRUS_TEAM_ID (which team the transcripts belong to)
133
+ // are all configured. If any is missing the store stays null and the
134
+ // SDK falls back to local JSONL only. Operators can also opt out
135
+ // explicitly by setting CYRUS_DISABLE_REMOTE_SESSION_STORE=1, which
136
+ // keeps transcripts local even when the three vars above are present.
137
+ const sessionStoreBaseUrl = process.env.CYRUS_APP_URL;
138
+ const sessionStoreApiKey = process.env.CYRUS_API_KEY;
139
+ const sessionStoreTeamId = process.env.CYRUS_TEAM_ID;
140
+ const sessionStoreDisabled = this.isRemoteSessionStoreDisabled();
141
+ if (!sessionStoreDisabled &&
142
+ sessionStoreBaseUrl &&
143
+ sessionStoreApiKey &&
144
+ sessionStoreTeamId) {
145
+ this.claudeSessionStore = new HttpSessionStore({
146
+ baseUrl: sessionStoreBaseUrl,
147
+ apiKey: sessionStoreApiKey,
148
+ teamId: sessionStoreTeamId,
149
+ logger: this.logger,
150
+ });
151
+ this.logger.info(`[SessionStore] Mirroring Claude sessions to ${sessionStoreBaseUrl} for team ${sessionStoreTeamId}`);
152
+ }
153
+ else if (sessionStoreDisabled &&
154
+ sessionStoreBaseUrl &&
155
+ sessionStoreApiKey &&
156
+ sessionStoreTeamId) {
157
+ this.logger.info("[SessionStore] Remote session store disabled via CYRUS_DISABLE_REMOTE_SESSION_STORE; transcripts will stay local.");
158
+ }
113
159
  // Initialize GitHub comment service for posting replies to GitHub PRs
114
160
  this.gitHubCommentService = new GitHubCommentService();
115
161
  // Initialize GitLab comment service for posting replies to GitLab MRs
@@ -270,6 +316,14 @@ export class EdgeWorker extends EventEmitter {
270
316
  await this.skillsPluginResolver.ensureUserPluginScaffolded();
271
317
  // Load persisted state for each repository
272
318
  await this.loadPersistedState();
319
+ // Pre-warm the 30 most recent Claude sessions in the background
320
+ // so their first query after restart has near-zero cold-start latency.
321
+ // Disabled by default; opt in with CYRUS_ENABLE_WARM_SESSIONS=1.
322
+ if (this.isWarmSessionsEnabled()) {
323
+ this.warmupRecentSessions(30).catch((err) => {
324
+ this.logger.warn("Session warmup failed (non-fatal):", err);
325
+ });
326
+ }
273
327
  // Start config file watcher via ConfigManager
274
328
  this.configManager.on("configChanged", async (changes) => {
275
329
  this.updateLinearWorkspaceTokens(changes.newConfig);
@@ -327,7 +381,19 @@ export class EdgeWorker extends EventEmitter {
327
381
  async initializeComponents() {
328
382
  // 1. Platform-specific initialization
329
383
  if (this.config.platform === "cli") {
330
- // CLI mode: find any available CLIIssueTrackerService
384
+ // CLI mode: ensure a CLIIssueTrackerService exists for each repo workspace.
385
+ // Repos from config.repositories don't go through linearWorkspaces init,
386
+ // so we create trackers here if missing.
387
+ for (const [repoId, repo] of this.repositories) {
388
+ const wsId = repo.linearWorkspaceId;
389
+ if (wsId && !this.issueTrackers.has(wsId)) {
390
+ const service = new CLIIssueTrackerService();
391
+ service.seedDefaultData();
392
+ this.issueTrackers.set(wsId, service);
393
+ const activitySink = new LinearActivitySink(service, wsId);
394
+ this.activitySinks.set(repoId, activitySink);
395
+ }
396
+ }
331
397
  const firstCliTracker = Array.from(this.issueTrackers.values()).find((tracker) => tracker instanceof CLIIssueTrackerService);
332
398
  if (firstCliTracker) {
333
399
  this.cliRPCServer = new CLIRPCServer({
@@ -388,7 +454,7 @@ export class EdgeWorker extends EventEmitter {
388
454
  this.linearEventTransport.on("error", (error) => {
389
455
  this.handleError(error);
390
456
  });
391
- // Register the /webhook endpoint
457
+ // Register the /linear-webhook endpoint (with /webhook retained as a deprecated alias)
392
458
  this.linearEventTransport.register();
393
459
  this.logger.info(`✅ Linear event transport registered (${verificationMode} mode)`);
394
460
  this.logger.info(` Webhook endpoint: ${this.sharedApplicationServer.getWebhookUrl()}`);
@@ -468,6 +534,13 @@ export class EdgeWorker extends EventEmitter {
468
534
  });
469
535
  // Listen for legacy GitHub webhook events (deprecated, kept for backward compatibility)
470
536
  this.gitHubEventTransport.on("event", (event) => {
537
+ // Route push events to the base branch notification handler
538
+ if (event.eventType === "push") {
539
+ this.handleGitHubPushWebhook(event.payload).catch((error) => {
540
+ this.logger.error("Failed to handle GitHub push webhook", error instanceof Error ? error : new Error(String(error)));
541
+ });
542
+ return;
543
+ }
471
544
  this.handleGitHubWebhook(event).catch((error) => {
472
545
  this.logger.error("Failed to handle GitHub webhook", error instanceof Error ? error : new Error(String(error)));
473
546
  });
@@ -834,6 +907,68 @@ export class EdgeWorker extends EventEmitter {
834
907
  this.activeWebhookCount--;
835
908
  }
836
909
  }
910
+ /**
911
+ * Handle GitHub push webhook events.
912
+ * When a base branch receives new commits, find active sessions tracking that
913
+ * branch and stream a rebase notification to the running agent.
914
+ */
915
+ async handleGitHubPushWebhook(payload) {
916
+ // Only handle branch pushes (refs/heads/*), not tags
917
+ if (!payload.ref.startsWith("refs/heads/")) {
918
+ return;
919
+ }
920
+ // Ignore branch deletions
921
+ if (payload.deleted) {
922
+ return;
923
+ }
924
+ const branchName = payload.ref.replace("refs/heads/", "");
925
+ const repoFullName = payload.repository.full_name;
926
+ // Find the matching repository config
927
+ const repository = this.findRepositoryByGitHubUrl(repoFullName);
928
+ if (!repository) {
929
+ this.logger.debug(`No repository configured for GitHub push from ${repoFullName}`);
930
+ return;
931
+ }
932
+ // Find active sessions tracking this branch as their base branch
933
+ const sessions = this.agentSessionManager.getSessionsByBaseBranch(branchName, repository.id);
934
+ if (sessions.length === 0) {
935
+ this.logger.debug(`No active sessions tracking base branch ${branchName} for ${repository.name}`);
936
+ return;
937
+ }
938
+ // Build a notification prompt with commit summary
939
+ const commitCount = payload.commits.length;
940
+ const commitSummary = payload.commits
941
+ .slice(0, 5)
942
+ .map((c) => `- ${c.message.split("\n")[0]}`)
943
+ .join("\n");
944
+ const moreCommits = commitCount > 5 ? `\n- ... and ${commitCount - 5} more` : "";
945
+ const notification = `<base_branch_update>
946
+ <branch>${branchName}</branch>
947
+ <repository>${repoFullName}</repository>
948
+ <commit_count>${commitCount}</commit_count>
949
+ <compare_url>${payload.compare}</compare_url>
950
+ <commits>
951
+ ${commitSummary}${moreCommits}
952
+ </commits>
953
+ <guidance>
954
+ Your base branch \`${branchName}\` has received ${commitCount} new commit(s). Consider rebasing your working branch onto the updated base to avoid merge conflicts. You can do this with: \`git fetch origin && git rebase origin/${branchName}\`
955
+ </guidance>
956
+ </base_branch_update>`;
957
+ this.logger.info(`Base branch ${branchName} updated (${commitCount} commits) — notifying ${sessions.length} active session(s)`);
958
+ // Stream notification to the first running session that supports streaming
959
+ const sortedSessions = [...sessions].sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
960
+ for (const session of sortedSessions) {
961
+ const existingRunner = session.agentRunner;
962
+ const isRunning = existingRunner?.isRunning() || false;
963
+ if (isRunning &&
964
+ existingRunner?.supportsStreamingInput &&
965
+ existingRunner.addStreamMessage) {
966
+ existingRunner.addStreamMessage(notification);
967
+ this.logger.debug(`[base-branch-update] Streamed notification to session ${session.id} for branch ${branchName}`);
968
+ break;
969
+ }
970
+ }
971
+ }
837
972
  /**
838
973
  * Find a repository configuration that matches a GitHub repository URL.
839
974
  * Matches against the githubUrl field in repository config.
@@ -1869,6 +2004,14 @@ ${taskSection}`;
1869
2004
  async handleWebhook(webhook, repos) {
1870
2005
  // Track active webhook processing for status endpoint
1871
2006
  this.activeWebhookCount++;
2007
+ const webhookAction = webhook.action;
2008
+ const webhookType = webhook.type;
2009
+ this.logger.event("webhook_received", {
2010
+ source: "linear",
2011
+ action: webhookAction,
2012
+ type: webhookType,
2013
+ repoCount: repos.length,
2014
+ });
1872
2015
  // Log verbose webhook info if enabled
1873
2016
  if (process.env.CYRUS_WEBHOOK_DEBUG === "true") {
1874
2017
  this.logger.debug(`Full webhook payload:`, JSON.stringify(webhook, null, 2));
@@ -1909,6 +2052,10 @@ ${taskSection}`;
1909
2052
  // Handle issue title/description/attachments updates - feed changes into active session
1910
2053
  await this.handleIssueContentUpdate(webhook);
1911
2054
  }
2055
+ else if (isIssueStateIdUpdateWebhook(webhook)) {
2056
+ // Handle issue state changes — wake up parked sessions when blocking issues complete
2057
+ await this.handleIssueStateChange(webhook);
2058
+ }
1912
2059
  else {
1913
2060
  if (process.env.CYRUS_WEBHOOK_DEBUG === "true") {
1914
2061
  this.logger.debug(`Unhandled webhook type: ${webhook.action}`);
@@ -2268,6 +2415,173 @@ ${taskSection}`;
2268
2415
  * and includes guidance for the agent to evaluate whether these changes affect
2269
2416
  * its current implementation or action plan.
2270
2417
  */
2418
+ /**
2419
+ * Check if an issue has unresolved blocked-by dependencies.
2420
+ * Fetches the issue from Linear and checks its inverse relations for blocking issues
2421
+ * that haven't been completed or canceled.
2422
+ */
2423
+ async checkBlockedByDependencies(agentSession, linearWorkspaceId) {
2424
+ const issue = agentSession.issue;
2425
+ if (!issue) {
2426
+ return { blocked: false, blockingIssueIds: [], blockingIdentifiers: [] };
2427
+ }
2428
+ try {
2429
+ const fullIssue = await this.fetchFullIssueDetails(issue.id, linearWorkspaceId);
2430
+ if (!fullIssue) {
2431
+ return {
2432
+ blocked: false,
2433
+ blockingIssueIds: [],
2434
+ blockingIdentifiers: [],
2435
+ };
2436
+ }
2437
+ const blockingIssues = await this.promptBuilder.fetchBlockingIssues(fullIssue);
2438
+ if (blockingIssues.length === 0) {
2439
+ return {
2440
+ blocked: false,
2441
+ blockingIssueIds: [],
2442
+ blockingIdentifiers: [],
2443
+ };
2444
+ }
2445
+ // Filter to only unresolved blockers (not completed or canceled)
2446
+ const unresolvedBlockers = [];
2447
+ for (const blocker of blockingIssues) {
2448
+ try {
2449
+ const state = await blocker.state;
2450
+ if (state &&
2451
+ state.type !== "completed" &&
2452
+ state.type !== "canceled") {
2453
+ unresolvedBlockers.push({
2454
+ id: blocker.id,
2455
+ identifier: blocker.identifier,
2456
+ });
2457
+ }
2458
+ }
2459
+ catch {
2460
+ // If we can't resolve the state, assume it's unresolved
2461
+ unresolvedBlockers.push({
2462
+ id: blocker.id,
2463
+ identifier: blocker.identifier,
2464
+ });
2465
+ }
2466
+ }
2467
+ if (unresolvedBlockers.length === 0) {
2468
+ return {
2469
+ blocked: false,
2470
+ blockingIssueIds: [],
2471
+ blockingIdentifiers: [],
2472
+ };
2473
+ }
2474
+ return {
2475
+ blocked: true,
2476
+ blockingIssueIds: unresolvedBlockers.map((b) => b.id),
2477
+ blockingIdentifiers: unresolvedBlockers.map((b) => b.identifier),
2478
+ };
2479
+ }
2480
+ catch (error) {
2481
+ this.logger.error(`Failed to check blocked-by dependencies for ${issue.identifier}:`, error);
2482
+ // On error, don't block — proceed with normal flow
2483
+ return { blocked: false, blockingIssueIds: [], blockingIdentifiers: [] };
2484
+ }
2485
+ }
2486
+ /**
2487
+ * Handle issue state change webhooks.
2488
+ * When a blocking issue is completed, wake up any parked sessions that were waiting on it.
2489
+ */
2490
+ async handleIssueStateChange(webhook) {
2491
+ const issueData = webhook.data;
2492
+ const completedIssueId = issueData.id;
2493
+ const issueIdentifier = issueData.identifier;
2494
+ // Only care about transitions TO completed or canceled states
2495
+ // The IssueWebhookPayload has a stateId field — resolve the state
2496
+ // via the issue tracker to check if it's a completion state
2497
+ const stateId = issueData.stateId;
2498
+ if (!stateId) {
2499
+ return;
2500
+ }
2501
+ // Find workspace for this webhook to resolve state type
2502
+ const linearWorkspaceId = webhook.organizationId;
2503
+ const issueTracker = this.issueTrackers.get(linearWorkspaceId);
2504
+ if (!issueTracker) {
2505
+ return;
2506
+ }
2507
+ // Fetch the issue to check its current state type
2508
+ let stateType;
2509
+ try {
2510
+ const fullIssue = await issueTracker.fetchIssue(completedIssueId);
2511
+ const state = await fullIssue.state;
2512
+ stateType = state?.type;
2513
+ }
2514
+ catch {
2515
+ // Can't resolve state — skip
2516
+ return;
2517
+ }
2518
+ if (stateType !== "completed" && stateType !== "canceled") {
2519
+ return;
2520
+ }
2521
+ this.logger.debug(`Issue ${issueIdentifier} moved to ${stateType} — checking for parked sessions to wake`);
2522
+ // Find parked sessions that were blocked by this issue
2523
+ const sessionsToWake = [];
2524
+ for (const [blockedIssueId, parked] of this.parkedSessions.entries()) {
2525
+ if (parked.blockingIssueIds.includes(completedIssueId)) {
2526
+ // Remove this blocker from the list
2527
+ parked.blockingIssueIds = parked.blockingIssueIds.filter((id) => id !== completedIssueId);
2528
+ // If no more blockers, wake the session
2529
+ if (parked.blockingIssueIds.length === 0) {
2530
+ sessionsToWake.push(blockedIssueId);
2531
+ }
2532
+ else {
2533
+ this.logger.debug(`Parked session for issue ${blockedIssueId} still has ${parked.blockingIssueIds.length} remaining blocker(s)`);
2534
+ }
2535
+ }
2536
+ }
2537
+ // Wake up unblocked sessions
2538
+ for (const blockedIssueId of sessionsToWake) {
2539
+ const parked = this.parkedSessions.get(blockedIssueId);
2540
+ if (!parked)
2541
+ continue;
2542
+ this.parkedSessions.delete(blockedIssueId);
2543
+ this.logger.info(`Waking parked session for issue ${parked.agentSession.issue?.identifier} — all blockers resolved`);
2544
+ // Post activity about waking up
2545
+ await this.activityPoster.postThoughtActivity(parked.agentSession.id, parked.linearWorkspaceId, `All blocking dependencies are now resolved — starting work.`);
2546
+ // Replay the normal initializeAgentRunner flow
2547
+ try {
2548
+ await this.initializeAgentRunner(parked.agentSession, parked.repositories, parked.linearWorkspaceId, parked.guidance, parked.commentBody, parked.baseBranchOverrides, parked.routingMethod);
2549
+ }
2550
+ catch (error) {
2551
+ this.logger.error(`Failed to wake parked session for issue ${blockedIssueId}:`, error);
2552
+ }
2553
+ }
2554
+ }
2555
+ /**
2556
+ * Handle a user re-prompt on a parked (blocked-by) session.
2557
+ * Re-checks blocking status: if clear, wakes the session; if still blocked, re-posts status.
2558
+ */
2559
+ async handleParkedSessionReprompt(_webhook, issueId) {
2560
+ const parked = this.parkedSessions.get(issueId);
2561
+ if (!parked)
2562
+ return;
2563
+ const blockResult = await this.checkBlockedByDependencies(parked.agentSession, parked.linearWorkspaceId);
2564
+ if (blockResult.blocked) {
2565
+ // Still blocked — update the parked entry and re-post status
2566
+ parked.blockingIssueIds = blockResult.blockingIssueIds;
2567
+ const blockerList = blockResult.blockingIdentifiers
2568
+ .map((id) => `**${id}**`)
2569
+ .join(", ");
2570
+ await this.activityPoster.postThoughtActivity(parked.agentSession.id, parked.linearWorkspaceId, `Still blocked by ${blockerList}. Will start automatically when resolved.`);
2571
+ this.logger.info(`Re-prompt on parked session for ${parked.agentSession.issue?.identifier}: still blocked by ${blockResult.blockingIdentifiers.join(", ")}`);
2572
+ return;
2573
+ }
2574
+ // Blockers resolved — wake the session
2575
+ this.parkedSessions.delete(issueId);
2576
+ this.logger.info(`Re-prompt cleared blockers for ${parked.agentSession.issue?.identifier} — waking session`);
2577
+ await this.activityPoster.postThoughtActivity(parked.agentSession.id, parked.linearWorkspaceId, `Blocking dependencies are now resolved — starting work.`);
2578
+ try {
2579
+ await this.initializeAgentRunner(parked.agentSession, parked.repositories, parked.linearWorkspaceId, parked.guidance, parked.commentBody, parked.baseBranchOverrides, parked.routingMethod);
2580
+ }
2581
+ catch (error) {
2582
+ this.logger.error(`Failed to wake parked session for issue ${issueId} on re-prompt:`, error);
2583
+ }
2584
+ }
2271
2585
  buildIssueUpdatePrompt(issueIdentifier, issueData, updatedFrom) {
2272
2586
  return this.promptBuilder.buildIssueUpdatePrompt(issueIdentifier, issueData, updatedFrom);
2273
2587
  }
@@ -2292,8 +2606,7 @@ ${taskSection}`;
2292
2606
  getLinearTokenForWorkspace(linearWorkspaceId) {
2293
2607
  const workspaceConfig = this.config.linearWorkspaces?.[linearWorkspaceId];
2294
2608
  if (!workspaceConfig) {
2295
- throw new Error(`No Linear workspace config found for workspace ${linearWorkspaceId}. ` +
2296
- `Ensure linearWorkspaces.${linearWorkspaceId} is configured.`);
2609
+ return null; // CLI platform or unconfigured workspace
2297
2610
  }
2298
2611
  return workspaceConfig.linearToken;
2299
2612
  }
@@ -2484,6 +2797,29 @@ ${taskSection}`;
2484
2797
  log.info(`Handling agent session created`);
2485
2798
  const { agentSession, guidance } = webhook;
2486
2799
  const commentBody = agentSession.comment?.body;
2800
+ // Check for blocked-by dependencies before starting work
2801
+ const blockResult = await this.checkBlockedByDependencies(agentSession, linearWorkspaceId);
2802
+ if (blockResult.blocked) {
2803
+ // Park the session — don't create worktree or runner
2804
+ const parkedIssueId = agentSession.issue.id;
2805
+ this.parkedSessions.set(parkedIssueId, {
2806
+ agentSession,
2807
+ repositories,
2808
+ linearWorkspaceId,
2809
+ guidance,
2810
+ commentBody,
2811
+ baseBranchOverrides,
2812
+ routingMethod,
2813
+ blockingIssueIds: blockResult.blockingIssueIds,
2814
+ });
2815
+ // Post acknowledgment to the Linear agent session
2816
+ const blockerList = blockResult.blockingIdentifiers
2817
+ .map((id) => `**${id}**`)
2818
+ .join(", ");
2819
+ await this.activityPoster.postThoughtActivity(agentSession.id, linearWorkspaceId, `Blocked by ${blockerList} — will start automatically when ${blockResult.blockingIdentifiers.length === 1 ? "it is" : "they are"} resolved.`);
2820
+ log.info(`Session parked: issue ${agentSession.issue.identifier} is blocked by ${blockResult.blockingIdentifiers.join(", ")}`);
2821
+ return;
2822
+ }
2487
2823
  // Initialize agent runner using shared logic (pass full repositories array)
2488
2824
  await this.initializeAgentRunner(agentSession, repositories, linearWorkspaceId, guidance, commentBody, baseBranchOverrides, routingMethod);
2489
2825
  }
@@ -2656,17 +2992,39 @@ ${taskSection}`;
2656
2992
  await this.agentSessionManager.createResponseActivity(agentSessionId, `Stop signal received for ${issueTitle}. No active session was found (the session may have ended or the system was restarted). No further action is needed.`);
2657
2993
  return;
2658
2994
  }
2659
- // Stop the existing runner if it's active
2995
+ // Double-stop detection: two stop signals within 10s → full abort
2996
+ const now = Date.now();
2997
+ const lastStop = this.lastStopTimeBySession.get(agentSessionId);
2998
+ const isDoubleStop = lastStop !== undefined && now - lastStop < 10_000;
2999
+ this.lastStopTimeBySession.set(agentSessionId, now);
2660
3000
  const existingRunner = foundSession.agentRunner;
2661
- this.agentSessionManager.requestSessionStop(agentSessionId);
2662
- if (existingRunner) {
2663
- existingRunner.stop();
2664
- log.info(`Stopped agent session for agent activity session ${agentSessionId}`);
2665
- }
2666
- // Post confirmation
2667
3001
  const issueTitle = issue?.title || "this issue";
2668
- const stopConfirmation = `I've stopped working on ${issueTitle} as requested.\n\n**Stop Signal:** Received from ${webhook.agentSession.creator?.name || "user"}\n**Action Taken:** All ongoing work has been halted`;
2669
- await this.agentSessionManager.createResponseActivity(agentSessionId, stopConfirmation);
3002
+ const senderName = webhook.agentSession.creator?.name || "user";
3003
+ // Only warm sessions can be safely interrupted without killing the
3004
+ // underlying request. Non-warm sessions get a single-shot full stop —
3005
+ // calling interrupt() on them surfaces a "Request was aborted" error
3006
+ // from the SDK (see CYPACK-1145).
3007
+ const supportsInterrupt = Boolean(existingRunner?.interrupt && existingRunner?.isWarm?.());
3008
+ if (isDoubleStop || !supportsInterrupt) {
3009
+ // Either a second stop within window, or a non-warm runner — full kill
3010
+ this.agentSessionManager.requestSessionStop(agentSessionId);
3011
+ if (existingRunner) {
3012
+ existingRunner.stop();
3013
+ log.info(isDoubleStop
3014
+ ? `Double-stop: fully aborted session ${agentSessionId}`
3015
+ : `Stopped session ${agentSessionId} (interrupt not supported)`);
3016
+ }
3017
+ this.lastStopTimeBySession.delete(agentSessionId);
3018
+ await this.agentSessionManager.createResponseActivity(agentSessionId, isDoubleStop
3019
+ ? `I've fully stopped working on ${issueTitle}.\n\n**Stop Signal:** Received from ${senderName} (second stop)\n**Action Taken:** Session terminated`
3020
+ : `I've stopped working on ${issueTitle}.\n\n**Stop Signal:** Received from ${senderName}\n**Action Taken:** Session terminated`);
3021
+ }
3022
+ else {
3023
+ // First stop on a warm session — interrupt current turn, keep session warm
3024
+ await existingRunner.interrupt();
3025
+ log.info(`Interrupted current turn for session ${agentSessionId} (send stop again within 10s to fully terminate)`);
3026
+ await this.agentSessionManager.createResponseActivity(agentSessionId, `Interrupted by ${senderName}\n**Tip:** Type and send "stop" within 10 seconds to fully terminate the session.`);
3027
+ }
2670
3028
  }
2671
3029
  /**
2672
3030
  * Handle repository selection response from prompted webhook
@@ -2883,6 +3241,15 @@ ${taskSection}`;
2883
3241
  await this.handleStopSignal(webhook);
2884
3242
  return;
2885
3243
  }
3244
+ // Branch 1.5: Handle re-prompt for parked (blocked-by) sessions
3245
+ // When a user re-prompts and the session is parked, re-check blocking status.
3246
+ // If blockers are resolved, wake the session immediately.
3247
+ const issueIdForParkedCheck = webhook.agentSession?.issue?.id;
3248
+ if (issueIdForParkedCheck &&
3249
+ this.parkedSessions.has(issueIdForParkedCheck)) {
3250
+ await this.handleParkedSessionReprompt(webhook, issueIdForParkedCheck);
3251
+ return;
3252
+ }
2886
3253
  // Branch 2: Handle repository selection response
2887
3254
  // This is the first Claude runner initialization after user selects a repository.
2888
3255
  // The selection handler extracts the choice from the response (or uses fallback)
@@ -3027,8 +3394,14 @@ ${taskSection}`;
3027
3394
  */
3028
3395
  createRunnerForType(runnerType, config) {
3029
3396
  switch (runnerType) {
3030
- case "claude":
3031
- return new ClaudeRunner(config);
3397
+ case "claude": {
3398
+ // Inject the hosted SessionStore at the last moment so it only
3399
+ // attaches to Claude runners (the field is Claude-specific).
3400
+ const claudeConfig = this.claudeSessionStore
3401
+ ? { ...config, sessionStore: this.claudeSessionStore }
3402
+ : config;
3403
+ return new ClaudeRunner(claudeConfig, this.isWarmSessionsEnabled());
3404
+ }
3032
3405
  case "gemini":
3033
3406
  return new GeminiRunner(config);
3034
3407
  case "codex":
@@ -3637,7 +4010,7 @@ ${input.userComment}
3637
4010
  platform: session.issueContext?.trackerId,
3638
4011
  issueIdentifier: session.issueContext?.issueIdentifier,
3639
4012
  });
3640
- return this.runnerConfigBuilder.buildIssueConfig({
4013
+ const result = this.runnerConfigBuilder.buildIssueConfig({
3641
4014
  session,
3642
4015
  repository,
3643
4016
  sessionId,
@@ -3663,6 +4036,17 @@ ${input.userComment}
3663
4036
  createAskUserQuestionCallback: (sid, wid) => this.createAskUserQuestionCallback(sid, wid),
3664
4037
  requireLinearWorkspaceId,
3665
4038
  });
4039
+ // Attach pre-warmed session if available (only for Claude runner).
4040
+ // Skipped entirely when warm sessions are not enabled.
4041
+ if (result.runnerType === "claude" && this.isWarmSessionsEnabled()) {
4042
+ const warmSession = this.warmInstances.get(sessionId);
4043
+ if (warmSession) {
4044
+ this.warmInstances.delete(sessionId);
4045
+ result.config.warmSession = warmSession;
4046
+ log.debug("Attaching pre-warmed session to runner config");
4047
+ }
4048
+ }
4049
+ return result;
3666
4050
  }
3667
4051
  /**
3668
4052
  * Create an onAskUserQuestion callback for the ClaudeRunner.
@@ -3770,6 +4154,126 @@ ${input.userComment}
3770
4154
  this.logger.error(`Failed to load persisted EdgeWorker state:`, error);
3771
4155
  }
3772
4156
  }
4157
+ /**
4158
+ * Whether the warm-session feature is enabled.
4159
+ *
4160
+ * Warm sessions are an opt-in optimization that pre-spawns Claude Code
4161
+ * subprocesses on startup so the first query after a restart skips the
4162
+ * cold-start cost. Disabled by default; opt in by setting
4163
+ * `CYRUS_ENABLE_WARM_SESSIONS=1` (or `=true`).
4164
+ */
4165
+ isWarmSessionsEnabled() {
4166
+ const raw = process.env.CYRUS_ENABLE_WARM_SESSIONS;
4167
+ if (!raw)
4168
+ return false;
4169
+ const v = raw.toLowerCase().trim();
4170
+ return v === "1" || v === "true";
4171
+ }
4172
+ /**
4173
+ * Whether the remote Claude session store is explicitly disabled.
4174
+ *
4175
+ * The remote store mirrors SDK transcripts to the Cyrus hosted control
4176
+ * plane and is on by default whenever `CYRUS_APP_URL`, `CYRUS_API_KEY`,
4177
+ * and `CYRUS_TEAM_ID` are all set. Operators can opt out — without
4178
+ * unsetting those vars (which other features depend on) — by setting
4179
+ * `CYRUS_DISABLE_REMOTE_SESSION_STORE=1` (or `=true`).
4180
+ */
4181
+ isRemoteSessionStoreDisabled() {
4182
+ const raw = process.env.CYRUS_DISABLE_REMOTE_SESSION_STORE;
4183
+ if (!raw)
4184
+ return false;
4185
+ const v = raw.toLowerCase().trim();
4186
+ return v === "1" || v === "true";
4187
+ }
4188
+ /**
4189
+ * Pre-warm the N most recently updated Claude sessions so the first query
4190
+ * after a CLI restart has near-zero cold-start latency (~20x faster).
4191
+ *
4192
+ * Uses startup() from @anthropic-ai/claude-agent-sdk with MCP_CONNECTION_NONBLOCKING=true
4193
+ * so the warm instances are ready in ~500ms rather than ~4s.
4194
+ * Warm instances are stored in this.warmInstances keyed by agentSessionId and
4195
+ * consumed by buildAgentRunnerConfig() when the first message arrives.
4196
+ *
4197
+ * Gated by `isWarmSessionsEnabled()` — callers should check before invoking.
4198
+ */
4199
+ async warmupRecentSessions(count = 30) {
4200
+ const allSessions = this.agentSessionManager.getAllSessions();
4201
+ // Only warm Claude sessions that have a persisted session ID and a workspace path
4202
+ const candidates = allSessions
4203
+ .filter((s) => s.claudeSessionId && s.workspace?.path)
4204
+ .sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0))
4205
+ .slice(0, count);
4206
+ if (candidates.length === 0) {
4207
+ this.logger.debug("No Claude sessions to pre-warm");
4208
+ return;
4209
+ }
4210
+ this.logger.info(`Pre-warming ${candidates.length} most recent Claude sessions...`);
4211
+ const { startup } = await import("@anthropic-ai/claude-agent-sdk");
4212
+ await Promise.all(candidates.map(async (session) => {
4213
+ try {
4214
+ const repoId = this.sessionRepositories.get(session.id);
4215
+ const repo = repoId ? this.repositories.get(repoId) : undefined;
4216
+ if (!repo) {
4217
+ this.logger.debug(`No repo for session ${session.id}, skipping warmup`);
4218
+ return;
4219
+ }
4220
+ // Build MCP config for this session (same as the live runner would use)
4221
+ const linearWorkspaceId = requireLinearWorkspaceId(repo);
4222
+ 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);
4225
+ let mcpServers = { ...mcpConfig };
4226
+ if (mcpConfigPath) {
4227
+ const paths = Array.isArray(mcpConfigPath)
4228
+ ? mcpConfigPath
4229
+ : [mcpConfigPath];
4230
+ for (const filePath of paths) {
4231
+ try {
4232
+ if (existsSync(filePath)) {
4233
+ const fileContent = JSON.parse(readFileSync(filePath, "utf8"));
4234
+ const servers = fileContent.mcpServers || {};
4235
+ normalizeMcpHttpTransport(servers);
4236
+ mcpServers = { ...mcpServers, ...servers };
4237
+ }
4238
+ }
4239
+ catch {
4240
+ // Ignore unreadable MCP config files
4241
+ }
4242
+ }
4243
+ }
4244
+ const repoConfig = repo;
4245
+ const model = session.metadata?.model ||
4246
+ repoConfig.claudeDefaultModel ||
4247
+ repoConfig.model ||
4248
+ "claude-opus-4-6";
4249
+ // Build allowed/disallowed tools — same as what buildAgentRunnerConfig() uses.
4250
+ // Without these, startup() inherits the user's defaultMode ("default"),
4251
+ // which causes macOS permission prompts for file writes.
4252
+ const allowedTools = this.buildAllowedTools(repo);
4253
+ const disallowedTools = this.buildDisallowedTools(repo);
4254
+ const warm = await startup({
4255
+ options: {
4256
+ resume: session.claudeSessionId,
4257
+ model,
4258
+ cwd: session.workspace.path,
4259
+ ...(Object.keys(mcpServers).length > 0 && { mcpServers }),
4260
+ ...(allowedTools.length > 0 && { allowedTools }),
4261
+ ...(disallowedTools.length > 0 && { disallowedTools }),
4262
+ settingSources: ["user", "project", "local"],
4263
+ // CLAUDE_CODE_SUBPROCESS_ENV_SCRUB is intentionally not set here;
4264
+ // see CYPACK-1108 and ClaudeRunner.start() for context.
4265
+ env: buildBaseSessionEnv(),
4266
+ },
4267
+ });
4268
+ this.warmInstances.set(session.id, warm);
4269
+ this.logger.info(`Pre-warmed session ${session.id} (${session.issueContext?.issueIdentifier ?? "unknown"})`);
4270
+ }
4271
+ catch (err) {
4272
+ this.logger.debug(`Failed to pre-warm session ${session.id}:`, err);
4273
+ }
4274
+ }));
4275
+ this.logger.info(`Session pre-warm complete: ${this.warmInstances.size} sessions ready`);
4276
+ }
3773
4277
  /**
3774
4278
  * Save current EdgeWorker state for all repositories
3775
4279
  */