@agilemonster/mcp-server 1.0.16 → 1.0.21

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.
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Runtime plumbing shared by the pulse daemon and the stdio MCP server:
3
+ * - where Agile Monster keeps its machine-local state,
4
+ * - a PATH that matches what the developer has in their own terminal,
5
+ * - non-blocking command execution (so heartbeats keep ticking during long deploys),
6
+ * - a structured log that is never written to stdout in stdio mode (stdout is the MCP protocol channel).
7
+ */
8
+ export declare function agileMonsterHome(): string;
9
+ /** Daemon mode may echo to stdout (captured by launchd); stdio mode must never do that. */
10
+ export declare function configureLogger(options: {
11
+ echo: boolean;
12
+ }): void;
13
+ export declare const log: {
14
+ info: (event: string, data?: Record<string, unknown>) => void;
15
+ warn: (event: string, data?: Record<string, unknown>) => void;
16
+ error: (event: string, data?: Record<string, unknown>) => void;
17
+ };
18
+ export declare function errorMessage(err: unknown): string;
19
+ /**
20
+ * Merge PATH lists, keeping the order of `current` and appending entries from `extra` that are new.
21
+ * Pure so it can be unit tested.
22
+ */
23
+ export declare function mergePathEntries(current: string, extra: string[], delimiter?: ";" | ":"): string;
24
+ /**
25
+ * Cheap, synchronous half of PATH repair: add the well-known tool directories that exist on this machine
26
+ * (Homebrew, ~/.docker/bin, OrbStack, ~/.local/bin, …). Safe to call at startup; it only stats a few folders.
27
+ */
28
+ export declare function addWellKnownToolDirs(): string[];
29
+ /**
30
+ * A launchd/systemd daemon (or an IDE-spawned MCP server) starts with a minimal PATH, so tools that work in
31
+ * the developer's terminal (docker in ~/.docker/bin, fly, pnpm, nvm-managed node, …) are "command not found".
32
+ * This asks the login shell for its PATH once — without blocking the event loop — and merges it in together
33
+ * with the well-known tool directories. Resolves to the directories that were added.
34
+ */
35
+ export declare function ensureUserPath(force?: boolean): Promise<string[]>;
36
+ /** Locate an executable on the current PATH without spawning a shell. */
37
+ export declare function which(bin: string): string | null;
38
+ export interface ExecResult {
39
+ ok: boolean;
40
+ code: number | null;
41
+ stdout: string;
42
+ stderr: string;
43
+ timedOut: boolean;
44
+ /** Combined, trimmed tail of stderr + stdout: what a human needs to see when it failed. */
45
+ tail: string;
46
+ }
47
+ export interface ExecOptions {
48
+ cwd?: string;
49
+ timeoutMs?: number;
50
+ env?: NodeJS.ProcessEnv;
51
+ /** Run `command` through the shell (needed for user-supplied deploy commands). */
52
+ shell?: boolean;
53
+ }
54
+ /**
55
+ * Run a command without blocking the event loop. Never rejects: failures come back as `ok: false`.
56
+ * With `shell: false` (the default) `args` are passed verbatim, so paths and titles need no quoting.
57
+ */
58
+ export declare function exec(command: string, args?: string[], options?: ExecOptions): Promise<ExecResult>;
59
+ /** Run a user-supplied command line through the shell. */
60
+ export declare function execShell(commandLine: string, options?: ExecOptions): Promise<ExecResult>;
61
+ export declare function sleep(ms: number): Promise<void>;
62
+ /** Write JSON atomically so a crash mid-write never leaves a truncated state file. */
63
+ export declare function writeJsonAtomic(file: string, data: unknown): void;
64
+ export declare function readJson<T>(file: string): T | null;
@@ -0,0 +1,293 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { spawn } from 'node:child_process';
5
+ /**
6
+ * Runtime plumbing shared by the pulse daemon and the stdio MCP server:
7
+ * - where Agile Monster keeps its machine-local state,
8
+ * - a PATH that matches what the developer has in their own terminal,
9
+ * - non-blocking command execution (so heartbeats keep ticking during long deploys),
10
+ * - a structured log that is never written to stdout in stdio mode (stdout is the MCP protocol channel).
11
+ */
12
+ export function agileMonsterHome() {
13
+ const override = (process.env.AGILEMONSTER_HOME || '').trim();
14
+ const dir = override || path.join(os.homedir(), '.agilemonster');
15
+ try {
16
+ fs.mkdirSync(dir, { recursive: true });
17
+ }
18
+ catch { }
19
+ return dir;
20
+ }
21
+ let echoToStdout = false;
22
+ const MAX_LOG_BYTES = 2 * 1024 * 1024;
23
+ /** Daemon mode may echo to stdout (captured by launchd); stdio mode must never do that. */
24
+ export function configureLogger(options) {
25
+ echoToStdout = options.echo;
26
+ }
27
+ function writeLog(level, event, data) {
28
+ const entry = { at: new Date().toISOString(), level, event, pid: process.pid, ...(data || {}) };
29
+ let line = '';
30
+ try {
31
+ line = JSON.stringify(entry);
32
+ }
33
+ catch {
34
+ line = JSON.stringify({ at: entry.at, level, event, pid: process.pid, note: 'unserializable log data' });
35
+ }
36
+ try {
37
+ const dir = path.join(agileMonsterHome(), 'logs');
38
+ fs.mkdirSync(dir, { recursive: true });
39
+ const file = path.join(dir, 'daemon.jsonl');
40
+ try {
41
+ if (fs.statSync(file).size > MAX_LOG_BYTES) {
42
+ fs.renameSync(file, `${file}.1`);
43
+ }
44
+ }
45
+ catch { }
46
+ fs.appendFileSync(file, line + '\n', 'utf8');
47
+ }
48
+ catch { }
49
+ if (echoToStdout) {
50
+ try {
51
+ console.log(line);
52
+ }
53
+ catch { }
54
+ }
55
+ }
56
+ export const log = {
57
+ info: (event, data) => writeLog('info', event, data),
58
+ warn: (event, data) => writeLog('warn', event, data),
59
+ error: (event, data) => writeLog('error', event, data),
60
+ };
61
+ export function errorMessage(err) {
62
+ if (err instanceof Error)
63
+ return err.message;
64
+ return String(err);
65
+ }
66
+ // ---------------------------------------------------------------------------
67
+ // PATH hydration
68
+ // ---------------------------------------------------------------------------
69
+ /**
70
+ * Merge PATH lists, keeping the order of `current` and appending entries from `extra` that are new.
71
+ * Pure so it can be unit tested.
72
+ */
73
+ export function mergePathEntries(current, extra, delimiter = path.delimiter) {
74
+ const seen = new Set();
75
+ const out = [];
76
+ for (const entry of [...current.split(delimiter), ...extra]) {
77
+ const clean = entry.trim();
78
+ if (!clean || seen.has(clean))
79
+ continue;
80
+ seen.add(clean);
81
+ out.push(clean);
82
+ }
83
+ return out.join(delimiter);
84
+ }
85
+ function wellKnownToolDirs() {
86
+ const home = os.homedir();
87
+ return [
88
+ '/opt/homebrew/bin',
89
+ '/opt/homebrew/sbin',
90
+ '/usr/local/bin',
91
+ path.join(home, '.docker', 'bin'),
92
+ path.join(home, '.orbstack', 'bin'),
93
+ '/Applications/Docker.app/Contents/Resources/bin',
94
+ path.join(home, '.local', 'bin'),
95
+ path.join(home, 'bin'),
96
+ path.join(home, '.fly', 'bin'),
97
+ path.join(home, '.bun', 'bin'),
98
+ path.join(home, '.volta', 'bin'),
99
+ path.join(home, '.cargo', 'bin'),
100
+ path.join(home, '.pub-cache', 'bin'),
101
+ path.join(home, 'go', 'bin'),
102
+ '/snap/bin',
103
+ ];
104
+ }
105
+ function applyExtraPath(extra) {
106
+ const before = process.env.PATH || '';
107
+ const merged = mergePathEntries(before, extra);
108
+ process.env.PATH = merged;
109
+ const beforeSet = new Set(before.split(path.delimiter));
110
+ return merged.split(path.delimiter).filter((p) => !beforeSet.has(p));
111
+ }
112
+ /**
113
+ * Cheap, synchronous half of PATH repair: add the well-known tool directories that exist on this machine
114
+ * (Homebrew, ~/.docker/bin, OrbStack, ~/.local/bin, …). Safe to call at startup; it only stats a few folders.
115
+ */
116
+ export function addWellKnownToolDirs() {
117
+ if (process.platform === 'win32')
118
+ return [];
119
+ const extra = [];
120
+ for (const dir of wellKnownToolDirs()) {
121
+ try {
122
+ if (fs.existsSync(dir))
123
+ extra.push(dir);
124
+ }
125
+ catch { }
126
+ }
127
+ return applyExtraPath(extra);
128
+ }
129
+ let shellPathPromise = null;
130
+ /**
131
+ * A launchd/systemd daemon (or an IDE-spawned MCP server) starts with a minimal PATH, so tools that work in
132
+ * the developer's terminal (docker in ~/.docker/bin, fly, pnpm, nvm-managed node, …) are "command not found".
133
+ * This asks the login shell for its PATH once — without blocking the event loop — and merges it in together
134
+ * with the well-known tool directories. Resolves to the directories that were added.
135
+ */
136
+ export function ensureUserPath(force = false) {
137
+ if (shellPathPromise && !force)
138
+ return shellPathPromise;
139
+ shellPathPromise = (async () => {
140
+ const added = addWellKnownToolDirs();
141
+ if (process.platform === 'win32')
142
+ return added;
143
+ try {
144
+ const shell = process.env.SHELL && fs.existsSync(process.env.SHELL) ? process.env.SHELL : '/bin/sh';
145
+ const res = await exec(shell, ['-ilc', 'printf "__AM_PATH__%s__AM_END__" "$PATH"'], { timeoutMs: 8000 });
146
+ const match = res.stdout.match(/__AM_PATH__(.*?)__AM_END__/s);
147
+ if (match && match[1]) {
148
+ added.push(...applyExtraPath(match[1].split(path.delimiter).filter((d) => d && fs.existsSync(d))));
149
+ }
150
+ }
151
+ catch {
152
+ // A shell that refuses -i or takes too long must never stop a deployment; the well-known dirs still apply.
153
+ }
154
+ return added;
155
+ })();
156
+ return shellPathPromise;
157
+ }
158
+ /** Locate an executable on the current PATH without spawning a shell. */
159
+ export function which(bin) {
160
+ const exts = process.platform === 'win32' ? ['.exe', '.cmd', '.bat', ''] : [''];
161
+ for (const dir of (process.env.PATH || '').split(path.delimiter)) {
162
+ if (!dir)
163
+ continue;
164
+ for (const ext of exts) {
165
+ const candidate = path.join(dir, bin + ext);
166
+ try {
167
+ fs.accessSync(candidate, fs.constants.X_OK);
168
+ if (fs.statSync(candidate).isFile())
169
+ return candidate;
170
+ }
171
+ catch { }
172
+ }
173
+ }
174
+ return null;
175
+ }
176
+ const MAX_CAPTURE = 256 * 1024;
177
+ function summarizeTail(stdout, stderr) {
178
+ return (stderr + '\n' + stdout)
179
+ .split('\n')
180
+ .map((l) => l.trim())
181
+ .filter((l) => l.length > 0)
182
+ .slice(-8)
183
+ .join(' | ')
184
+ .slice(0, 900);
185
+ }
186
+ /**
187
+ * Run a command without blocking the event loop. Never rejects: failures come back as `ok: false`.
188
+ * With `shell: false` (the default) `args` are passed verbatim, so paths and titles need no quoting.
189
+ */
190
+ export function exec(command, args = [], options = {}) {
191
+ return new Promise((resolve) => {
192
+ let stdout = '';
193
+ let stderr = '';
194
+ let timedOut = false;
195
+ let settled = false;
196
+ const useGroup = process.platform !== 'win32';
197
+ let child;
198
+ try {
199
+ child = spawn(command, options.shell ? [] : args, {
200
+ cwd: options.cwd,
201
+ env: options.env || process.env,
202
+ shell: options.shell ? true : false,
203
+ stdio: ['ignore', 'pipe', 'pipe'],
204
+ detached: useGroup,
205
+ });
206
+ }
207
+ catch (err) {
208
+ resolve({ ok: false, code: null, stdout: '', stderr: errorMessage(err), timedOut: false, tail: errorMessage(err) });
209
+ return;
210
+ }
211
+ const killTree = (signal) => {
212
+ try {
213
+ if (useGroup && child.pid) {
214
+ process.kill(-child.pid, signal);
215
+ }
216
+ else {
217
+ child.kill(signal);
218
+ }
219
+ }
220
+ catch { }
221
+ };
222
+ const timer = options.timeoutMs
223
+ ? setTimeout(() => {
224
+ timedOut = true;
225
+ killTree('SIGTERM');
226
+ setTimeout(() => {
227
+ killTree('SIGKILL');
228
+ // A grandchild that detached itself can keep the pipes open forever: never wait on it.
229
+ setTimeout(() => finish(null), 2000);
230
+ }, 4000);
231
+ }, options.timeoutMs)
232
+ : null;
233
+ const finish = (code, extraErr = '') => {
234
+ if (settled)
235
+ return;
236
+ settled = true;
237
+ if (timer)
238
+ clearTimeout(timer);
239
+ // Stop listening so a lingering background process cannot keep this call (or the event loop) alive
240
+ child.stdout?.destroy();
241
+ child.stderr?.destroy();
242
+ const errText = extraErr ? `${stderr}\n${extraErr}` : stderr;
243
+ const fullErr = timedOut
244
+ ? `${errText}\nTimed out after ${Math.round((options.timeoutMs || 0) / 1000)}s`
245
+ : errText;
246
+ resolve({
247
+ ok: code === 0 && !timedOut,
248
+ code,
249
+ stdout,
250
+ stderr: fullErr,
251
+ timedOut,
252
+ tail: summarizeTail(stdout, fullErr),
253
+ });
254
+ };
255
+ child.stdout?.on('data', (d) => {
256
+ stdout = (stdout + d.toString('utf8')).slice(-MAX_CAPTURE);
257
+ });
258
+ child.stderr?.on('data', (d) => {
259
+ stderr = (stderr + d.toString('utf8')).slice(-MAX_CAPTURE);
260
+ });
261
+ child.on('error', (err) => finish(null, errorMessage(err)));
262
+ child.on('close', (code) => finish(code));
263
+ // The command is done when its process exits. A background process it started (`nohup server &`) may hold the
264
+ // output pipes open indefinitely, so give the pipes a moment to drain and then report — without killing it.
265
+ child.on('exit', (code) => {
266
+ setTimeout(() => finish(code), 400);
267
+ });
268
+ });
269
+ }
270
+ /** Run a user-supplied command line through the shell. */
271
+ export function execShell(commandLine, options = {}) {
272
+ return exec(commandLine, [], { ...options, shell: true });
273
+ }
274
+ export function sleep(ms) {
275
+ return new Promise((resolve) => setTimeout(resolve, ms));
276
+ }
277
+ /** Write JSON atomically so a crash mid-write never leaves a truncated state file. */
278
+ export function writeJsonAtomic(file, data) {
279
+ fs.mkdirSync(path.dirname(file), { recursive: true });
280
+ const tmp = `${file}.${process.pid}.tmp`;
281
+ fs.writeFileSync(tmp, JSON.stringify(data, null, 2), 'utf8');
282
+ fs.renameSync(tmp, file);
283
+ }
284
+ export function readJson(file) {
285
+ try {
286
+ if (!fs.existsSync(file))
287
+ return null;
288
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
289
+ }
290
+ catch {
291
+ return null;
292
+ }
293
+ }
package/dist/server.d.ts CHANGED
@@ -35,11 +35,24 @@ export declare function createServer(client?: AgileMonsterClient): Server<{
35
35
  } | undefined;
36
36
  } | undefined;
37
37
  }>;
38
+ export declare function collectProjectSourceFiles(projectPath: string, keywords: string[]): string[];
39
+ /**
40
+ * Roll back edits left behind by a worker that died mid-card. A file is only restored if it still holds
41
+ * exactly what that worker wrote — anything the developer touched since is left alone.
42
+ */
43
+ export declare function recoverEditJournals(): number;
44
+ /**
45
+ * A card whose TITLE is an operations request ("Restart the container", "Redeploy production").
46
+ * Anchored to the start of the title so "Add a restart button to the server page" is not one.
47
+ */
48
+ export declare function isRedeployOnlyCard(card: Pick<Card, 'title'>): boolean;
38
49
  export declare function executeQueuedCardAutonomously(client: AgileMonsterClient, card: Card, projectPath: string): Promise<boolean>;
39
50
  /**
40
- * Deploy a card the human approved into Done (or flagged as `deploying`).
41
- * Idempotent: keyed on card ID only (never on `updated_at`, which changes whenever we post a comment),
42
- * so a card is deployed at most once unless the human explicitly re-triggers it into `deploying`.
51
+ * Finish a card the human approved into Done (or flagged as `deploying`).
52
+ * Idempotent: keyed on card ID only (never on `updated_at`, which changes whenever we post a comment).
53
+ *
54
+ * Approving a card that was already deployed and verified at review time only CONFIRMS it — it no longer
55
+ * triggers a second deployment (or an empty "redeploy" commit) for every approval.
43
56
  */
44
57
  export declare function deployApprovedCardIfNeeded(client: AgileMonsterClient, card: Card, projectPath: string): Promise<boolean>;
45
58
  export declare function acquireWorkerLock(role: 'daemon' | 'stdio'): boolean;