@agilemonster/mcp-server 1.0.25 → 1.0.27

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/server.js CHANGED
@@ -8,13 +8,36 @@ import { spawn, execSync, execFileSync } from 'node:child_process';
8
8
  import { isUtf8 } from 'node:buffer';
9
9
  import { fileURLToPath } from 'node:url';
10
10
  import { AgileMonsterClient, MCP_SERVER_VERSION, isClaimConflict } from './client.js';
11
+ import { machineReport } from './machine.js';
12
+ import { managedRunnerDir, updateArgs } from './service.js';
11
13
  import { AGENT_GUIDELINES_MARKDOWN } from './guidelines.js';
12
14
  import { knownSecrets, redactResult } from './redact.js';
13
15
  import { describeProfile, methodLabel, normalizeMethod, resolveDeployProfile, trustBoundRoute, } from './deploy-profile.js';
14
- import { addPendingVerification, getCardState, loadMemory, recordAttempt, removePendingVerification, setCardState, setExplicitProfile, } from './deploy-memory.js';
16
+ import { addPendingVerification, getBranchQuestion, getCardState, loadMemory, recordAttempt, removePendingVerification, setBranchQuestion, setCardState, setExplicitProfile, } from './deploy-memory.js';
17
+ import { describeBlockedBranch, describePushConflict, parseBranchDirective, prepareBranch, shipOnlyCardCommits, startFromRemote, } from './branch-health.js';
15
18
  import { checkCiOnce, describeFailureForHuman, ensureLocalGitExcludes, parseDeployDirective, runDeployment, } from './deploy-runner.js';
16
19
  import { baselineFor, buildAgentPrompt, changesSince, clearBaseline, engineLabelFor, engineStatuses, markNewFilesForDiff, parseAgentReport, proseOf, resolveEngines, restoreWorkingTree, runEngine, } from './agent-engine.js';
17
20
  import { addWellKnownToolDirs, agileMonsterHome, configureLogger, ensureUserPath, errorMessage, exec, log, readJson, which, writeJsonAtomic, } from './runtime.js';
21
+ /**
22
+ * The answers a deploy question accepts, as reply buttons: nobody should have to type `deploy: ./deploy.sh`.
23
+ * A first-time route gets "yes, this way" and "I deploy it myself"; a failure gets "try again".
24
+ */
25
+ export function deployReplyOptions(res) {
26
+ // A refused push has real ways out; "try again" on its own could never succeed
27
+ if (res.conflict)
28
+ return describePushConflict(res.conflict).options;
29
+ if (res.stage === 'git')
30
+ return [{ label: 'I fixed it, try again', reply: 'retry' }];
31
+ if (res.errorKind === 'needs_confirmation') {
32
+ const route = (res.command || res.method || '').trim();
33
+ const options = [];
34
+ if (route)
35
+ options.push({ label: 'Yes, deploy this way', reply: `deploy: ${route}` });
36
+ options.push({ label: 'I deploy it myself', reply: 'deploy: manual' });
37
+ return options;
38
+ }
39
+ return [{ label: 'Try again', reply: 'retry' }];
40
+ }
18
41
  export function createServer(client = new AgileMonsterClient()) {
19
42
  const server = new Server({
20
43
  name: 'agilemonster-mcp-server',
@@ -263,6 +286,11 @@ Please strictly follow the Agile Monster Task Lifecycle Protocol:
263
286
  type: 'string',
264
287
  description: 'The question for the user.',
265
288
  },
289
+ options: {
290
+ type: 'array',
291
+ items: { type: 'string' },
292
+ description: 'Optional: up to four short answers the user can pick with one tap. The card shows them as buttons, and the user can still type a different answer.',
293
+ },
266
294
  },
267
295
  required: ['card_id', 'provider', 'question'],
268
296
  },
@@ -640,7 +668,10 @@ Please strictly follow the Agile Monster Task Lifecycle Protocol:
640
668
  const cardId = args.card_id;
641
669
  const provider = args.provider || projectConfig?.provider || 'antigravity';
642
670
  const question = args.question;
643
- const card = await client.askQuestion(cardId, provider, question);
671
+ const answers = Array.isArray(args.options)
672
+ ? args.options.map((o) => String(o).trim()).filter((o) => o.length > 0 && o.length <= 80).slice(0, 4)
673
+ : [];
674
+ const card = await client.askQuestion(cardId, provider, question, answers);
644
675
  releaseClaim(cardId);
645
676
  return {
646
677
  content: [
@@ -1015,11 +1046,16 @@ function triggerBackgroundSelfUpdate(targetVersion) {
1015
1046
  catch { }
1016
1047
  }
1017
1048
  }
1018
- // 2. Install @agilemonster/mcp-server@latest into global/npx cache, then exit once idle so launchd respawns the daemon
1019
- const child = spawn('sh', ['-c', 'npm install -g @agilemonster/mcp-server@latest >/dev/null 2>&1 || npx -y @agilemonster/mcp-server@latest --version >/dev/null 2>&1'], {
1020
- stdio: 'ignore',
1021
- detached: true,
1022
- });
1049
+ // 2. Fetch the new version, then exit once idle so launchd/systemd starts it. The background service
1050
+ // updates its own copy in place; a runner started any other way refreshes the global install / npx cache.
1051
+ const ownDir = managedRunnerDir(fileURLToPath(import.meta.url));
1052
+ const npm = which('npm');
1053
+ const child = ownDir && npm
1054
+ ? spawn(npm, updateArgs(ownDir), { stdio: 'ignore', detached: true, cwd: ownDir })
1055
+ : spawn('sh', ['-c', 'npm install -g @agilemonster/mcp-server@latest >/dev/null 2>&1 || npx -y @agilemonster/mcp-server@latest --version >/dev/null 2>&1'], {
1056
+ stdio: 'ignore',
1057
+ detached: true,
1058
+ });
1023
1059
  child.on('exit', () => {
1024
1060
  appendIdeActivityLog(cwd, {
1025
1061
  cardId: 'system-update',
@@ -1579,7 +1615,7 @@ async function runRedeployOnlyCard(client, card, projectPath, provider, workerLa
1579
1615
  pending_confirmation: res.errorKind === 'needs_confirmation' ? { method: res.method, command: res.command } : null,
1580
1616
  }));
1581
1617
  removeTaskLock(lockFile);
1582
- await client.askQuestion(card.id, provider, res.errorKind === 'needs_confirmation' ? describeFailureForHuman(res) : `The redeploy did not go through.\n\n${describeFailureForHuman(res)}`);
1618
+ await client.askQuestion(card.id, provider, res.errorKind === 'needs_confirmation' ? describeFailureForHuman(res) : `The redeploy did not go through.\n\n${describeFailureForHuman(res)}`, deployReplyOptions(res));
1583
1619
  return false;
1584
1620
  }
1585
1621
  if (res.pending && res.pendingVerification)
@@ -1643,6 +1679,86 @@ function removeTaskLock(lockFile) {
1643
1679
  }
1644
1680
  catch { }
1645
1681
  }
1682
+ /**
1683
+ * Look at the project's branch before any work is built on it.
1684
+ *
1685
+ * 'go' the folder is fine (or was brought up to date, which is noted on the card)
1686
+ * 'fresh' the owner chose to start from the remote's version: unpushed commits are on a backup branch
1687
+ * 'asked' the branch cannot be pushed as it is, and the card now carries the question
1688
+ *
1689
+ * A check that cannot be made (no Git, no remote, offline) never holds a card up.
1690
+ */
1691
+ async function ensureBranchReady(client, card, projectPath, provider, workerLabel, lockFile, replyAfter) {
1692
+ let prep;
1693
+ try {
1694
+ prep = await prepareBranch(projectPath);
1695
+ }
1696
+ catch (err) {
1697
+ log.warn('branch.check_failed', { card: card.id, project: projectPath, error: errorMessage(err) });
1698
+ return 'go';
1699
+ }
1700
+ const pending = getBranchQuestion(projectPath, card.id);
1701
+ if (prep.status !== 'blocked') {
1702
+ if (pending)
1703
+ setBranchQuestion(projectPath, card.id, null);
1704
+ if (prep.status === 'updated') {
1705
+ log.info('branch.updated', { card: card.id, project: projectPath, branch: prep.health.branch, behind: prep.health.behind, ahead: prep.health.ahead });
1706
+ try {
1707
+ await client.addProgress(card.id, provider, prep.note);
1708
+ }
1709
+ catch { }
1710
+ }
1711
+ return 'go';
1712
+ }
1713
+ const health = prep.health;
1714
+ log.info('branch.blocked', { card: card.id, project: projectPath, branch: health.branch, behind: health.behind, ahead: health.ahead, files: health.conflictFiles.slice(0, 5) });
1715
+ // Only a reply written after the question (or after the failed push that led here) is an answer to it
1716
+ const since = [pending?.at, replyAfter].filter((t) => Boolean(t)).sort().pop() || null;
1717
+ let reply = null;
1718
+ if (since) {
1719
+ try {
1720
+ const human = latestHumanComment(await client.listComments(card.id));
1721
+ reply = human && commentIsAfter(human, since) ? human : null;
1722
+ }
1723
+ catch (err) {
1724
+ log.warn('card.comments_unavailable', { card: card.id, error: errorMessage(err) });
1725
+ }
1726
+ }
1727
+ let prefix = '';
1728
+ if (reply && parseBranchDirective(reply.text) === 'fresh') {
1729
+ // Moving commits around in the project folder is for the person whose computer this is to decide
1730
+ const ownerId = await machineOwnerId(client);
1731
+ const authorId = reply.user_id ? String(reply.user_id) : '';
1732
+ if (!ownerId || !authorId || authorId !== ownerId) {
1733
+ prefix = 'Only the person whose computer runs this agent can decide what happens to the commits in its project folder. Please ask them to choose here.\n\n';
1734
+ }
1735
+ else {
1736
+ const fresh = await startFromRemote(projectPath);
1737
+ if (fresh.ok) {
1738
+ setBranchQuestion(projectPath, card.id, null);
1739
+ try {
1740
+ await client.addProgress(card.id, provider, `Done: the ${fresh.setAside === 1 ? 'unpushed commit is' : `${fresh.setAside} unpushed commits are`} safe on the branch \`${fresh.backup}\`, and the project folder now matches ${health.remoteLabel}. Files that only ${fresh.setAside === 1 ? 'that commit' : 'those commits'} added are on that branch too, and no longer in the folder. Starting on the card.`);
1741
+ }
1742
+ catch { }
1743
+ return 'fresh';
1744
+ }
1745
+ prefix = `I could not do that: ${fresh.error}. Nothing was moved.\n\n`;
1746
+ }
1747
+ }
1748
+ const { question, options } = describeBlockedBranch(health);
1749
+ removeTaskLock(lockFile);
1750
+ setBranchQuestion(projectPath, card.id, new Date().toISOString());
1751
+ appendIdeActivityLog(projectPath, {
1752
+ cardId: card.id,
1753
+ cardTitle: card.title,
1754
+ agentName: workerLabel,
1755
+ model: card.agent_model || 'auto',
1756
+ status: 'waiting_for_input',
1757
+ summary: `Not started: \`${health.branch}\` is ${health.behind} behind and ${health.ahead} ahead of ${health.remoteLabel}, and the two clash.`,
1758
+ });
1759
+ await client.askQuestion(card.id, provider, prefix + question, options);
1760
+ return 'asked';
1761
+ }
1646
1762
  /**
1647
1763
  * The code change for this card already exists — only its deployment failed. When the human replies
1648
1764
  * ("retry", or "deploy: <method or command>"), redo ONLY the deployment. Previously the whole card was
@@ -1665,7 +1781,8 @@ async function resumeFailedDeployment(client, card, projectPath, provider, worke
1665
1781
  if (reply && !parsed)
1666
1782
  return 'continue';
1667
1783
  const pendingRoute = state.pending_confirmation || null;
1668
- const choosesRoute = Boolean(parsed && (parsed.method || parsed.liveUrl || pendingRoute));
1784
+ // (so does "ship only this change", which moves commits around in their project folder)
1785
+ const choosesRoute = Boolean(parsed && (parsed.method || parsed.liveUrl || parsed.onlyThis || pendingRoute));
1669
1786
  if (choosesRoute) {
1670
1787
  // Choosing or confirming a route decides what is executed on THIS computer, so it has to come from the
1671
1788
  // person whose account this machine runs under — not from anyone who can comment on the board.
@@ -1683,7 +1800,7 @@ async function resumeFailedDeployment(client, card, projectPath, provider, worke
1683
1800
  // Nothing is ever confirmed on the human's behalf.
1684
1801
  removeTaskLock(lockFile);
1685
1802
  setCardState(projectPath, card.id, { ...state, at: new Date().toISOString() });
1686
- await client.askQuestion(card.id, provider, `I still need an answer before I deploy this project for the first time. Reply \`deploy: ${pendingRoute.command || pendingRoute.method}\` to confirm, \`deploy: manual\` if you deploy it yourself, or tell me the right route.`);
1803
+ await client.askQuestion(card.id, provider, `I still need an answer before I deploy this project for the first time. Reply \`deploy: ${pendingRoute.command || pendingRoute.method}\` to confirm, \`deploy: manual\` if you deploy it yourself, or tell me the right route.`, deployReplyOptions({ errorKind: 'needs_confirmation', method: pendingRoute.method, command: pendingRoute.command }));
1687
1804
  return 'failed';
1688
1805
  }
1689
1806
  // No fresh reply at all: a re-queue of a deploy-failed card means "try the same deployment again".
@@ -1703,15 +1820,33 @@ async function resumeFailedDeployment(client, card, projectPath, provider, worke
1703
1820
  set_by: 'the machine owner',
1704
1821
  });
1705
1822
  }
1706
- await client.addProgress(card.id, provider, directive.method
1707
- ? `Got it — deploying with **${methodLabel(directive.method)}**${directive.command ? ` (\`${directive.command}\`)` : ''}. I've saved this as the deployment route for this project, so future cards use it automatically.`
1708
- : 'Retrying the deployment of the change I already made (no code is being regenerated).');
1823
+ // "Ship only this change": older unpushed commits go to a backup branch, this card's commit goes on top of the remote
1824
+ let onlyThisNote = '';
1825
+ if (directive.onlyThis) {
1826
+ const only = await shipOnlyCardCommits(projectPath, card.id);
1827
+ if (!only.ok) {
1828
+ removeTaskLock(lockFile);
1829
+ setCardState(projectPath, card.id, { ...state, at: new Date().toISOString() });
1830
+ await client.askQuestion(card.id, provider, `I could not ship only this card's change: ${only.error}. Nothing was moved; the project folder is exactly as it was.\n\nIf you sort out the branch or push it yourself, reply **retry**: I will not redo the work, only push what is missing and check the deployment.`, [{ label: 'I fixed it, try again', reply: 'retry' }]);
1831
+ return 'failed';
1832
+ }
1833
+ onlyThisNote =
1834
+ `Putting only this card's change on top of the newer version and deploying it.` +
1835
+ (only.setAside > 0 ? ` The ${only.setAside === 1 ? 'older commit is' : `${only.setAside} older commits are`} safe on the branch \`${only.backup}\` and ${only.setAside === 1 ? 'is' : 'are'} not deployed. Files that only ${only.setAside === 1 ? 'that commit' : 'those commits'} added are on that branch too, and no longer in the folder.` : '');
1836
+ }
1837
+ await client.addProgress(card.id, provider, onlyThisNote
1838
+ ? onlyThisNote
1839
+ : directive.method
1840
+ ? `Got it — deploying with **${methodLabel(directive.method)}**${directive.command ? ` (\`${directive.command}\`)` : ''}. I've saved this as the deployment route for this project, so future cards use it automatically.`
1841
+ : 'Retrying the deployment of the change I already made (no code is being regenerated).');
1709
1842
  const res = await runDeployment({
1710
1843
  projectPath,
1711
1844
  title: card.title || 'update',
1712
1845
  cardId: card.id,
1713
1846
  files: state.files,
1714
- redeploy: true,
1847
+ // When the change never reached the remote, pushing it (or finding that a person already did) IS the retry:
1848
+ // an empty "trigger redeploy" commit would deploy production a second time for nothing.
1849
+ redeploy: state.failed_stage !== 'git',
1715
1850
  bound: boundProfileOf(card, await machineOwnerId(client)),
1716
1851
  override: directive.method ? { method: directive.method, command: directive.command } : null,
1717
1852
  });
@@ -1721,6 +1856,7 @@ async function resumeFailedDeployment(client, card, projectPath, provider, worke
1721
1856
  ...state,
1722
1857
  attempts: state.attempts + 1,
1723
1858
  last_error: res.errorDetail,
1859
+ failed_stage: res.stage,
1724
1860
  at: new Date().toISOString(),
1725
1861
  pending_confirmation: res.errorKind === 'needs_confirmation' ? { method: res.method, command: res.command } : null,
1726
1862
  });
@@ -1734,9 +1870,9 @@ async function resumeFailedDeployment(client, card, projectPath, provider, worke
1734
1870
  summary: `Deployment retry failed: ${res.errorDetail.slice(0, 500)}`,
1735
1871
  files: state.files,
1736
1872
  });
1737
- await client.askQuestion(card.id, provider, res.errorKind === 'needs_confirmation'
1873
+ await client.askQuestion(card.id, provider, res.errorKind === 'needs_confirmation' || res.stage === 'git'
1738
1874
  ? describeFailureForHuman(res)
1739
- : `The deployment still did not go through, so this card stays in progress.\n\n${describeFailureForHuman(res)}`);
1875
+ : `The deployment still did not go through, so this card stays in progress.\n\n${describeFailureForHuman(res)}`, deployReplyOptions(res));
1740
1876
  return 'failed';
1741
1877
  }
1742
1878
  if (res.pending && res.pendingVerification) {
@@ -1904,7 +2040,7 @@ async function produceChangeWithAgent(run) {
1904
2040
  return files.length > 0;
1905
2041
  };
1906
2042
  run.rollback.fn = async () => revert((await changesSince(projectPath, baseline)).changed);
1907
- const backToHuman = async (question, summary, files) => {
2043
+ const backToHuman = async (question, summary, files, options = []) => {
1908
2044
  run.rollback.fn = null;
1909
2045
  removeTaskLock(lockFile);
1910
2046
  appendIdeActivityLog(projectPath, {
@@ -1916,9 +2052,10 @@ async function produceChangeWithAgent(run) {
1916
2052
  summary,
1917
2053
  files,
1918
2054
  });
1919
- await client.askQuestion(card.id, provider, question);
2055
+ await client.askQuestion(card.id, provider, question, options);
1920
2056
  return 'handled';
1921
2057
  };
2058
+ const TRY_AGAIN = [{ label: 'Try again', reply: 'Please try again.' }];
1922
2059
  const prompt = buildAgentPrompt({
1923
2060
  title: card.title || '',
1924
2061
  description: card.description || '',
@@ -1974,11 +2111,11 @@ async function produceChangeWithAgent(run) {
1974
2111
  await revert(change.changed);
1975
2112
  return backToHuman(`I need to change ${foreign.map((f) => `\`${f}\``).join(', ')}, but ${foreign.length === 1 ? 'that file has' : 'those files have'} uncommitted changes in \`${projectPath}\` that are not mine. Committing my edit would also commit that unfinished work, so I stopped and put everything back.\n\n${started.interrupted
1976
2113
  ? 'An earlier run of this card was interrupted before it finished, so these may be its leftovers rather than your work. If you did not make them, discard them (`git checkout -- <file>`); otherwise commit or stash them. Then reply here and I will continue.'
1977
- : 'Commit or stash those changes, then reply here and I will continue.'}`, `Stopped: ${foreign.join(', ')} had uncommitted changes`, foreign);
2114
+ : 'Commit or stash those changes, then reply here and I will continue.'}`, `Stopped: ${foreign.join(', ')} had uncommitted changes`, foreign, [{ label: 'Done, continue', reply: 'I committed or stashed those changes. Please continue.' }]);
1978
2115
  }
1979
2116
  if (!result.ok) {
1980
2117
  await revert(change.changed);
1981
- return backToHuman(`${engine.label} worked on this for ${Math.max(1, Math.round(result.durationMs / 60000))} minute(s) but did not finish: ${result.failure || 'it stopped without a result'}.\n\nI put the files back the way they were. Reply here to have it try again — if the card is large, splitting it into smaller cards helps.`, `${engine.label} did not finish: ${result.failure || 'no result'}`, []);
2118
+ return backToHuman(`${engine.label} worked on this for ${Math.max(1, Math.round(result.durationMs / 60000))} minute(s) but did not finish: ${result.failure || 'it stopped without a result'}.\n\nI put the files back the way they were. Reply here to have it try again — if the card is large, splitting it into smaller cards helps.`, `${engine.label} did not finish: ${result.failure || 'no result'}`, [], TRY_AGAIN);
1982
2119
  }
1983
2120
  let report = parseAgentReport(result.text);
1984
2121
  if (report.status === 'blocked' || change.changed.length === 0) {
@@ -1986,7 +2123,7 @@ async function produceChangeWithAgent(run) {
1986
2123
  const said = report.question || report.summary || proseOf(result.text);
1987
2124
  return backToHuman(report.status === 'blocked' && report.question
1988
2125
  ? report.question
1989
- : `${engine.label} finished without changing any files.${said ? `\n\n${said}` : ''}\n\nIf something is still missing, reply here with what you expected to change.`, `Needs input: ${said.slice(0, 400)}`, []);
2126
+ : `${engine.label} finished without changing any files.${said ? `\n\n${said}` : ''}\n\nIf something is still missing, reply here with what you expected to change.`, `Needs input: ${said.slice(0, 400)}`, [], report.status === 'blocked' && report.question ? report.options : []);
1990
2127
  }
1991
2128
  // 2. The runner's own build gate. The agent built it too, so a failure here is usually this machine's
1992
2129
  // environment — check that before sending the agent after an error that was never its own.
@@ -2032,7 +2169,7 @@ async function produceChangeWithAgent(run) {
2032
2169
  const stillForeign = change.overlapping.filter((f) => !carryOverFiles.includes(f));
2033
2170
  if (!gate.ok || change.changed.length === 0 || stillForeign.length > 0) {
2034
2171
  await revert(change.changed);
2035
- return backToHuman(`${engine.label} made the change, but the project did not build with it, and its attempt to fix that did not get the build green. I reverted everything rather than ship a broken build.\n\n\`\`\`\n${buildErrorExcerpt(gate.errorOutput || '')}\n\`\`\`\n\nYour description was clear — nothing more is needed from you on that. Reply here to have it try again.`, `Reverted: the build failed after ${engine.label}'s change`, []);
2172
+ return backToHuman(`${engine.label} made the change, but the project did not build with it, and its attempt to fix that did not get the build green. I reverted everything rather than ship a broken build.\n\n\`\`\`\n${buildErrorExcerpt(gate.errorOutput || '')}\n\`\`\`\n\nYour description was clear — nothing more is needed from you on that. Reply here to have it try again.`, `Reverted: the build failed after ${engine.label}'s change`, [], TRY_AGAIN);
2036
2173
  }
2037
2174
  }
2038
2175
  }
@@ -2411,6 +2548,18 @@ export async function executeQueuedCardAutonomously(client, card, projectPath) {
2411
2548
  if (isRedeployOnlyCard(card)) {
2412
2549
  return await runRedeployOnlyCard(client, card, projectPath, provider, workerLabel, lockFile);
2413
2550
  }
2551
+ // 1c. Can work be built and pushed from this folder? Look BEFORE the agent spends minutes on a branch that is
2552
+ // behind its remote — and ask now, in plain words, if a person has to decide something first.
2553
+ let earlierSummary = priorState?.summary || null;
2554
+ const branchReady = await ensureBranchReady(client, card, projectPath, provider, workerLabel, lockFile, priorState?.at || null);
2555
+ if (branchReady === 'asked')
2556
+ return false;
2557
+ if (branchReady === 'fresh') {
2558
+ // The local commits (this card's earlier attempt included) are on a backup branch: start the card over
2559
+ carryOverFiles = [];
2560
+ earlierSummary = null;
2561
+ setCardState(projectPath, card.id, null);
2562
+ }
2414
2563
  const profile = resolveDeployProfile(projectPath, { bound: boundProfileOf(card, await machineOwnerId(client)), memory: loadMemory(projectPath) });
2415
2564
  const ownerId = await machineOwnerId(client);
2416
2565
  const run = {
@@ -2425,7 +2574,7 @@ export async function executeQueuedCardAutonomously(client, card, projectPath) {
2425
2574
  originalContents,
2426
2575
  ownerId,
2427
2576
  conversation: await cardConversation(client, card, ownerId),
2428
- earlierSummary: priorState?.summary || null,
2577
+ earlierSummary,
2429
2578
  engineNotes: [],
2430
2579
  rollback,
2431
2580
  };
@@ -2489,6 +2638,7 @@ export async function executeQueuedCardAutonomously(client, card, projectPath) {
2489
2638
  setCardState(projectPath, card.id, cardState('deploy_failed', {
2490
2639
  commit: deployInfo.commitHash || null,
2491
2640
  last_error: deployInfo.errorDetail,
2641
+ failed_stage: deployInfo.stage,
2492
2642
  pending_confirmation: deployInfo.errorKind === 'needs_confirmation' ? { method: deployInfo.method, command: deployInfo.command } : null,
2493
2643
  }));
2494
2644
  removeTaskLock(lockFile);
@@ -2505,7 +2655,9 @@ export async function executeQueuedCardAutonomously(client, card, projectPath) {
2505
2655
  const changed = modifiedFiles.map((f) => `\`${f}\``).join(', ');
2506
2656
  await client.askQuestion(card.id, provider, deployInfo.errorKind === 'needs_confirmation'
2507
2657
  ? `I made the code change (${changed}). One question before I deploy this project for the first time:\n\n${describeFailureForHuman(deployInfo)}`
2508
- : `I made the code change (${changed}), but the deployment could not be verified, so I kept this card in progress instead of moving it to Testing.\n\n${describeFailureForHuman(deployInfo)}`);
2658
+ : deployInfo.stage === 'git'
2659
+ ? `${describeFailureForHuman(deployInfo)}\n\nFiles I changed for this card: ${changed}.`
2660
+ : `I made the code change (${changed}), but the deployment could not be verified, so I kept this card in progress instead of moving it to Testing.\n\n${describeFailureForHuman(deployInfo)}`, deployReplyOptions(deployInfo));
2509
2661
  return false;
2510
2662
  }
2511
2663
  if (deployInfo.pending && deployInfo.pendingVerification) {
@@ -2820,7 +2972,7 @@ async function processPendingVerifications(client, projectPaths) {
2820
2972
  attempts: 1,
2821
2973
  last_error: detail,
2822
2974
  });
2823
- await client.askQuestion(p.card_id, p.provider, `The deployment I started for this card did not succeed, so the change is NOT live yet.\n\nWhat failed: ${detail}\n\nFix the failing step and reply **retry**, or tell me the right route with \`deploy: <method or command>\`.`);
2975
+ await client.askQuestion(p.card_id, p.provider, `The deployment I started for this card did not succeed, so the change is NOT live yet.\n\nWhat failed: ${detail}\n\nFix the failing step and reply **retry**, or tell me the right route with \`deploy: <method or command>\`.`, deployReplyOptions({ errorKind: 'ci_failed' }));
2824
2976
  await client.reportDeployment({ card_id: p.card_id, project_path: projectPath, method: 'github_actions', ok: false, error: detail.slice(0, 480), error_kind: 'ci_failed', commit: short });
2825
2977
  }
2826
2978
  else if (ageMs > 90 * 60 * 1000) {
@@ -2887,6 +3039,7 @@ export function startBuiltInPullPulse(server, client, intervalMs = 20000, role =
2887
3039
  let holdsWorkerLock = false;
2888
3040
  let journalsRecovered = false;
2889
3041
  let consecutiveFailures = 0;
3042
+ let warnedNoKey = false;
2890
3043
  const autoProvisionSavedBindings = (apiKey, bindings) => {
2891
3044
  for (const b of bindings) {
2892
3045
  const projPath = (b.project_path || '').trim();
@@ -2965,19 +3118,28 @@ export function startBuiltInPullPulse(server, client, intervalMs = 20000, role =
2965
3118
  try {
2966
3119
  const config = client.loadProjectConfig();
2967
3120
  const apiKey = client.getApiKey();
2968
- if (!config?.board_id || !apiKey) {
3121
+ // The background service is not started inside a project folder: it has no board of its own and
3122
+ // serves every folder linked on this machine. A coding tool's MCP server only pulses for its project.
3123
+ const homeBoardId = config?.board_id || '';
3124
+ if (!apiKey || (!homeBoardId && role !== 'daemon')) {
3125
+ if (role === 'daemon' && !apiKey && !warnedNoKey) {
3126
+ warnedNoKey = true;
3127
+ log.warn('daemon.no_api_key', { hint: 'run `npx -y @agilemonster/mcp-server bootstrap` in a project folder' });
3128
+ }
2969
3129
  return;
2970
3130
  }
3131
+ const homePath = homeBoardId ? config?.project_path || process.cwd() : '';
2971
3132
  const includeDiscovery = pulseCount === 0 || pulseCount % 3 === 0;
2972
3133
  pulseCount++;
2973
3134
  const res = await client.pulse({
2974
- boardId: config.board_id,
3135
+ boardId: homeBoardId || undefined,
2975
3136
  provider: 'any',
2976
- projectName: config.project_name || path.basename(process.cwd()),
2977
- projectPath: config.project_path || process.cwd(),
3137
+ projectName: homeBoardId ? config?.project_name || path.basename(process.cwd()) : undefined,
3138
+ projectPath: homePath || undefined,
2978
3139
  discoveredProjects: includeDiscovery ? client.discoverLocalProjects() : undefined,
2979
3140
  discoveredModels: includeDiscovery ? client.discoverLocalAgentModels() : undefined,
2980
3141
  activeClaims: activeClaims(),
3142
+ machine: await machineReport(role).catch(() => undefined),
2981
3143
  });
2982
3144
  if (consecutiveFailures > 0) {
2983
3145
  log.info('pulse.recovered', { after_failures: consecutiveFailures });
@@ -2987,13 +3149,14 @@ export function startBuiltInPullPulse(server, client, intervalMs = 20000, role =
2987
3149
  autoProvisionSavedBindings(apiKey, res.saved_bindings);
2988
3150
  }
2989
3151
  const queued = res.queued_cards || [];
2990
- syncIdeTaskRules(config.board_name || 'Board', queued);
3152
+ if (homeBoardId)
3153
+ syncIdeTaskRules(config?.board_name || 'Board', queued);
2991
3154
  for (const card of queued) {
2992
3155
  if (!notifiedCardIds.has(card.id)) {
2993
3156
  notifiedCardIds.add(card.id);
2994
3157
  const worker = card.agent_display_name || card.agent_provider || 'AI Agent';
2995
3158
  const modelTag = card.agent_model && card.agent_model !== 'auto' ? ` [${card.agent_model}]` : '';
2996
- sendDesktopNotification(`Agile Monster -> ${worker}${modelTag}`, `New task queued on ${config.board_name || 'Board'}: "${card.title}"`);
3159
+ sendDesktopNotification(`Agile Monster -> ${worker}${modelTag}`, `New task queued on ${config?.board_name || 'Board'}: "${card.title}"`);
2997
3160
  try {
2998
3161
  if (server) {
2999
3162
  await server.sendResourceUpdated({ uri: 'agilemonster://project/context' });
@@ -3013,7 +3176,7 @@ export function startBuiltInPullPulse(server, client, intervalMs = 20000, role =
3013
3176
  const boundQueued = res.all_bound_queued_cards || [];
3014
3177
  for (const qCard of boundQueued) {
3015
3178
  const targetPath = (qCard.bound_project_path || '').trim() ||
3016
- (qCard.board_id === config.board_id ? config.project_path || process.cwd() : '');
3179
+ (homeBoardId && qCard.board_id === homeBoardId ? homePath : '');
3017
3180
  if (targetPath && fs.existsSync(targetPath)) {
3018
3181
  await executeQueuedCardAutonomously(client, qCard, targetPath);
3019
3182
  }
@@ -3024,12 +3187,12 @@ export function startBuiltInPullPulse(server, client, intervalMs = 20000, role =
3024
3187
  const boundDone = res.all_bound_done_cards || [];
3025
3188
  for (const dCard of boundDone) {
3026
3189
  const targetPath = (dCard.bound_project_path || '').trim() ||
3027
- (dCard.board_id === config.board_id ? config.project_path || process.cwd() : '');
3190
+ (homeBoardId && dCard.board_id === homeBoardId ? homePath : '');
3028
3191
  if (targetPath && fs.existsSync(targetPath)) {
3029
3192
  await deployApprovedCardIfNeeded(client, dCard, targetPath);
3030
3193
  }
3031
3194
  }
3032
- const knownPaths = new Set([config.project_path || process.cwd()]);
3195
+ const knownPaths = new Set(homePath ? [homePath] : []);
3033
3196
  for (const b of res.saved_bindings || []) {
3034
3197
  if (b.project_path)
3035
3198
  knownPaths.add(b.project_path.trim());
@@ -0,0 +1,82 @@
1
+ import { ExecOptions, ExecResult } from './runtime.js';
2
+ /**
3
+ * Runs the card runner as a background service that starts with the computer.
4
+ *
5
+ * Without this, cards queued from the board are only worked while an AI coding tool happens to be open in
6
+ * the project folder (its MCP server pulses as a side effect). People do not know that, so their first card
7
+ * sits in "queued" for ever. `bootstrap` installs the service; `service install|status|uninstall` manage it.
8
+ *
9
+ * The service runs its own copy of the package from ~/.agilemonster/runner, so it does not depend on an npx
10
+ * cache that can be cleared or on a project folder that can move, and it updates that copy itself.
11
+ *
12
+ * macOS: a launchd agent. Linux: a systemd user unit. Windows: not yet.
13
+ */
14
+ export declare const SERVICE_LABEL = "com.agilemonster.pulse";
15
+ export declare const SYSTEMD_UNIT = "agilemonster-runner.service";
16
+ export type ServiceKind = 'launchd' | 'systemd';
17
+ export interface ServiceDeps {
18
+ platform: NodeJS.Platform;
19
+ home: string;
20
+ /** Machine-local Agile Monster folder (~/.agilemonster). */
21
+ amHome: string;
22
+ uid: number | null;
23
+ node: string;
24
+ npm: string | null;
25
+ pathEnv: string;
26
+ exec: (command: string, args?: string[], options?: ExecOptions) => Promise<ExecResult>;
27
+ }
28
+ /**
29
+ * The node binary the service should start. `process.execPath` is often a versioned folder (Homebrew's
30
+ * Cellar/node@24/24.21.0_1/…) that disappears with the next upgrade and would leave the service unable to
31
+ * start; the `node` on PATH is a stable link to the same binary, so prefer it when it is the same one.
32
+ */
33
+ export declare function stableNodePath(): string;
34
+ export declare function defaultServiceDeps(): ServiceDeps;
35
+ export declare function runnerDir(amHome: string): string;
36
+ /** The service's own copy of this package's entry point. */
37
+ export declare function runnerEntry(amHome: string): string;
38
+ export declare function serviceKind(platform: NodeJS.Platform): ServiceKind | null;
39
+ export declare function unitPath(kind: ServiceKind, home: string): string;
40
+ export interface UnitOptions {
41
+ node: string;
42
+ entry: string;
43
+ home: string;
44
+ logDir: string;
45
+ pathEnv: string;
46
+ }
47
+ export declare function launchdPlist(o: UnitOptions): string;
48
+ export declare function systemdUnit(o: UnitOptions): string;
49
+ /** The program an existing unit file starts (the script after the node binary), or null when unreadable. */
50
+ export declare function unitTarget(kind: ServiceKind, content: string): string | null;
51
+ export interface ServiceStatus {
52
+ supported: boolean;
53
+ kind: ServiceKind | null;
54
+ unitPath: string | null;
55
+ installed: boolean;
56
+ /** True when the unit starts the copy this module installs (not a hand-made or development setup). */
57
+ managed: boolean;
58
+ target: string | null;
59
+ running: boolean;
60
+ }
61
+ export declare function serviceStatus(deps?: ServiceDeps): Promise<ServiceStatus>;
62
+ export interface ServiceResult {
63
+ ok: boolean;
64
+ /** 'installed' | 'kept' (someone else's setup left alone) | 'unsupported' | 'failed' | 'removed' | 'absent' */
65
+ outcome: 'installed' | 'kept' | 'unsupported' | 'failed' | 'removed' | 'absent';
66
+ message: string;
67
+ }
68
+ export declare function installService(options: {
69
+ force?: boolean;
70
+ hasApiKey: boolean;
71
+ }, deps?: ServiceDeps): Promise<ServiceResult>;
72
+ export declare function uninstallService(options?: {
73
+ force?: boolean;
74
+ }, deps?: ServiceDeps): Promise<ServiceResult>;
75
+ /** npm arguments that install or update the service's own copy of the package. */
76
+ export declare function updateArgs(dir: string, version?: string): string[];
77
+ /**
78
+ * When this process is the service's own copy, the folder to update in place. Null for a copy started by
79
+ * npx, a global install or a source checkout: those update the way they were installed.
80
+ */
81
+ export declare function managedRunnerDir(runningFile: string, amHome?: string): string | null;
82
+ export declare function describeService(deps?: ServiceDeps): Promise<string>;