@agilemonster/mcp-server 1.0.22 → 1.0.23

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/client.d.ts CHANGED
@@ -1,5 +1,12 @@
1
- import { AgileMonsterProjectConfig, AutonomousStepResponse, CardComment, DeployReport, Board, BoardAgentConfig, Card, ContextResponse, DiscoveredProject, PulseResponse } from './types.js';
2
- export declare const MCP_SERVER_VERSION = "1.0.22";
1
+ import { ActiveClaim, AgileMonsterProjectConfig, AutonomousStepResponse, CardComment, DeployReport, Board, BoardAgentConfig, Card, ContextResponse, DiscoveredProject, PulseResponse } from './types.js';
2
+ export declare const MCP_SERVER_VERSION = "1.0.23";
3
+ /** An API call the server answered with an error status. `status` lets callers tell "refused" from "broken". */
4
+ export declare class ApiError extends Error {
5
+ readonly status: number;
6
+ constructor(message: string, status: number);
7
+ }
8
+ /** The server refused a claim because another agent session holds the card (HTTP 409). */
9
+ export declare function isClaimConflict(err: unknown): boolean;
3
10
  export declare class AgileMonsterClient {
4
11
  private apiKey;
5
12
  private baseUrl;
@@ -41,7 +48,13 @@ export declare class AgileMonsterClient {
41
48
  models: Record<string, string> | string[];
42
49
  }>;
43
50
  installedAgents?: string[];
51
+ /** Cards this process holds: the server extends their claim (heartbeat). */
52
+ activeClaims?: ActiveClaim[];
44
53
  }): Promise<PulseResponse>;
54
+ /** Extend the claims this process holds. Used while a long job keeps the pulse loop busy. */
55
+ heartbeat(activeClaims: ActiveClaim[]): Promise<{
56
+ renewed: number;
57
+ }>;
45
58
  /**
46
59
  * Cross-platform detection of AI coding agents installed on this computer.
47
60
  * Checks for Cursor, Claude Code, Antigravity, Codex, Hermes, Windsurf, Aider.
package/dist/client.js CHANGED
@@ -4,7 +4,20 @@ import { execSync } from 'node:child_process';
4
4
  import { resolveDeployProfile } from './deploy-profile.js';
5
5
  import { loadMemory } from './deploy-memory.js';
6
6
  import { stripRemoteCredentials } from './redact.js';
7
- export const MCP_SERVER_VERSION = '1.0.22';
7
+ export const MCP_SERVER_VERSION = '1.0.23';
8
+ /** An API call the server answered with an error status. `status` lets callers tell "refused" from "broken". */
9
+ export class ApiError extends Error {
10
+ status;
11
+ constructor(message, status) {
12
+ super(message);
13
+ this.name = 'ApiError';
14
+ this.status = status;
15
+ }
16
+ }
17
+ /** The server refused a claim because another agent session holds the card (HTTP 409). */
18
+ export function isClaimConflict(err) {
19
+ return err instanceof ApiError && err.status === 409;
20
+ }
8
21
  export class AgileMonsterClient {
9
22
  apiKey = '';
10
23
  baseUrl;
@@ -144,7 +157,7 @@ export class AgileMonsterClient {
144
157
  }
145
158
  if (!res.ok) {
146
159
  const msg = json?.message || json?.error || `HTTP ${res.status}: ${text.slice(0, 150)}`;
147
- throw new Error(`Agile Monster API Error: ${msg}`);
160
+ throw new ApiError(`Agile Monster API Error: ${msg}`, res.status);
148
161
  }
149
162
  return json;
150
163
  }
@@ -202,9 +215,17 @@ export class AgileMonsterClient {
202
215
  discovered_projects: options.discoveredProjects,
203
216
  discovered_models: options.discoveredModels,
204
217
  installed_agents: installed,
218
+ active_claims: options.activeClaims && options.activeClaims.length > 0 ? options.activeClaims : undefined,
205
219
  }),
206
220
  });
207
221
  }
222
+ /** Extend the claims this process holds. Used while a long job keeps the pulse loop busy. */
223
+ async heartbeat(activeClaims) {
224
+ return this.request('/api/agent/heartbeat', {
225
+ method: 'POST',
226
+ body: JSON.stringify({ active_claims: activeClaims }),
227
+ });
228
+ }
208
229
  /**
209
230
  * Cross-platform detection of AI coding agents installed on this computer.
210
231
  * Checks for Cursor, Claude Code, Antigravity, Codex, Hermes, Windsurf, Aider.
@@ -11,7 +11,11 @@ import type { ProjectMemory } from './deploy-memory.js';
11
11
  * Precedence: explicit project file > user-set binding > last verified success > ranked evidence.
12
12
  */
13
13
  export type DeployMethod = 'github_actions' | 'docker_compose' | 'deploy_script' | 'cloud_cli' | 'git_push' | 'manual' | 'none';
14
- export type ProfileSource = 'explicit' | 'user' | 'learned' | 'verified' | 'detected';
14
+ /**
15
+ * 'proposed' = a route someone ELSE set for this project on the board (a teammate, or a deployment verified
16
+ * on another machine). It is never "confirmed" for this computer by that alone.
17
+ */
18
+ export type ProfileSource = 'explicit' | 'user' | 'learned' | 'verified' | 'detected' | 'proposed';
15
19
  export type Confidence = 'high' | 'medium' | 'low';
16
20
  export interface PushTrigger {
17
21
  branches: string[] | null;
@@ -61,6 +65,16 @@ export interface DeployCandidate {
61
65
  /** The deploy workflow only starts when dispatched (pushing alone does not deploy). */
62
66
  dispatchOnly?: boolean;
63
67
  }
68
+ /** A deploy route another account set on the board binding. The server sends it apart from the confirmed fields. */
69
+ export interface RouteProposal {
70
+ method?: string | null;
71
+ command?: string | null;
72
+ live_url?: string | null;
73
+ /** How it is stored on the board: 'user' (stated by that person), 'verified' (worked on their machine), 'auto'. */
74
+ source?: string | null;
75
+ set_by?: string | null;
76
+ set_by_name?: string | null;
77
+ }
64
78
  export interface BoundProfile {
65
79
  deploy_method?: string | null;
66
80
  deploy_command?: string | null;
@@ -68,7 +82,18 @@ export interface BoundProfile {
68
82
  build_command?: string | null;
69
83
  /** 'user' = set by a human, 'verified' = confirmed by a successful deploy, 'auto' = a guess. */
70
84
  deploy_source?: string | null;
85
+ /** The account that set deploy_method / deploy_command on the board. */
86
+ deploy_set_by?: string | null;
87
+ /** A route someone else set: used only as described at `resolveDeployProfile`. */
88
+ proposal?: RouteProposal | null;
71
89
  }
90
+ /**
91
+ * Second check on this side of the wire: the server only sends a binding's route as confirmed to the account
92
+ * that set it, but what runs in a shell on this computer is decided HERE. If the confirmed fields name a
93
+ * setter and it is not this machine's account, the route is demoted to a proposal.
94
+ * `ownerId` null = this machine's account could not be determined; the fields are left as the server sent them.
95
+ */
96
+ export declare function trustBoundRoute(bound: BoundProfile | null | undefined, ownerId: string | null | undefined): BoundProfile | null;
72
97
  export interface GitInfo {
73
98
  isRepo: boolean;
74
99
  branch: string | null;
@@ -1,6 +1,34 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { execFileSync } from 'node:child_process';
4
+ /**
5
+ * Second check on this side of the wire: the server only sends a binding's route as confirmed to the account
6
+ * that set it, but what runs in a shell on this computer is decided HERE. If the confirmed fields name a
7
+ * setter and it is not this machine's account, the route is demoted to a proposal.
8
+ * `ownerId` null = this machine's account could not be determined; the fields are left as the server sent them.
9
+ */
10
+ export function trustBoundRoute(bound, ownerId) {
11
+ if (!bound)
12
+ return null;
13
+ const setBy = (bound.deploy_set_by || '').trim();
14
+ const hasRoute = Boolean((bound.deploy_method || '').trim() || (bound.deploy_command || '').trim());
15
+ if (!hasRoute || !setBy || !ownerId || setBy === ownerId)
16
+ return bound;
17
+ return {
18
+ ...bound,
19
+ deploy_method: null,
20
+ deploy_command: null,
21
+ deploy_source: null,
22
+ proposal: bound.proposal || {
23
+ method: bound.deploy_method,
24
+ command: bound.deploy_command,
25
+ live_url: bound.live_url,
26
+ source: bound.deploy_source || 'auto',
27
+ set_by: setBy,
28
+ set_by_name: null,
29
+ },
30
+ };
31
+ }
4
32
  // ---------------------------------------------------------------------------
5
33
  // Small helpers
6
34
  // ---------------------------------------------------------------------------
@@ -898,8 +926,43 @@ export function resolveDeployProfile(projectPath, options = {}) {
898
926
  }
899
927
  if (bound && boundSource === 'user') {
900
928
  const method = normalizeMethod(bound.deploy_method);
929
+ // The server only sends source "user" to the account that set the route (see trustBoundRoute)
901
930
  if (method)
902
- return pinned(method, bound.deploy_command || null, 'user', 'set by a teammate in Agile Monster');
931
+ return pinned(method, bound.deploy_command || null, 'user', 'set by you in Agile Monster');
932
+ }
933
+ // A route someone else set on the board. It may steer this machine towards something it could have run
934
+ // unasked anyway (push and let CI deploy, refresh the detected Compose stack). Anything that puts THEIR
935
+ // command line, a deploy script, a cloud CLI or a manual dispatch on this computer is asked about once;
936
+ // only the machine owner's answer on the card confirms it, and it is then remembered locally.
937
+ const proposal = bound?.proposal || null;
938
+ const proposalSource = (proposal?.source || '').toLowerCase();
939
+ const fromProposal = () => {
940
+ const method = normalizeMethod(proposal?.method);
941
+ if (!method)
942
+ return null;
943
+ const match = candidateFor(method);
944
+ const theirCommand = isRunnableCommand(proposal?.command) ? (proposal?.command).trim() : null;
945
+ const custom = theirCommand !== null && theirCommand !== (match?.command || null);
946
+ const sensitive = requiresConfirmation({ method, dispatchOnly: Boolean(match?.dispatchOnly) });
947
+ if (!custom && sensitive && !match?.command)
948
+ return null; // nothing concrete to run or to confirm
949
+ if (!custom && !match && method !== 'manual' && method !== 'none')
950
+ return null; // does not apply to this checkout
951
+ const last = memory?.last_success || null;
952
+ const ranHereBefore = Boolean(last) && normalizeMethod(last?.method) === method && ((last?.command || '').trim() || null) === (theirCommand || match?.command || null);
953
+ const needsConfirmation = !ranHereBefore && (custom || sensitive);
954
+ const who = (proposal?.set_by_name || '').trim() || 'a teammate';
955
+ const what = proposalSource === 'verified' ? `verified by a deployment on ${who}'s machine` : `set by ${who} in Agile Monster`;
956
+ return {
957
+ ...pinned(method, theirCommand, 'proposed', needsConfirmation ? `${what}; not confirmed on this computer yet` : what),
958
+ confidence: needsConfirmation ? 'medium' : 'high',
959
+ needsConfirmation,
960
+ };
961
+ };
962
+ if (proposal && proposalSource === 'user') {
963
+ const proposed = fromProposal();
964
+ if (proposed)
965
+ return proposed;
903
966
  }
904
967
  // What worked last time, as long as it has not started failing since
905
968
  const last = memory?.last_success || null;
@@ -917,6 +980,11 @@ export function resolveDeployProfile(projectPath, options = {}) {
917
980
  return pinned(method, bound.deploy_command || null, 'verified', 'verified by an earlier deployment recorded in Agile Monster');
918
981
  }
919
982
  }
983
+ if (proposal && proposalSource === 'verified') {
984
+ const proposed = fromProposal();
985
+ if (proposed)
986
+ return proposed;
987
+ }
920
988
  const top = candidates[0];
921
989
  if (!top) {
922
990
  return {
@@ -973,7 +973,9 @@ export async function runDeployment(req) {
973
973
  log.info('deploy.needs_confirmation', { card: req.cardId, project: req.projectPath, method: profile.method, command: profile.command });
974
974
  return result({
975
975
  errorKind: 'needs_confirmation',
976
- errorDetail: `This project looks like it deploys with ${methodLabel(profile.method)}${profile.command ? ` (\`${profile.command}\`)` : ''}, but I have never run that here and nobody has confirmed it.`,
976
+ errorDetail: profile.source === 'proposed'
977
+ ? `On the board this project is set to deploy with ${methodLabel(profile.method)}${profile.command ? ` (\`${profile.command}\`)` : ''}, but that was not set from this computer's account and has never been confirmed here.`
978
+ : `This project looks like it deploys with ${methodLabel(profile.method)}${profile.command ? ` (\`${profile.command}\`)` : ''}, but I have never run that here and nobody has confirmed it.`,
977
979
  });
978
980
  }
979
981
  const git = await commitAndPush(req, profile, pushPolicy);
package/dist/server.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
2
2
  import { AgileMonsterClient } from './client.js';
3
- import { Card } from './types.js';
3
+ import { ActiveClaim, Card } from './types.js';
4
+ import { BoundProfile } from './deploy-profile.js';
4
5
  export declare function createServer(client?: AgileMonsterClient): Server<{
5
6
  method: string;
6
7
  params?: {
@@ -36,6 +37,23 @@ export declare function createServer(client?: AgileMonsterClient): Server<{
36
37
  } | undefined;
37
38
  }>;
38
39
  export declare function collectProjectSourceFiles(projectPath: string, keywords: string[]): string[];
40
+ /**
41
+ * The board binding's deploy profile for a card, as this machine may use it. A route that another account
42
+ * set arrives as a proposal (or is demoted to one here): see trustBoundRoute and resolveDeployProfile.
43
+ */
44
+ export declare function boundProfileOf(card: Partial<Card>, ownerId: string | null): BoundProfile;
45
+ /**
46
+ * The id under which this process claims cards. The server keeps a claim for the session that made it, so
47
+ * the id is stable for the life of the process. Background runs start with `auto-pulse-`: only those are
48
+ * re-queued automatically when they stop responding.
49
+ */
50
+ export declare function sessionRunRef(kind: 'background' | 'interactive', provider?: string): string;
51
+ export declare function holdClaim(cardId: string, runRef: string): void;
52
+ /** The agent did something with the card: its claim is still in use. */
53
+ export declare function touchClaim(cardId: string): void;
54
+ export declare function releaseClaim(cardId: string): void;
55
+ /** What the next pulse reports as held by this process. */
56
+ export declare function activeClaims(now?: number): ActiveClaim[];
39
57
  /**
40
58
  * Roll back edits left behind by a worker that died mid-card. A file is only restored if it still holds
41
59
  * exactly what that worker wrote — anything the developer touched since is left alone.
package/dist/server.js CHANGED
@@ -7,10 +7,10 @@ import crypto from 'node:crypto';
7
7
  import { spawn, execSync, execFileSync } from 'node:child_process';
8
8
  import { isUtf8 } from 'node:buffer';
9
9
  import { fileURLToPath } from 'node:url';
10
- import { AgileMonsterClient, MCP_SERVER_VERSION } from './client.js';
10
+ import { AgileMonsterClient, MCP_SERVER_VERSION, isClaimConflict } from './client.js';
11
11
  import { AGENT_GUIDELINES_MARKDOWN } from './guidelines.js';
12
12
  import { knownSecrets, redactResult } from './redact.js';
13
- import { describeProfile, methodLabel, normalizeMethod, resolveDeployProfile, } from './deploy-profile.js';
13
+ import { describeProfile, methodLabel, normalizeMethod, resolveDeployProfile, trustBoundRoute, } from './deploy-profile.js';
14
14
  import { addPendingVerification, getCardState, loadMemory, recordAttempt, removePendingVerification, setCardState, setExplicitProfile, } from './deploy-memory.js';
15
15
  import { checkCiOnce, describeFailureForHuman, ensureLocalGitExcludes, parseDeployDirective, runDeployment, } from './deploy-runner.js';
16
16
  import { addWellKnownToolDirs, agileMonsterHome, configureLogger, ensureUserPath, errorMessage, exec, log, readJson, which, writeJsonAtomic, } from './runtime.js';
@@ -608,7 +608,8 @@ Please strictly follow the Agile Monster Task Lifecycle Protocol:
608
608
  case 'agilemonster_claim_task': {
609
609
  const cardId = args.card_id;
610
610
  const provider = args.provider || projectConfig?.provider || 'antigravity';
611
- const runRef = args.run_ref;
611
+ // One id per IDE session, so the server can tell this session from the background daemon
612
+ const runRef = args.run_ref?.trim() || sessionRunRef('interactive', provider);
612
613
  const expectedBoardId = projectConfig?.board_id;
613
614
  if (!expectedBoardId) {
614
615
  return {
@@ -623,6 +624,8 @@ Please strictly follow the Agile Monster Task Lifecycle Protocol:
623
624
  };
624
625
  }
625
626
  const card = await client.claimTask(cardId, provider, runRef, expectedBoardId);
627
+ // Reported with every pulse from now on: the claim stays ours while this IDE's MCP process lives
628
+ holdClaim(cardId, runRef);
626
629
  return {
627
630
  content: [
628
631
  {
@@ -637,6 +640,7 @@ Please strictly follow the Agile Monster Task Lifecycle Protocol:
637
640
  const provider = args.provider || projectConfig?.provider || 'antigravity';
638
641
  const question = args.question;
639
642
  const card = await client.askQuestion(cardId, provider, question);
643
+ releaseClaim(cardId);
640
644
  return {
641
645
  content: [
642
646
  {
@@ -653,6 +657,7 @@ Please strictly follow the Agile Monster Task Lifecycle Protocol:
653
657
  const tokensUsed = args.tokens_used;
654
658
  const costUsd = args.cost_usd;
655
659
  const card = await client.addProgress(cardId, provider, update, tokensUsed, costUsd);
660
+ touchClaim(cardId);
656
661
  return {
657
662
  content: [
658
663
  {
@@ -669,6 +674,7 @@ Please strictly follow the Agile Monster Task Lifecycle Protocol:
669
674
  const costUsd = args.cost_usd || 0;
670
675
  const note = args.note;
671
676
  const card = await client.recordSpend(cardId, provider, tokens, costUsd, note);
677
+ touchClaim(cardId);
672
678
  return {
673
679
  content: [
674
680
  {
@@ -700,6 +706,7 @@ Please strictly follow the Agile Monster Task Lifecycle Protocol:
700
706
  const targetList = args.target_list || args.target_list_id || undefined;
701
707
  const assignToUserId = args.assign_to_user_id;
702
708
  const card = await client.submitReview(cardId, provider, summary, verificationSteps, targetList, assignToUserId);
709
+ releaseClaim(cardId);
703
710
  return {
704
711
  content: [
705
712
  {
@@ -728,6 +735,7 @@ Please strictly follow the Agile Monster Task Lifecycle Protocol:
728
735
  const provider = args.provider || projectConfig?.provider || 'antigravity';
729
736
  const comment = args.comment;
730
737
  const card = await client.blockTask(cardId, provider, comment);
738
+ releaseClaim(cardId);
731
739
  return {
732
740
  content: [
733
741
  {
@@ -742,6 +750,7 @@ Please strictly follow the Agile Monster Task Lifecycle Protocol:
742
750
  const provider = args.provider || projectConfig?.provider || 'antigravity';
743
751
  const comment = args.comment;
744
752
  const card = await client.completeTask(cardId, provider, comment);
753
+ releaseClaim(cardId);
745
754
  return {
746
755
  content: [
747
756
  {
@@ -1351,14 +1360,61 @@ function appendIdeActivityLog(projectPath, entry) {
1351
1360
  }
1352
1361
  catch { }
1353
1362
  }
1354
- function boundProfileOf(card) {
1355
- return {
1363
+ /**
1364
+ * The board binding's deploy profile for a card, as this machine may use it. A route that another account
1365
+ * set arrives as a proposal (or is demoted to one here): see trustBoundRoute and resolveDeployProfile.
1366
+ */
1367
+ export function boundProfileOf(card, ownerId) {
1368
+ return (trustBoundRoute({
1356
1369
  deploy_method: card.deploy_method,
1357
1370
  deploy_command: card.deploy_command,
1358
1371
  live_url: card.live_url,
1359
1372
  build_command: card.build_command,
1360
1373
  deploy_source: card.deploy_source,
1361
- };
1374
+ deploy_set_by: card.deploy_set_by,
1375
+ proposal: card.deploy_proposal || null,
1376
+ }, ownerId) || {});
1377
+ }
1378
+ // ---------------------------------------------------------------------------
1379
+ // Claims held by this process
1380
+ // ---------------------------------------------------------------------------
1381
+ const PROCESS_STARTED_AT = Date.now();
1382
+ /** An interactive claim nobody touched for this long is no longer reported as alive. */
1383
+ const CLAIM_REPORT_MAX_IDLE_MS = 4 * 60 * 60 * 1000;
1384
+ const heldClaims = new Map();
1385
+ /**
1386
+ * The id under which this process claims cards. The server keeps a claim for the session that made it, so
1387
+ * the id is stable for the life of the process. Background runs start with `auto-pulse-`: only those are
1388
+ * re-queued automatically when they stop responding.
1389
+ */
1390
+ export function sessionRunRef(kind, provider) {
1391
+ return kind === 'background'
1392
+ ? `auto-pulse-${process.pid}-${PROCESS_STARTED_AT}`
1393
+ : `ide-${(provider || 'agent').replace(/[^a-z0-9_]+/gi, '-').slice(0, 24)}-${process.pid}-${PROCESS_STARTED_AT}`;
1394
+ }
1395
+ export function holdClaim(cardId, runRef) {
1396
+ heldClaims.set(cardId, { runRef, touchedAt: Date.now() });
1397
+ }
1398
+ /** The agent did something with the card: its claim is still in use. */
1399
+ export function touchClaim(cardId) {
1400
+ const held = heldClaims.get(cardId);
1401
+ if (held)
1402
+ held.touchedAt = Date.now();
1403
+ }
1404
+ export function releaseClaim(cardId) {
1405
+ heldClaims.delete(cardId);
1406
+ }
1407
+ /** What the next pulse reports as held by this process. */
1408
+ export function activeClaims(now = Date.now()) {
1409
+ const out = [];
1410
+ for (const [cardId, held] of heldClaims.entries()) {
1411
+ if (now - held.touchedAt > CLAIM_REPORT_MAX_IDLE_MS) {
1412
+ heldClaims.delete(cardId);
1413
+ continue;
1414
+ }
1415
+ out.push({ card_id: cardId, run_ref: held.runRef });
1416
+ }
1417
+ return out;
1362
1418
  }
1363
1419
  /** Feed the outcome back to the server so the board's binding converges on the route that really works. */
1364
1420
  async function reportOutcome(client, card, projectPath, res) {
@@ -1497,7 +1553,7 @@ async function runRedeployOnlyCard(client, card, projectPath, provider, workerLa
1497
1553
  title: card.title || 'redeploy',
1498
1554
  cardId: card.id,
1499
1555
  redeploy: true,
1500
- bound: boundProfileOf(card),
1556
+ bound: boundProfileOf(card, await machineOwnerId(client)),
1501
1557
  });
1502
1558
  await reportOutcome(client, card, projectPath, res);
1503
1559
  const blankState = (extra) => ({
@@ -1650,7 +1706,7 @@ async function resumeFailedDeployment(client, card, projectPath, provider, worke
1650
1706
  cardId: card.id,
1651
1707
  files: state.files,
1652
1708
  redeploy: true,
1653
- bound: boundProfileOf(card),
1709
+ bound: boundProfileOf(card, await machineOwnerId(client)),
1654
1710
  override: directive.method ? { method: directive.method, command: directive.command } : null,
1655
1711
  });
1656
1712
  await reportOutcome(client, card, projectPath, res);
@@ -1768,7 +1824,21 @@ export async function executeQueuedCardAutonomously(client, card, projectPath) {
1768
1824
  log.info('card.start', { card: card.id, title: card.title, project: projectPath, provider });
1769
1825
  try {
1770
1826
  // 1. Claim the card (automatically moves it from ToDo -> In Progress on the board!)
1771
- await client.claimTask(card.id, provider, `auto-pulse-${Date.now()}`, card.board_id);
1827
+ const runRef = sessionRunRef('background');
1828
+ try {
1829
+ await client.claimTask(card.id, provider, runRef, card.board_id);
1830
+ }
1831
+ catch (err) {
1832
+ if (isClaimConflict(err)) {
1833
+ // Another session (an IDE agent, or a teammate's machine with the same folder) holds this card.
1834
+ // That is the claim doing its job, not a failure: leave the card and the board alone.
1835
+ removeTaskLock(lockFile);
1836
+ log.info('card.claim_lost', { card: card.id, project: projectPath, reason: errorMessage(err) });
1837
+ return false;
1838
+ }
1839
+ throw err;
1840
+ }
1841
+ holdClaim(card.id, runRef);
1772
1842
  sendDesktopNotification(`Agile Monster -> ${workerLabel}`, `Claimed & moved to In Progress: "${card.title}"`);
1773
1843
  // 1b. Deploy-only resume: the change exists, only shipping it is outstanding
1774
1844
  const priorState = getCardState(projectPath, card.id);
@@ -1788,7 +1858,7 @@ export async function executeQueuedCardAutonomously(client, card, projectPath) {
1788
1858
  if (isRedeployOnlyCard(card)) {
1789
1859
  return await runRedeployOnlyCard(client, card, projectPath, provider, workerLabel, lockFile);
1790
1860
  }
1791
- const profile = resolveDeployProfile(projectPath, { bound: boundProfileOf(card), memory: loadMemory(projectPath) });
1861
+ const profile = resolveDeployProfile(projectPath, { bound: boundProfileOf(card, await machineOwnerId(client)), memory: loadMemory(projectPath) });
1792
1862
  await client.addProgress(card.id, provider, `Autonomous background runner claimed task in \`${projectPath}\` and moved card to **In Progress**. Scanning repository files...`);
1793
1863
  // 2. Build keyword list from card title + description and collect project source files
1794
1864
  const rawText = `${card.title || ''} ${card.description || ''}`.toLowerCase();
@@ -2095,7 +2165,7 @@ export async function executeQueuedCardAutonomously(client, card, projectPath) {
2095
2165
  title: card.title || 'update',
2096
2166
  cardId: card.id,
2097
2167
  files: commitFiles,
2098
- bound: boundProfileOf(card),
2168
+ bound: boundProfileOf(card, await machineOwnerId(client)),
2099
2169
  });
2100
2170
  await reportOutcome(client, card, projectPath, deployInfo);
2101
2171
  if (!deployInfo.ok) {
@@ -2187,6 +2257,7 @@ export async function executeQueuedCardAutonomously(client, card, projectPath) {
2187
2257
  return false;
2188
2258
  }
2189
2259
  finally {
2260
+ releaseClaim(card.id);
2190
2261
  activeAutonomousCards.delete(card.id);
2191
2262
  activeWork--;
2192
2263
  }
@@ -2288,7 +2359,7 @@ export async function deployApprovedCardIfNeeded(client, card, projectPath) {
2288
2359
  const earlierSuccess = memory.deployed_cards[card.id] || null;
2289
2360
  // The deployment started at review time is still running in CI: wait for its verdict rather than deploying again
2290
2361
  if (stillPending && !isContainerRestartCard) {
2291
- const profile = resolveDeployProfile(projectPath, { bound: boundProfileOf(card), memory });
2362
+ const profile = resolveDeployProfile(projectPath, { bound: boundProfileOf(card, await machineOwnerId(client)), memory });
2292
2363
  const check = await checkCiOnce(projectPath, stillPending.sha, stillPending.workflow_names, profile.workflows.some((w) => w.isDeploy && w.workflowRun));
2293
2364
  if (check.verdict === 'success') {
2294
2365
  removePendingVerification(projectPath, card.id);
@@ -2335,7 +2406,7 @@ export async function deployApprovedCardIfNeeded(client, card, projectPath) {
2335
2406
  cardId: card.id,
2336
2407
  files: state?.files,
2337
2408
  redeploy: isContainerRestartCard,
2338
- bound: boundProfileOf(card),
2409
+ bound: boundProfileOf(card, await machineOwnerId(client)),
2339
2410
  });
2340
2411
  await reportOutcome(client, card, projectPath, res);
2341
2412
  if (!res.ok) {
@@ -2559,6 +2630,12 @@ export function startBuiltInPullPulse(server, client, intervalMs = 20000, role =
2559
2630
  if (pulseRunning) {
2560
2631
  if (holdsWorkerLock)
2561
2632
  acquireWorkerLock(role);
2633
+ // The same goes for the cards being worked on: while this pulse is busy nothing else tells the server
2634
+ // they are still held, and a build or deploy step can outlast the claim's lease.
2635
+ const held = activeClaims();
2636
+ if (held.length > 0) {
2637
+ void client.heartbeat(held).catch((err) => log.warn('claim.heartbeat_failed', { error: errorMessage(err) }));
2638
+ }
2562
2639
  return;
2563
2640
  }
2564
2641
  pulseRunning = true;
@@ -2577,6 +2654,7 @@ export function startBuiltInPullPulse(server, client, intervalMs = 20000, role =
2577
2654
  projectPath: config.project_path || process.cwd(),
2578
2655
  discoveredProjects: includeDiscovery ? client.discoverLocalProjects() : undefined,
2579
2656
  discoveredModels: includeDiscovery ? client.discoverLocalAgentModels() : undefined,
2657
+ activeClaims: activeClaims(),
2580
2658
  });
2581
2659
  if (consecutiveFailures > 0) {
2582
2660
  log.info('pulse.recovered', { after_failures: consecutiveFailures });
package/dist/types.d.ts CHANGED
@@ -42,6 +42,25 @@ export interface Card {
42
42
  deploy_stage?: string | null;
43
43
  /** 'user' = set by a human, 'verified' = confirmed by a successful deploy, 'auto' = a guess. */
44
44
  deploy_source?: string | null;
45
+ /** The account that set the deploy route on the board binding. */
46
+ deploy_set_by?: string | null;
47
+ /**
48
+ * A deploy route that another account set. The server withholds it from deploy_method / deploy_command
49
+ * and sends it here instead: it is never run on this machine without the owner's confirmation.
50
+ */
51
+ deploy_proposal?: {
52
+ method?: string | null;
53
+ command?: string | null;
54
+ live_url?: string | null;
55
+ source?: string | null;
56
+ set_by?: string | null;
57
+ set_by_name?: string | null;
58
+ } | null;
59
+ }
60
+ /** A card this process has claimed, reported with every pulse so the server keeps the claim alive. */
61
+ export interface ActiveClaim {
62
+ card_id: string;
63
+ run_ref: string;
45
64
  }
46
65
  export interface CardComment {
47
66
  id: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agilemonster/mcp-server",
3
- "version": "1.0.22",
3
+ "version": "1.0.23",
4
4
  "type": "module",
5
5
  "description": "Official Model Context Protocol (MCP) server & Orchestrator for Agile Monster (Cursor, Claude Code, Antigravity, Codex, Hermes, Windsurf)",
6
6
  "main": "dist/server.js",