gsd-unsupervised 1.0.0 → 1.0.1

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/README.md CHANGED
@@ -244,7 +244,7 @@ Resume uses this to re-run `execute-plan` for phase 2 plan 1 only, then continue
244
244
 
245
245
  **Parallel goal pool:** With `--parallel`, a worker pool of size `--max-concurrent` is used; a per-workspace mutex keeps one goal running at a time for a single workspace (phase-level parallel inside execute-phase still applies).
246
246
 
247
- **SMS (Twilio):** Optional. Set `TWILIO_ACCOUNT_SID`, `TWILIO_AUTH_TOKEN`, `TWILIO_FROM`, and `TWILIO_TO` to receive SMS on goal complete, goal failed, and daemon paused (after 3 retries). If any are unset, SMS is skipped and the daemon runs normally.
247
+ **SMS (Twilio):** Optional. Set `TWILIO_ACCOUNT_SID`, `TWILIO_AUTH_TOKEN`, `TWILIO_FROM`, and `TWILIO_TO` to receive SMS on goal complete, goal failed, and daemon paused (after 3 retries). If any are unset, SMS is skipped and the daemon runs normally. To verify delivery, run `npx gsd-unsupervised test-sms` from the project root (after `npm run build`).
248
248
 
249
249
  **State and heartbeat:** When started via `./run` or `gsd-unsupervised run --state .gsd/state.json`, the daemon writes to `.gsd/state.json` (PID, current goal, progress, `lastHeartbeat`). You can use `lastHeartbeat` in an external cron or script to send a periodic "alive" SMS (e.g. every 30 min) or alert if the heartbeat is stale (e.g. >10 min).
250
250
 
package/dist/cli.js CHANGED
@@ -9,6 +9,7 @@ import { loadConfig } from './config.js';
9
9
  import { loadGoals, getPendingGoals } from './goals.js';
10
10
  import { runDaemon, registerShutdownHandlers } from './daemon.js';
11
11
  import { validateCursorApiKey } from './cursor-agent.js';
12
+ import { sendSms } from './notifier.js';
12
13
  import { applyWslBootstrap } from './bootstrap/wsl-bootstrap.js';
13
14
  import { readGsdStateFromPath } from './gsd-state.js';
14
15
  const __filename = fileURLToPath(import.meta.url);
@@ -166,6 +167,26 @@ program
166
167
  const { runInit } = await import('./init-wizard.js');
167
168
  await runInit();
168
169
  });
170
+ /** Send a test SMS to verify Twilio config (TWILIO_* in .env or env). */
171
+ program
172
+ .command('test-sms')
173
+ .description('Send a test SMS to verify Twilio credentials and delivery')
174
+ .option('--message <text>', 'Custom message (default: GSD Autopilot test message)', undefined)
175
+ .action(async (opts) => {
176
+ const message = opts.message?.trim() ||
177
+ 'GSD Autopilot test SMS. If you received this, notifications are working.';
178
+ try {
179
+ await sendSms(message);
180
+ console.log('Test SMS sent successfully. Check your phone (TWILIO_TO).');
181
+ }
182
+ catch (err) {
183
+ const msg = err instanceof Error ? err.message : String(err);
184
+ console.error('Failed to send test SMS:', msg);
185
+ console.error('');
186
+ console.error('Check: TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_FROM, TWILIO_TO in .env or environment.');
187
+ process.exit(1);
188
+ }
189
+ });
169
190
  export function main() {
170
191
  program.parse();
171
192
  }
package/dist/config.d.ts CHANGED
@@ -6,14 +6,18 @@ export declare const AutopilotConfigSchema: z.ZodObject<{
6
6
  maxConcurrent: z.ZodDefault<z.ZodNumber>;
7
7
  /**
8
8
  * Upper bound on allowed CPU usage before new agent work waits.
9
- * Expressed as a fraction of total CPU capacity (1.0 = 100% of all cores).
9
+ * Expressed as a fraction of total CPU capacity (1.0 = 100%). 0.8 = 80% recommended for parallel.
10
10
  */
11
11
  maxCpuFraction: z.ZodDefault<z.ZodNumber>;
12
12
  /**
13
13
  * Upper bound on allowed memory usage before new agent work waits.
14
- * Expressed as a fraction of total system memory (1.0 = 100% of RAM).
14
+ * Expressed as a fraction of total system memory (1.0 = 100%). 0.8 = 80% recommended for parallel.
15
15
  */
16
16
  maxMemoryFraction: z.ZodDefault<z.ZodNumber>;
17
+ /**
18
+ * Upper bound on GPU utilization (0–1) when nvidia-smi is available. 0.8 = 80% recommended for parallel.
19
+ */
20
+ maxGpuFraction: z.ZodOptional<z.ZodNumber>;
17
21
  verbose: z.ZodDefault<z.ZodBoolean>;
18
22
  logLevel: z.ZodDefault<z.ZodEnum<["debug", "info", "warn", "error"]>>;
19
23
  workspaceRoot: z.ZodDefault<z.ZodString>;
@@ -50,6 +54,7 @@ export declare const AutopilotConfigSchema: z.ZodObject<{
50
54
  requireCleanGitBeforePlan: boolean;
51
55
  autoCheckpoint: boolean;
52
56
  ngrok: boolean;
57
+ maxGpuFraction?: number | undefined;
53
58
  statusServerPort?: number | undefined;
54
59
  statePath?: string | undefined;
55
60
  }, {
@@ -58,6 +63,7 @@ export declare const AutopilotConfigSchema: z.ZodObject<{
58
63
  maxConcurrent?: number | undefined;
59
64
  maxCpuFraction?: number | undefined;
60
65
  maxMemoryFraction?: number | undefined;
66
+ maxGpuFraction?: number | undefined;
61
67
  verbose?: boolean | undefined;
62
68
  logLevel?: "error" | "warn" | "info" | "debug" | undefined;
63
69
  workspaceRoot?: string | undefined;
package/dist/config.js CHANGED
@@ -9,14 +9,18 @@ export const AutopilotConfigSchema = z.object({
9
9
  maxConcurrent: z.number().int().min(1).max(10).default(3),
10
10
  /**
11
11
  * Upper bound on allowed CPU usage before new agent work waits.
12
- * Expressed as a fraction of total CPU capacity (1.0 = 100% of all cores).
12
+ * Expressed as a fraction of total CPU capacity (1.0 = 100%). 0.8 = 80% recommended for parallel.
13
13
  */
14
- maxCpuFraction: z.number().min(0.1).max(1).default(0.75),
14
+ maxCpuFraction: z.number().min(0.1).max(1).default(0.8),
15
15
  /**
16
16
  * Upper bound on allowed memory usage before new agent work waits.
17
- * Expressed as a fraction of total system memory (1.0 = 100% of RAM).
17
+ * Expressed as a fraction of total system memory (1.0 = 100%). 0.8 = 80% recommended for parallel.
18
18
  */
19
- maxMemoryFraction: z.number().min(0.5).max(1).default(0.9),
19
+ maxMemoryFraction: z.number().min(0.5).max(1).default(0.8),
20
+ /**
21
+ * Upper bound on GPU utilization (0–1) when nvidia-smi is available. 0.8 = 80% recommended for parallel.
22
+ */
23
+ maxGpuFraction: z.number().min(0.1).max(1).optional(),
20
24
  verbose: z.boolean().default(false),
21
25
  logLevel: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
22
26
  workspaceRoot: z.string().default(process.cwd()),
@@ -87,6 +91,9 @@ function readPlanningOverrides(workspaceRoot) {
87
91
  if (typeof parsed.maxMemoryFraction === 'number') {
88
92
  overrides.maxMemoryFraction = parsed.maxMemoryFraction;
89
93
  }
94
+ if (typeof parsed.maxGpuFraction === 'number') {
95
+ overrides.maxGpuFraction = parsed.maxGpuFraction;
96
+ }
90
97
  return overrides;
91
98
  }
92
99
  catch {
package/dist/daemon.js CHANGED
@@ -169,6 +169,12 @@ export async function runDaemon(config, logger) {
169
169
  status: 'crashed',
170
170
  error: `Heartbeat timeout (>${heartbeatTimeoutMs / 1000}s)`,
171
171
  });
172
+ try {
173
+ await sendSms(`GSD goal crashed (heartbeat timeout).\nGoal: ${crashed.goalTitle}`);
174
+ }
175
+ catch (e) {
176
+ logger.debug({ err: e }, 'SMS (goal crashed) skipped or failed');
177
+ }
172
178
  }
173
179
  }
174
180
  catch {
@@ -178,6 +184,12 @@ export async function runDaemon(config, logger) {
178
184
  status: 'crashed',
179
185
  error: 'Heartbeat timeout (missing)',
180
186
  });
187
+ try {
188
+ await sendSms(`GSD goal crashed (heartbeat timeout).\nGoal: ${crashed.goalTitle}`);
189
+ }
190
+ catch (e) {
191
+ logger.debug({ err: e }, 'SMS (goal crashed) skipped or failed');
192
+ }
181
193
  }
182
194
  }
183
195
  resumeFrom = await computeResumePointer({
@@ -327,6 +339,12 @@ export async function runDaemon(config, logger) {
327
339
  break;
328
340
  }
329
341
  logger.info({ goal: goal.title }, `Processing goal: ${goal.title}`);
342
+ try {
343
+ await sendSms(`GSD goal started.\nGoal: ${goal.title}`);
344
+ }
345
+ catch (e) {
346
+ logger.debug({ err: e }, 'SMS (goal started) skipped or failed');
347
+ }
330
348
  const isFirst = completedCount === 1 && resumeFrom !== null;
331
349
  await workspaceMutex.run(() => runOneGoal(goal, isFirst ? resumeFrom : null));
332
350
  running.delete(goal.title);
@@ -102,6 +102,7 @@ export async function orchestrateGoal(options) {
102
102
  await waitForHeadroom({
103
103
  maxCpuFraction: config.maxCpuFraction,
104
104
  maxMemoryFraction: config.maxMemoryFraction,
105
+ maxGpuFraction: config.maxGpuFraction,
105
106
  logger,
106
107
  });
107
108
  }
@@ -16,18 +16,28 @@ export interface LoadInfo {
16
16
  memoryFraction: number;
17
17
  totalMemBytes: number;
18
18
  freeMemBytes: number;
19
+ /**
20
+ * Best-effort GPU utilization fraction (0–1). Only set when nvidia-smi is
21
+ * available and returns utilization; otherwise undefined.
22
+ */
23
+ gpuFraction?: number;
19
24
  }
20
25
  export interface WaitForHeadroomOptions {
21
26
  /**
22
27
  * Maximum allowed CPU fraction before new agent work is allowed to start.
23
- * 1.0 means 100% of all logical CPUs; 0.75 (default) means ~75% of total.
28
+ * 1.0 means 100% of all logical CPUs; 0.8 recommended for parallel work.
24
29
  */
25
30
  maxCpuFraction: number;
26
31
  /**
27
32
  * Maximum allowed memory fraction before new agent work is allowed to start.
28
- * 1.0 means 100% of total RAM; 0.9 (default) means ~90%.
33
+ * 1.0 means 100% of total RAM; 0.8 recommended for parallel work.
29
34
  */
30
35
  maxMemoryFraction?: number;
36
+ /**
37
+ * Maximum allowed GPU utilization fraction (0–1). Only checked when
38
+ * nvidia-smi is available. 0.8 recommended for parallel work.
39
+ */
40
+ maxGpuFraction?: number;
31
41
  /**
32
42
  * Minimum delay between load checks while waiting for headroom.
33
43
  * Defaults to 2s to avoid busy-waiting.
@@ -47,8 +57,24 @@ export interface WaitForHeadroomOptions {
47
57
  warn: (obj: unknown, msg?: string) => void;
48
58
  };
49
59
  }
50
- export declare function currentLoadInfo(maxCpuFraction?: number, maxMemoryFraction?: number): LoadInfo & {
60
+ /**
61
+ * Best-effort GPU utilization fraction (0–1) via nvidia-smi. Returns undefined
62
+ * if nvidia-smi is not available or parsing fails.
63
+ */
64
+ export declare function getGpuFraction(): Promise<number | undefined>;
65
+ export declare function currentLoadInfo(maxCpuFraction?: number, maxMemoryFraction?: number, gpuFraction?: number): LoadInfo & {
51
66
  maxCpuFraction?: number;
52
67
  maxMemoryFraction?: number;
68
+ maxGpuFraction?: number;
53
69
  };
70
+ /** Like currentLoadInfo but async; fetches GPU utilization when maxGpuFraction is set. */
71
+ export declare function currentLoadInfoAsync(options: {
72
+ maxCpuFraction?: number;
73
+ maxMemoryFraction?: number;
74
+ maxGpuFraction?: number;
75
+ }): Promise<LoadInfo & {
76
+ maxCpuFraction?: number;
77
+ maxMemoryFraction?: number;
78
+ maxGpuFraction?: number;
79
+ }>;
54
80
  export declare function waitForHeadroom(options: WaitForHeadroomOptions): Promise<void>;
@@ -1,5 +1,28 @@
1
1
  import os from 'node:os';
2
- export function currentLoadInfo(maxCpuFraction, maxMemoryFraction) {
2
+ import { execFile } from 'node:child_process';
3
+ import { promisify } from 'node:util';
4
+ const execFileP = promisify(execFile);
5
+ /**
6
+ * Best-effort GPU utilization fraction (0–1) via nvidia-smi. Returns undefined
7
+ * if nvidia-smi is not available or parsing fails.
8
+ */
9
+ export async function getGpuFraction() {
10
+ try {
11
+ const { stdout } = await execFileP('nvidia-smi', ['--query-gpu=utilization.gpu', '--format=csv,noheader,nounits'], {
12
+ timeout: 5000,
13
+ encoding: 'utf-8',
14
+ });
15
+ const line = stdout.trim().split('\n')[0];
16
+ const pct = line ? parseInt(line.trim(), 10) : NaN;
17
+ if (Number.isFinite(pct) && pct >= 0 && pct <= 100)
18
+ return pct / 100;
19
+ }
20
+ catch {
21
+ // nvidia-smi not installed or failed
22
+ }
23
+ return undefined;
24
+ }
25
+ export function currentLoadInfo(maxCpuFraction, maxMemoryFraction, gpuFraction) {
3
26
  const [load1, load5, load15] = os.loadavg();
4
27
  const cpuCount = Math.max(os.cpus()?.length ?? 1, 1);
5
28
  const cpuFraction = cpuCount > 0 ? load1 / cpuCount : 0;
@@ -15,34 +38,48 @@ export function currentLoadInfo(maxCpuFraction, maxMemoryFraction) {
15
38
  memoryFraction,
16
39
  totalMemBytes,
17
40
  freeMemBytes,
41
+ gpuFraction,
18
42
  maxCpuFraction,
19
43
  maxMemoryFraction,
20
44
  };
21
45
  }
46
+ /** Like currentLoadInfo but async; fetches GPU utilization when maxGpuFraction is set. */
47
+ export async function currentLoadInfoAsync(options) {
48
+ const gpuFraction = options.maxGpuFraction != null ? await getGpuFraction() : undefined;
49
+ const info = currentLoadInfo(options.maxCpuFraction, options.maxMemoryFraction, gpuFraction);
50
+ return { ...info, maxGpuFraction: options.maxGpuFraction };
51
+ }
22
52
  export async function waitForHeadroom(options) {
23
- const { maxCpuFraction, maxMemoryFraction, pollIntervalMs = 2000, maxWaitMs = 120_000, logger, } = options;
53
+ const { maxCpuFraction, maxMemoryFraction, maxGpuFraction, pollIntervalMs = 2000, maxWaitMs = 120_000, logger, } = options;
24
54
  // Treat thresholds >= 1 as "no gating" for that resource to keep tests
25
55
  // and opt-out configurations fast.
26
56
  const validCpu = maxCpuFraction !== undefined && maxCpuFraction > 0 && maxCpuFraction < 1;
27
57
  const validMem = maxMemoryFraction !== undefined && maxMemoryFraction > 0 && maxMemoryFraction < 1;
28
- if (!validCpu && !validMem) {
29
- // Misconfiguration do not block orchestration, just log once and return.
30
- logger?.warn({ maxCpuFraction, maxMemoryFraction }, 'resource-governor: invalid thresholds, skipping headroom check');
58
+ const validGpu = maxGpuFraction !== undefined && maxGpuFraction > 0 && maxGpuFraction < 1;
59
+ if (!validCpu && !validMem && !validGpu) {
60
+ logger?.warn({ maxCpuFraction, maxMemoryFraction, maxGpuFraction }, 'resource-governor: invalid thresholds, skipping headroom check');
31
61
  return;
32
62
  }
33
63
  const start = Date.now();
34
- // First, allow a cheap fast-path so we don't sleep when there's plenty of headroom.
35
- let info = currentLoadInfo(maxCpuFraction, maxMemoryFraction);
64
+ async function getInfo() {
65
+ const gpuFraction = validGpu ? await getGpuFraction() : undefined;
66
+ const info = currentLoadInfo(maxCpuFraction, maxMemoryFraction, gpuFraction);
67
+ return { ...info, maxGpuFraction };
68
+ }
69
+ let info = await getInfo();
36
70
  const withinCpu = !validCpu || info.cpuFraction <= maxCpuFraction;
37
71
  const withinMem = !validMem || info.memoryFraction <= maxMemoryFraction;
38
- if (withinCpu && withinMem) {
39
- logger?.debug({ load: info }, 'resource-governor: sufficient CPU headroom, proceeding immediately');
72
+ const withinGpu = !validGpu ||
73
+ info.gpuFraction == null ||
74
+ info.gpuFraction <= maxGpuFraction;
75
+ if (withinCpu && withinMem && withinGpu) {
76
+ logger?.debug({ load: info }, 'resource-governor: sufficient headroom, proceeding immediately');
40
77
  return;
41
78
  }
42
79
  logger?.warn({ load: info }, 'resource-governor: high system load detected, waiting for headroom');
43
- // Slow-path: periodically poll until below threshold or timeout expires.
44
80
  while ((!validCpu || info.cpuFraction > maxCpuFraction) ||
45
- (!validMem || info.memoryFraction > maxMemoryFraction)) {
81
+ (!validMem || info.memoryFraction > maxMemoryFraction) ||
82
+ (validGpu && info.gpuFraction != null && info.gpuFraction > maxGpuFraction)) {
46
83
  const elapsed = Date.now() - start;
47
84
  if (elapsed >= maxWaitMs) {
48
85
  logger?.warn({ load: info, elapsedMs: elapsed, maxWaitMs }, 'resource-governor: max wait exceeded, proceeding despite high load');
@@ -51,7 +88,7 @@ export async function waitForHeadroom(options) {
51
88
  const remainingMs = maxWaitMs - elapsed;
52
89
  const delayMs = Math.min(pollIntervalMs, remainingMs);
53
90
  await new Promise((resolve) => setTimeout(resolve, delayMs));
54
- info = currentLoadInfo(maxCpuFraction, maxMemoryFraction);
91
+ info = await getInfo();
55
92
  }
56
- logger?.debug({ load: info, waitedMs: Date.now() - start }, 'resource-governor: CPU headroom restored, resuming work');
93
+ logger?.debug({ load: info, waitedMs: Date.now() - start }, 'resource-governor: headroom restored, resuming work');
57
94
  }
@@ -72,6 +72,7 @@ export interface DashboardStatusPayload {
72
72
  systemLoad?: import('./resource-governor.js').LoadInfo & {
73
73
  maxCpuFraction?: number;
74
74
  maxMemoryFraction?: number;
75
+ maxGpuFraction?: number;
75
76
  };
76
77
  }
77
78
  /** Optional webhook: add goals/todos via API or Twilio inbound. */
@@ -12,7 +12,7 @@ function escapeTwiML(s) {
12
12
  import { readStateMd } from './state-parser.js';
13
13
  import { readSessionLog } from './session-log.js';
14
14
  import { getRecentCommits } from './git.js';
15
- import { currentLoadInfo } from './resource-governor.js';
15
+ import { currentLoadInfo, currentLoadInfoAsync } from './resource-governor.js';
16
16
  const DEFAULT_PLANNING_CONFIG = {
17
17
  mode: 'interactive',
18
18
  depth: 'standard',
@@ -341,11 +341,29 @@ export function createStatusServer(port, getStatus, options) {
341
341
  /** Dashboard API: rich payload. */
342
342
  app.get('/api/status', async (_req, res) => {
343
343
  const legacy = getStatus();
344
+ let systemLoad = currentLoadInfo();
345
+ if (options?.planningConfigPath) {
346
+ try {
347
+ const raw = await readFile(options.planningConfigPath, 'utf-8');
348
+ const planning = JSON.parse(raw);
349
+ const maxCpu = typeof planning.maxCpuFraction === 'number' ? planning.maxCpuFraction : 0.8;
350
+ const maxMem = typeof planning.maxMemoryFraction === 'number' ? planning.maxMemoryFraction : 0.8;
351
+ const maxGpu = typeof planning.maxGpuFraction === 'number' ? planning.maxGpuFraction : undefined;
352
+ systemLoad = await currentLoadInfoAsync({
353
+ maxCpuFraction: maxCpu,
354
+ maxMemoryFraction: maxMem,
355
+ maxGpuFraction: maxGpu,
356
+ });
357
+ }
358
+ catch {
359
+ // keep sync load info
360
+ }
361
+ }
344
362
  const payload = {
345
363
  ...legacy,
346
364
  tokens: {},
347
365
  cost: {},
348
- systemLoad: currentLoadInfo(),
366
+ systemLoad,
349
367
  };
350
368
  if (options) {
351
369
  const [stateSnapshot, sessionLogEntries, gitFeed] = await Promise.all([
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gsd-unsupervised",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "Autonomous orchestrator for Cursor agent + GSD framework",
5
5
  "type": "module",
6
6
  "main": "dist/cli.js",
@@ -28,7 +28,8 @@
28
28
  "start": "node dist/cli.js",
29
29
  "dev": "tsc --watch",
30
30
  "test": "vitest run --reporter verbose",
31
- "test:integration": "vitest run tests/"
31
+ "test:integration": "vitest run tests/",
32
+ "test:sms": "node dist/cli.js test-sms"
32
33
  },
33
34
  "engines": {
34
35
  "node": ">=18"