@bridge_gpt/mcp-server 0.2.0 → 0.2.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.
@@ -16,11 +16,13 @@
16
16
  */
17
17
  import { execFile } from "node:child_process";
18
18
  import { promises as fs } from "node:fs";
19
- import { randomUUID } from "node:crypto";
19
+ import { randomUUID, createHash } from "node:crypto";
20
20
  import { pathApiForPlatform, selectSchedulerBackend, getSchedulerBackendByName, getSchedulerBackendsForPlatform, unsupportedSchedulerPlatformMessage, } from "./scheduler-backends/index.js";
21
- import { ensureScheduleDirectories, getSchedulePaths, writeScheduleMetadata, readScheduleMetadata, listScheduleMetadata, deleteScheduleMetadata, } from "./schedule-store.js";
21
+ import { ensureScheduleDirectories, getSchedulePaths, writeScheduleMetadata, readScheduleMetadata, listScheduleMetadata, deleteScheduleMetadata, appendScheduleRunEvent, } from "./schedule-store.js";
22
22
  import { getAgentLauncher, formatValidAgentLauncherNames } from "./agent-launchers/index.js";
23
23
  import { resolveCommandOnPath } from "./agent-launchers/claude.js";
24
+ import { discoverCommandCatalog, resolveSchedulableCommand, validateCommandArgv, } from "./command-catalog.js";
25
+ import { buildTargetCommandLine } from "./scheduled-prompt.js";
24
26
  /**
25
27
  * Default deps backed by real subprocesses / process state. Subprocess execution
26
28
  * uses `execFile` (list-based, never `shell: true`) and supports
@@ -55,12 +57,30 @@ export function createDefaultScheduleRunDeps() {
55
57
  execPath: process.execPath,
56
58
  homeDir: process.env.HOME ?? process.env.USERPROFILE ?? "",
57
59
  now: () => Date.now(),
60
+ // The packaged CLI entry is argv[1]; the trigger invocation re-enters it as
61
+ // `node <cliEntryPath> schedule-run _execute <id>`.
62
+ cliEntryPath: process.argv[1],
63
+ // Best-effort: a Bridge credential is considered resolvable when BAPI_API_KEY
64
+ // is present in the environment. Never reads or returns the value itself.
65
+ bridgeCredentialResolved: () => Boolean(process.env.BAPI_API_KEY),
58
66
  };
59
67
  }
60
68
  /** Return true only for a zero exit code. */
61
69
  export function commandSucceeded(result) {
62
70
  return result.exitCode === 0;
63
71
  }
72
+ /**
73
+ * Detect a CLI entry path that lives inside a transient npx cache directory
74
+ * (`~/.npm/_npx/<hash>/…` on POSIX, `…\npm-cache\_npx\<hash>\…` on Windows).
75
+ * Such an entry can be garbage-collected before a future schedule fires, which
76
+ * would make the trigger shim `node <gone-path>` fail with ENOENT. Matched on the
77
+ * `_npx` path segment so it is independent of the surrounding cache root.
78
+ */
79
+ export function isTransientNpxEntryPath(entryPath) {
80
+ if (!entryPath)
81
+ return false;
82
+ return /[\\/]_npx[\\/]/.test(entryPath);
83
+ }
64
84
  const VALID_BACKEND_NAMES = [
65
85
  "launchd",
66
86
  "task-scheduler",
@@ -78,23 +98,28 @@ export function getScheduleRunUsage() {
78
98
  "Usage:",
79
99
  " npx -y @bridge_gpt/mcp-server schedule-run <create|list|cancel|doctor> [flags]",
80
100
  "",
81
- "Schedules a local, one-shot Claude full-automation run at a chosen time using",
82
- "an OS-native scheduler (launchd / Task Scheduler / systemd-user / at). Schedules",
83
- "are stored locally under ~/.bridge-gpt/schedules/ and are never sent to a server.",
101
+ "Schedules a local, one-shot run of ANY Bridge slash-command automation (from",
102
+ "the repo's .claude/commands/*.md catalog) at a chosen time using an OS-native",
103
+ "scheduler (launchd / Task Scheduler / systemd-user / at). Schedules are stored",
104
+ "locally under ~/.bridge-gpt/schedules/ and are never sent to a server.",
84
105
  "",
85
106
  "create:",
86
- ' schedule-run create --at "<datetime>" --idea-file <path> [flags]',
87
- " schedule-run create --in <duration> --idea-file <path> [flags]",
107
+ " schedule-run create --in 2h --command review-ticket -- BAPI-999",
108
+ ' schedule-run create --at "<datetime>" --command start-tickets -- BAPI-1 BAPI-2',
109
+ " legacy: schedule-run create --in 2h --idea-file <path>",
110
+ "",
111
+ "All tokens after a bare -- are the structured argv passed to the command.",
88
112
  "",
89
113
  "create flags:",
90
114
  ' --at "<datetime>" ISO-8601 time to run (mutually exclusive with --in)',
91
115
  " --in <duration> Run after a duration, e.g. 30m, 4h, 1d (mutually exclusive with --at)",
92
- " --idea-file <path> REQUIRED absolute or relative path to the idea file",
93
- " --agent claude Agent to launch (only 'claude' is supported in v1)",
94
- " --repo-path <path> Working directory for the run (default: current directory)",
95
- " --auto Forward --auto to /full-automation (default)",
96
- " --no-auto Do not forward --auto",
97
- " --dry-run Print the generated unit + invocation without creating anything",
116
+ " --command <name> Discovered .claude/commands command to schedule",
117
+ " --idea-file <path> Legacy: schedule /full-automation with this idea file",
118
+ " --agent <name> Agent to launch (claude [default] or cursor-agent)",
119
+ " --repo-path <path> Working directory + command catalog root (default: cwd)",
120
+ " --auto Run hands-off; gate proceeds even when firing late",
121
+ " --no-auto Late runs halt without executing (headless-safe default)",
122
+ " --dry-run Print the generated unit + prompt + invocation without creating anything",
98
123
  " --id <id> Override the auto-generated schedule id (mainly for tests)",
99
124
  "",
100
125
  "list flags:",
@@ -132,13 +157,24 @@ function takeValue(argv, index, arg, flag) {
132
157
  function matchesFlag(arg, flag) {
133
158
  return arg === flag || arg.startsWith(`${flag}=`);
134
159
  }
135
- /** Parse `create` args. */
160
+ /**
161
+ * Parse `create` args. Everything after the FIRST bare `--` is the structured
162
+ * command argv (passed through verbatim, never re-tokenized); scheduler flags are
163
+ * parsed only before that delimiter. `--idea-file` without `--command` implies
164
+ * the legacy `full-automation` command.
165
+ */
136
166
  export function parseScheduleCreateArgs(argv) {
137
- if (argv.includes("-h") || argv.includes("--help")) {
167
+ // Split on the first bare "--". Help is honored only from the scheduler-flag
168
+ // side; tokens after "--" belong to the delegated command verbatim.
169
+ const delimIndex = argv.indexOf("--");
170
+ const schedulerArgs = delimIndex === -1 ? argv : argv.slice(0, delimIndex);
171
+ const commandArgs = delimIndex === -1 ? [] : argv.slice(delimIndex + 1);
172
+ if (schedulerArgs.includes("-h") || schedulerArgs.includes("--help")) {
138
173
  return { status: "help", usage: getScheduleRunUsage() };
139
174
  }
140
175
  let at;
141
176
  let inDuration;
177
+ let commandName;
142
178
  let ideaFile;
143
179
  let agent = "claude";
144
180
  let repoPath;
@@ -146,10 +182,10 @@ export function parseScheduleCreateArgs(argv) {
146
182
  let sawAutoApprove = false;
147
183
  let sawNoAutoApprove = false;
148
184
  let dryRun = false;
149
- for (let i = 0; i < argv.length; i++) {
150
- const arg = argv[i];
185
+ for (let i = 0; i < schedulerArgs.length; i++) {
186
+ const arg = schedulerArgs[i];
151
187
  if (matchesFlag(arg, "--at")) {
152
- const r = takeValue(argv, i, arg, "--at");
188
+ const r = takeValue(schedulerArgs, i, arg, "--at");
153
189
  if ("error" in r)
154
190
  return { status: "error", message: r.error };
155
191
  at = r.value;
@@ -157,15 +193,23 @@ export function parseScheduleCreateArgs(argv) {
157
193
  continue;
158
194
  }
159
195
  if (matchesFlag(arg, "--in")) {
160
- const r = takeValue(argv, i, arg, "--in");
196
+ const r = takeValue(schedulerArgs, i, arg, "--in");
161
197
  if ("error" in r)
162
198
  return { status: "error", message: r.error };
163
199
  inDuration = r.value;
164
200
  i = r.nextIndex;
165
201
  continue;
166
202
  }
203
+ if (matchesFlag(arg, "--command")) {
204
+ const r = takeValue(schedulerArgs, i, arg, "--command");
205
+ if ("error" in r)
206
+ return { status: "error", message: r.error };
207
+ commandName = r.value;
208
+ i = r.nextIndex;
209
+ continue;
210
+ }
167
211
  if (matchesFlag(arg, "--idea-file")) {
168
- const r = takeValue(argv, i, arg, "--idea-file");
212
+ const r = takeValue(schedulerArgs, i, arg, "--idea-file");
169
213
  if ("error" in r)
170
214
  return { status: "error", message: r.error };
171
215
  ideaFile = r.value;
@@ -173,13 +217,13 @@ export function parseScheduleCreateArgs(argv) {
173
217
  continue;
174
218
  }
175
219
  if (matchesFlag(arg, "--agent")) {
176
- const r = takeValue(argv, i, arg, "--agent");
220
+ const r = takeValue(schedulerArgs, i, arg, "--agent");
177
221
  if ("error" in r)
178
222
  return { status: "error", message: r.error };
179
223
  if (!getAgentLauncher(r.value)) {
180
224
  return {
181
225
  status: "error",
182
- message: `Invalid --agent value: '${r.value}'. Only ${formatValidAgentLauncherNames()} is supported in v1.`,
226
+ message: `Invalid --agent value: '${r.value}'. Valid: ${formatValidAgentLauncherNames()}.`,
183
227
  };
184
228
  }
185
229
  agent = r.value;
@@ -187,7 +231,7 @@ export function parseScheduleCreateArgs(argv) {
187
231
  continue;
188
232
  }
189
233
  if (matchesFlag(arg, "--repo-path")) {
190
- const r = takeValue(argv, i, arg, "--repo-path");
234
+ const r = takeValue(schedulerArgs, i, arg, "--repo-path");
191
235
  if ("error" in r)
192
236
  return { status: "error", message: r.error };
193
237
  repoPath = r.value;
@@ -195,7 +239,7 @@ export function parseScheduleCreateArgs(argv) {
195
239
  continue;
196
240
  }
197
241
  if (matchesFlag(arg, "--id")) {
198
- const r = takeValue(argv, i, arg, "--id");
242
+ const r = takeValue(schedulerArgs, i, arg, "--id");
199
243
  if ("error" in r)
200
244
  return { status: "error", message: r.error };
201
245
  id = r.value;
@@ -219,7 +263,8 @@ export function parseScheduleCreateArgs(argv) {
219
263
  }
220
264
  return {
221
265
  status: "error",
222
- message: `Unexpected positional argument: '${arg}'. create takes only flags.`,
266
+ message: `Unexpected positional argument: '${arg}'. create takes flags before '--'; ` +
267
+ "put command arguments after '--'.",
223
268
  };
224
269
  }
225
270
  if (Boolean(at) === Boolean(inDuration)) {
@@ -228,9 +273,6 @@ export function parseScheduleCreateArgs(argv) {
228
273
  message: "create requires exactly one of --at or --in.",
229
274
  };
230
275
  }
231
- if (!ideaFile) {
232
- return { status: "error", message: "create requires --idea-file <path>." };
233
- }
234
276
  if (sawAutoApprove && sawNoAutoApprove) {
235
277
  return {
236
278
  status: "error",
@@ -244,21 +286,60 @@ export function parseScheduleCreateArgs(argv) {
244
286
  "(start alphanumeric; letters, digits, '_' and '-'; max 64 chars).",
245
287
  };
246
288
  }
289
+ // Legacy compatibility: --idea-file without --command implies full-automation.
290
+ // --idea-file with any OTHER command is rejected (it only applies to that path).
291
+ if (ideaFile !== undefined) {
292
+ if (commandName === undefined) {
293
+ commandName = "full-automation";
294
+ }
295
+ else if (commandName !== "full-automation") {
296
+ return {
297
+ status: "error",
298
+ message: `--idea-file is only valid for legacy full-automation scheduling, not --command '${commandName}'. ` +
299
+ "Pass command arguments after '--' instead.",
300
+ };
301
+ }
302
+ }
303
+ if (commandName === undefined) {
304
+ return {
305
+ status: "error",
306
+ message: "create requires --command <name> (or legacy --idea-file <path>).",
307
+ };
308
+ }
247
309
  return {
248
310
  status: "ok",
249
311
  subcommand: "create",
250
312
  options: {
251
313
  at,
252
314
  in: inDuration,
315
+ commandName,
316
+ commandArgs,
253
317
  ideaFile,
254
318
  agent,
255
319
  repoPath,
256
320
  autoApprove: !sawNoAutoApprove,
321
+ autoApproveExplicit: sawAutoApprove || sawNoAutoApprove,
257
322
  dryRun,
258
323
  id,
259
324
  },
260
325
  };
261
326
  }
327
+ /** Parse the hidden `_execute <id>` shim args (exactly one valid schedule id). */
328
+ export function parseScheduleExecuteArgs(argv) {
329
+ const positionals = argv.filter((a) => !a.startsWith("-"));
330
+ const flags = argv.filter((a) => a.startsWith("-"));
331
+ if (flags.length > 0) {
332
+ return { status: "error", message: `_execute takes no flags (got ${flags.join(", ")}).` };
333
+ }
334
+ if (positionals.length !== 1) {
335
+ return { status: "error", message: "_execute requires exactly one schedule id." };
336
+ }
337
+ const id = positionals[0];
338
+ if (!SCHEDULE_ID_PATTERN.test(id)) {
339
+ return { status: "error", message: `Invalid schedule id: '${id}'.` };
340
+ }
341
+ return { status: "ok", subcommand: "_execute", options: { id } };
342
+ }
262
343
  /** Parse `list` args. */
263
344
  export function parseScheduleListArgs(argv) {
264
345
  if (argv.includes("-h") || argv.includes("--help")) {
@@ -427,6 +508,9 @@ export function parseScheduleRunArgs(argv) {
427
508
  return parseScheduleCancelArgs(rest);
428
509
  case "doctor":
429
510
  return parseScheduleDoctorArgs(rest);
511
+ // Hidden internal shim the OS unit invokes; intentionally absent from usage.
512
+ case "_execute":
513
+ return parseScheduleExecuteArgs(rest);
430
514
  default:
431
515
  return {
432
516
  status: "error",
@@ -464,24 +548,42 @@ function resolveAbsolute(p, deps) {
464
548
  return pathApi.isAbsolute(p) ? pathApi.normalize(p) : pathApi.resolve(deps.cwd, p);
465
549
  }
466
550
  /**
467
- * Resolve and validate create inputs, baking the PATH-trap values. Validates
468
- * that the idea file exists and is a file and the repo path exists and is a
469
- * directory BEFORE attempting binary resolution. Captures `env_path` exactly
470
- * from the injected env (`PATH` then `Path`); resolves node/npx/claude against
471
- * that baked PATH; builds the locked invocation (never with `--cwd`).
551
+ * Collect read-only prerequisite/auth readiness for the selected create input.
552
+ * Returns presence/readiness booleans only it never reads or returns secret
553
+ * values (CURSOR_API_KEY / Bridge credential material).
554
+ */
555
+ export function collectScheduleRunPrereqStatus(input) {
556
+ return {
557
+ agentBinaryResolved: input.agentBinaryResolved,
558
+ repoPathExists: input.repoPathExists,
559
+ cursorApiKeyPresent: Boolean(input.deps.env.CURSOR_API_KEY),
560
+ bridgeCredentialResolved: input.deps.bridgeCredentialResolved?.() ?? Boolean(input.deps.env.BAPI_API_KEY),
561
+ };
562
+ }
563
+ /**
564
+ * Resolve and validate generic create inputs. Resolves the repo path first and
565
+ * validates it is a directory; discovers the `.claude/commands` catalog under it;
566
+ * resolves and validates the requested command (rejecting unknown /
567
+ * `schedulable: false` / malformed); validates structured argv against the
568
+ * command's schema; resolves the agent binary on the baked PATH; builds the agent
569
+ * invocation via the launcher and the trigger invocation for the `_execute` shim.
570
+ * The legacy `--idea-file` path is converted to structured `["--idea-file", abs]`
571
+ * args for `full-automation`. Installs nothing — orchestration owns side effects.
472
572
  */
473
573
  export async function resolveScheduleCreateInput(options, runAtIso, deps) {
474
- const ideaFile = resolveAbsolute(options.ideaFile, deps);
475
- const repoPath = resolveAbsolute(options.repoPath ?? deps.cwd, deps);
476
- try {
477
- const ideaStat = await fs.stat(ideaFile);
478
- if (!ideaStat.isFile()) {
479
- return { ok: false, error: `--idea-file is not a file: ${ideaFile}` };
480
- }
574
+ const launcher = getAgentLauncher(options.agent);
575
+ if (!launcher) {
576
+ return {
577
+ ok: false,
578
+ error: `Unsupported agent: '${options.agent}'. Valid: ${formatValidAgentLauncherNames()}.`,
579
+ };
481
580
  }
482
- catch {
483
- return { ok: false, error: `--idea-file does not exist: ${ideaFile}` };
581
+ const commandName = options.commandName;
582
+ if (!commandName) {
583
+ return { ok: false, error: "create requires a command (--command or legacy --idea-file)." };
484
584
  }
585
+ // Resolve + validate the repo path (command-catalog root) BEFORE discovery.
586
+ const repoPath = resolveAbsolute(options.repoPath ?? deps.cwd, deps);
485
587
  try {
486
588
  const repoStat = await fs.stat(repoPath);
487
589
  if (!repoStat.isDirectory()) {
@@ -491,30 +593,75 @@ export async function resolveScheduleCreateInput(options, runAtIso, deps) {
491
593
  catch {
492
594
  return { ok: false, error: `--repo-path does not exist: ${repoPath}` };
493
595
  }
596
+ // Build the structured command argv. Legacy --idea-file is validated and turned
597
+ // into ["--idea-file", <abs>] (full-automation only; enforced by the parser).
598
+ let commandArgs = options.commandArgs;
599
+ let legacyIdeaFile;
600
+ if (options.ideaFile !== undefined) {
601
+ const ideaFile = resolveAbsolute(options.ideaFile, deps);
602
+ try {
603
+ const ideaStat = await fs.stat(ideaFile);
604
+ if (!ideaStat.isFile())
605
+ return { ok: false, error: `--idea-file is not a file: ${ideaFile}` };
606
+ }
607
+ catch {
608
+ return { ok: false, error: `--idea-file does not exist: ${ideaFile}` };
609
+ }
610
+ legacyIdeaFile = ideaFile;
611
+ commandArgs = ["--idea-file", ideaFile];
612
+ }
613
+ // Discover the command catalog under the resolved repo and resolve the command.
614
+ const catalog = await discoverCommandCatalog(repoPath, deps.platform, deps.commandCatalogFsDeps);
615
+ const resolvedCommand = resolveSchedulableCommand(catalog, commandName);
616
+ if (!resolvedCommand.ok)
617
+ return { ok: false, error: resolvedCommand.error };
618
+ const command = resolvedCommand.command;
619
+ // Validate the structured argv against the command's schema (or passthrough).
620
+ const validation = validateCommandArgv(commandArgs, command.argumentSchema);
621
+ if (!validation.ok) {
622
+ return { ok: false, error: `Invalid arguments for '${commandName}': ${validation.error}` };
623
+ }
624
+ const normalizedArgv = validation.normalizedArgv;
494
625
  const envPath = deps.env.PATH ?? deps.env.Path ?? "";
495
626
  const nodePath = deps.execPath;
496
- const launcher = getAgentLauncher(options.agent);
497
- if (!launcher) {
498
- return {
499
- ok: false,
500
- error: `Unsupported agent: '${options.agent}'. Valid: ${formatValidAgentLauncherNames()}.`,
501
- };
502
- }
503
- const claudePath = await launcher.resolveBinary(envPath, deps);
504
- if (!claudePath) {
627
+ const agentPath = await launcher.resolveBinary(envPath, deps);
628
+ if (!agentPath) {
505
629
  return {
506
630
  ok: false,
507
631
  error: `Could not resolve the '${launcher.capability.command}' binary on the baked PATH.`,
508
632
  };
509
633
  }
510
- // npx is resolved best-effort (v1 invokes claude directly); empty when absent.
634
+ // npx is resolved best-effort; empty when absent.
511
635
  const npxPath = (await resolveCommandOnPath("npx", envPath, deps)) ?? "";
512
- const invocation = launcher.buildInvocation(claudePath, {
636
+ const id = options.id ?? randomUUID();
637
+ const agentInvocation = launcher.buildInvocation(agentPath, {
638
+ scheduleId: id,
513
639
  runAtIso,
514
- ideaFile,
640
+ commandName,
641
+ args: normalizedArgv,
515
642
  autoApprove: options.autoApprove,
643
+ repoPath,
644
+ commandFilePath: command.filePath,
645
+ commandBody: command.body,
646
+ schema: command.argumentSchema,
516
647
  });
517
- const id = options.id ?? randomUUID();
648
+ const cliEntryPath = deps.cliEntryPath ?? process.argv[1] ?? "";
649
+ const triggerInvocation = {
650
+ exe: nodePath,
651
+ args: [cliEntryPath, "schedule-run", "_execute", id],
652
+ };
653
+ const commandBodyHash = createHash("sha256").update(command.body, "utf-8").digest("hex");
654
+ const warnings = [];
655
+ if (command.interactive && !options.autoApproveExplicit) {
656
+ warnings.push(`Command '${commandName}' has an interactive step but --auto/--no-auto was not specified; ` +
657
+ "a headless scheduled run may hang waiting for confirmation. Pass --auto to run hands-off.");
658
+ }
659
+ if (isTransientNpxEntryPath(cliEntryPath)) {
660
+ warnings.push(`The scheduled run re-enters this CLI at '${cliEntryPath}', which is inside an npx cache ` +
661
+ "directory. npx caches can be pruned before a future schedule fires, which would make the " +
662
+ "run fail with ENOENT. Install the CLI persistently (e.g. `npm i -g @bridge_gpt/mcp-server`) " +
663
+ "and schedule with the installed binary so the trigger path stays stable.");
664
+ }
518
665
  const paths = getSchedulePaths(id, deps.homeDir, deps.platform);
519
666
  return {
520
667
  ok: true,
@@ -522,40 +669,71 @@ export async function resolveScheduleCreateInput(options, runAtIso, deps) {
522
669
  id,
523
670
  runAtIso,
524
671
  agent: options.agent,
525
- ideaFile,
672
+ command: commandName,
673
+ args: normalizedArgv,
674
+ commandFilePath: command.filePath,
675
+ commandBodyHash,
676
+ autoApprove: options.autoApprove,
526
677
  repoPath,
678
+ legacyIdeaFile,
527
679
  envPath,
528
680
  nodePath,
529
681
  npxPath,
530
- claudePath,
531
- invocation,
682
+ agentPath,
683
+ agentInvocation,
684
+ triggerInvocation,
685
+ renderedPrompt: agentInvocation.prompt,
686
+ schema: command.argumentSchema,
532
687
  paths: {
533
688
  schedulesDir: paths.schedulesDir,
534
689
  logsDir: paths.logsDir,
535
690
  stdoutPath: paths.stdoutPath,
536
691
  stderrPath: paths.stderrPath,
537
692
  },
693
+ warnings,
694
+ prereq: collectScheduleRunPrereqStatus({
695
+ agent: options.agent,
696
+ agentBinaryResolved: Boolean(agentPath),
697
+ repoPathExists: true,
698
+ deps,
699
+ }),
538
700
  },
539
701
  };
540
702
  }
541
- /** Build the local-only schedule metadata from resolved inputs + backend result. */
703
+ /**
704
+ * Build the local-only schedule metadata from resolved inputs + backend result.
705
+ * Writes the generalized fields plus legacy compatibility aliases (`invocation`,
706
+ * `claude_path`, `idea_file`) so old readers and full-automation records keep
707
+ * working. Run history is initialized with a `created` event.
708
+ */
542
709
  export function buildScheduleMetadata(resolved, createResult, createdAtIso) {
543
710
  return {
544
711
  id: resolved.id,
545
712
  run_at_iso: resolved.runAtIso,
713
+ scheduled_at: resolved.runAtIso,
546
714
  backend: createResult.backend,
547
715
  unit_path: createResult.unitPath,
548
716
  unit_paths: createResult.unitPaths,
549
717
  backend_job_id: createResult.backendJobId,
550
718
  agent: resolved.agent,
551
- idea_file: resolved.ideaFile,
719
+ agent_path: resolved.agentPath,
720
+ command: resolved.command,
721
+ args: resolved.args,
722
+ command_file: resolved.commandFilePath,
723
+ command_body_hash: resolved.commandBodyHash,
724
+ auto_approve: resolved.autoApprove,
552
725
  repo_path: resolved.repoPath,
553
726
  created_at: createdAtIso,
554
727
  env_path: resolved.envPath,
555
728
  node_path: resolved.nodePath,
556
729
  npx_path: resolved.npxPath,
557
- claude_path: resolved.claudePath,
558
- invocation: resolved.invocation,
730
+ // Legacy alias retained for full-automation/claude records.
731
+ claude_path: resolved.agent === "claude" ? resolved.agentPath : undefined,
732
+ idea_file: resolved.legacyIdeaFile,
733
+ invocation: resolved.agentInvocation,
734
+ agent_invocation: resolved.agentInvocation,
735
+ trigger_invocation: resolved.triggerInvocation,
736
+ run_history: [{ status: "created", at: createdAtIso }],
559
737
  logs: { stdout: resolved.paths.stdoutPath, stderr: resolved.paths.stderrPath },
560
738
  };
561
739
  }
@@ -621,13 +799,17 @@ export async function orchestrateScheduleCreate(options, deps) {
621
799
  deps,
622
800
  id: resolved.id,
623
801
  runAtIso: resolved.runAtIso,
624
- invocation: resolved.invocation,
802
+ triggerInvocation: resolved.triggerInvocation,
803
+ agentInvocation: resolved.agentInvocation,
804
+ command: resolved.command,
805
+ args: resolved.args,
806
+ agent: resolved.agent,
625
807
  repoPath: resolved.repoPath,
626
- ideaFile: resolved.ideaFile,
627
808
  envPath: resolved.envPath,
628
809
  nodePath: resolved.nodePath,
629
810
  npxPath: resolved.npxPath,
630
- claudePath: resolved.claudePath,
811
+ agentPath: resolved.agentPath,
812
+ legacyIdeaFile: resolved.legacyIdeaFile,
631
813
  paths: resolved.paths,
632
814
  dryRun: options.dryRun,
633
815
  };
@@ -637,7 +819,7 @@ export async function orchestrateScheduleCreate(options, deps) {
637
819
  return { ok: false, error: createResult.error ?? "Backend dry-run failed." };
638
820
  }
639
821
  const metadata = buildScheduleMetadata(resolved, createResult, createdAtIso);
640
- return { ok: true, dryRun: true, metadata, metadataPath, createResult };
822
+ return { ok: true, dryRun: true, metadata, metadataPath, createResult, resolved };
641
823
  }
642
824
  await ensureScheduleDirectories(deps.homeDir, deps.platform);
643
825
  const createResult = await backend.create(createInput);
@@ -645,6 +827,9 @@ export async function orchestrateScheduleCreate(options, deps) {
645
827
  return { ok: false, error: createResult.error ?? "Scheduler backend create failed." };
646
828
  }
647
829
  const metadata = buildScheduleMetadata(resolved, createResult, createdAtIso);
830
+ // Append the `scheduled` event only after the backend created a real OS job.
831
+ const scheduledAtIso = new Date(deps.now ? deps.now() : Date.now()).toISOString();
832
+ metadata.run_history = [...(metadata.run_history ?? []), { status: "scheduled", at: scheduledAtIso }];
648
833
  try {
649
834
  await writeScheduleMetadata(metadata, deps.homeDir, deps.platform);
650
835
  }
@@ -657,33 +842,73 @@ export async function orchestrateScheduleCreate(options, deps) {
657
842
  `(${error instanceof Error ? error.message : String(error)}); attempted to cancel the native unit.`,
658
843
  };
659
844
  }
660
- return { ok: true, dryRun: false, metadata, metadataPath, createResult };
845
+ return { ok: true, dryRun: false, metadata, metadataPath, createResult, resolved };
846
+ }
847
+ /** Format a prereq status block (presence/readiness only, never secret values). */
848
+ function formatPrereqStatus(agent, prereq) {
849
+ const lines = [
850
+ ` agent binary: ${prereq.agentBinaryResolved ? "resolved" : "MISSING"}`,
851
+ ` repo path: ${prereq.repoPathExists ? "exists" : "MISSING"}`,
852
+ ];
853
+ if (agent === "cursor-agent") {
854
+ lines.push(` CURSOR_API_KEY set: ${prereq.cursorApiKeyPresent ? "yes" : "no"}`);
855
+ }
856
+ else {
857
+ lines.push(` Bridge credential: ${prereq.bridgeCredentialResolved ? "resolved" : "not resolved"}`);
858
+ }
859
+ return lines;
661
860
  }
662
- /** Format a create result for display (dry-run prints artifacts verbatim). */
861
+ /**
862
+ * Format a create result for display. Real create and dry-run both show the
863
+ * delegated command, normalized args, schedule time, agent, repo path, metadata
864
+ * path, agent binary, trigger invocation, agent invocation argv, and the rendered
865
+ * target command line. Dry-run additionally prints the full concatenated prompt,
866
+ * the prerequisite/auth status (secret-free), and the backend artifacts.
867
+ */
663
868
  export function formatScheduleCreateResult(result) {
664
869
  if (!result.ok)
665
870
  return `Error: ${result.error}`;
666
871
  const m = result.metadata;
872
+ const r = result.resolved;
873
+ const targetCommandLine = buildTargetCommandLine({
874
+ commandName: r.command,
875
+ args: r.args,
876
+ scheduledAt: r.runAtIso,
877
+ autoApprove: r.autoApprove,
878
+ schema: r.schema,
879
+ });
667
880
  const lines = [];
668
881
  lines.push(result.dryRun ? "[dry-run] schedule-run create preview" : "Schedule created.");
669
- lines.push(` id: ${m.id}`);
670
- lines.push(` backend: ${m.backend}`);
671
- lines.push(` run at: ${m.run_at_iso}`);
672
- lines.push(` agent: ${m.agent}`);
673
- lines.push(` metadata: ${result.metadataPath}${result.dryRun ? " (not written in dry-run)" : ""}`);
674
- lines.push(` repo path: ${m.repo_path}`);
675
- lines.push(` idea file: ${m.idea_file}`);
676
- lines.push(` stdout log: ${m.logs.stdout}`);
677
- lines.push(` stderr log: ${m.logs.stderr}`);
678
- lines.push(` baked PATH: ${m.env_path}`);
679
- lines.push(` node: ${m.node_path}`);
680
- lines.push(` npx: ${m.npx_path}`);
681
- lines.push(` claude: ${m.claude_path}`);
682
- lines.push(` invocation: ${[m.invocation.exe, ...m.invocation.args].join(" ")}`);
882
+ lines.push(` id: ${m.id}`);
883
+ lines.push(` backend: ${m.backend}`);
884
+ lines.push(` command: ${r.command}`);
885
+ lines.push(` args: ${r.args.length > 0 ? r.args.join(" ") : "(none)"}`);
886
+ lines.push(` run at: ${m.run_at_iso}`);
887
+ lines.push(` agent: ${m.agent}`);
888
+ lines.push(` auto-approve: ${m.auto_approve ? "yes" : "no"}`);
889
+ lines.push(` metadata: ${result.metadataPath}${result.dryRun ? " (not written in dry-run)" : ""}`);
890
+ lines.push(` repo path: ${m.repo_path}`);
891
+ if (m.idea_file)
892
+ lines.push(` idea file: ${m.idea_file}`);
893
+ lines.push(` stdout log: ${m.logs.stdout}`);
894
+ lines.push(` stderr log: ${m.logs.stderr}`);
895
+ lines.push(` agent binary: ${r.agentPath}`);
896
+ lines.push(` target command: ${targetCommandLine}`);
897
+ lines.push(` trigger invocation: ${[r.triggerInvocation.exe, ...r.triggerInvocation.args].join(" ")}`);
898
+ lines.push(` agent invocation: ${[r.agentInvocation.exe, ...r.agentInvocation.args].join(" ")}`);
683
899
  if (m.unit_paths.length > 0) {
684
- lines.push(` unit paths: ${m.unit_paths.join(", ")}`);
900
+ lines.push(` unit paths: ${m.unit_paths.join(", ")}`);
901
+ }
902
+ for (const warning of r.warnings) {
903
+ lines.push(` warning: ${warning}`);
685
904
  }
686
905
  if (result.dryRun) {
906
+ lines.push("");
907
+ lines.push("prerequisites (secret-free):");
908
+ lines.push(...formatPrereqStatus(r.agent, r.prereq));
909
+ lines.push("");
910
+ lines.push("----- rendered prompt -----");
911
+ lines.push(r.renderedPrompt);
687
912
  for (const artifact of result.createResult.artifacts) {
688
913
  lines.push("");
689
914
  lines.push(`----- ${artifact.kind}: ${artifact.path} -----`);
@@ -745,6 +970,19 @@ export async function orchestrateScheduleList(options, deps) {
745
970
  entries.sort((a, b) => a.metadata.id.localeCompare(b.metadata.id));
746
971
  return { entries, warnings };
747
972
  }
973
+ /** Derive the displayed command (legacy rows show `full-automation`). */
974
+ export function scheduleCommandLabel(m) {
975
+ if (m.command)
976
+ return m.command;
977
+ if (m.idea_file)
978
+ return "full-automation";
979
+ return "(unknown)";
980
+ }
981
+ /** Latest run-history status, or empty string when no history is recorded. */
982
+ export function latestRunStatus(m) {
983
+ const h = m.run_history;
984
+ return h && h.length > 0 ? h[h.length - 1].status : "";
985
+ }
748
986
  /** Format a list report as a table (or JSON when requested). */
749
987
  export function formatScheduleListResult(report, json) {
750
988
  if (json) {
@@ -753,9 +991,15 @@ export function formatScheduleListResult(report, json) {
753
991
  return JSON.stringify({
754
992
  entries: report.entries.map((e) => ({
755
993
  id: e.metadata.id,
994
+ command: scheduleCommandLabel(e.metadata),
995
+ args: e.metadata.args ?? [],
996
+ scheduled_at: e.metadata.scheduled_at ?? e.metadata.run_at_iso,
756
997
  run_at: e.metadata.run_at_iso,
757
998
  backend: e.metadata.backend,
758
999
  agent: e.metadata.agent,
1000
+ native_status: e.status,
1001
+ latest_run_status: latestRunStatus(e.metadata),
1002
+ run_history: e.metadata.run_history ?? [],
759
1003
  status: e.status,
760
1004
  unit_path: e.metadata.unit_path,
761
1005
  })),
@@ -769,14 +1013,16 @@ export function formatScheduleListResult(report, json) {
769
1013
  lines.push("No schedules found.");
770
1014
  return lines.join("\n");
771
1015
  }
772
- lines.push(["ID", "RUN_AT", "BACKEND", "AGENT", "STATUS", "UNIT_PATH"].join(" "));
1016
+ lines.push(["ID", "COMMAND", "RUN_AT", "BACKEND", "AGENT", "NATIVE", "LATEST", "UNIT_PATH"].join(" "));
773
1017
  for (const e of report.entries) {
774
1018
  lines.push([
775
1019
  e.metadata.id,
1020
+ scheduleCommandLabel(e.metadata),
776
1021
  e.metadata.run_at_iso,
777
1022
  e.metadata.backend,
778
1023
  e.metadata.agent,
779
1024
  e.status,
1025
+ latestRunStatus(e.metadata) || "-",
780
1026
  e.metadata.unit_path ?? "-",
781
1027
  ].join(" "));
782
1028
  }
@@ -810,6 +1056,11 @@ export async function orchestrateScheduleCancel(options, deps) {
810
1056
  if (!cancelResult.ok) {
811
1057
  return { ok: false, error: cancelResult.error ?? "Backend cancel failed." };
812
1058
  }
1059
+ // Best-effort: record a `canceled` event before deleting metadata. This is
1060
+ // primarily useful if deletion later fails, or if cancellation is refactored
1061
+ // to retain records; a failure here must not mask a successful native cancel.
1062
+ const canceledAtIso = new Date(deps.now ? deps.now() : Date.now()).toISOString();
1063
+ await appendScheduleRunEvent(options.id, { status: "canceled", at: canceledAtIso }, deps.homeDir, deps.platform).catch(() => undefined);
813
1064
  // Stale or removed both delete local metadata; logs are preserved by the store.
814
1065
  await deleteScheduleMetadata(options.id, deps.homeDir, deps.platform);
815
1066
  return {
@@ -833,12 +1084,20 @@ export function formatScheduleCancelResult(result) {
833
1084
  " logs: preserved",
834
1085
  ].join("\n");
835
1086
  }
836
- /** Read-only diagnostics: platform support, backend order/availability, binaries. */
1087
+ /**
1088
+ * Read-only diagnostics: platform support, backend order/availability, both agent
1089
+ * binaries, and secret-free auth readiness for each agent. Stays zero-argument
1090
+ * and environment-oriented — command-specific prompt rendering belongs to
1091
+ * `create --dry-run`, never here.
1092
+ */
837
1093
  export async function orchestrateScheduleDoctor(deps) {
838
1094
  const platformResult = getSchedulerBackendsForPlatform(deps.platform);
839
1095
  const envPath = deps.env.PATH ?? deps.env.Path ?? "";
840
1096
  const claudeResolved = Boolean(await resolveCommandOnPath("claude", envPath, deps));
1097
+ const cursorResolved = Boolean(await resolveCommandOnPath("cursor-agent", envPath, deps));
841
1098
  const npxResolved = Boolean(await resolveCommandOnPath("npx", envPath, deps));
1099
+ const cursorApiKeyPresent = Boolean(deps.env.CURSOR_API_KEY);
1100
+ const bridgeCredentialResolved = deps.bridgeCredentialResolved?.() ?? Boolean(deps.env.BAPI_API_KEY);
842
1101
  if (!platformResult.ok) {
843
1102
  return {
844
1103
  platform: deps.platform,
@@ -846,7 +1105,10 @@ export async function orchestrateScheduleDoctor(deps) {
846
1105
  candidateBackends: [],
847
1106
  backendAvailability: [],
848
1107
  claudeResolved,
1108
+ cursorResolved,
849
1109
  npxResolved,
1110
+ cursorApiKeyPresent,
1111
+ bridgeCredentialResolved,
850
1112
  unsupportedMessage: platformResult.error,
851
1113
  };
852
1114
  }
@@ -861,10 +1123,13 @@ export async function orchestrateScheduleDoctor(deps) {
861
1123
  candidateBackends,
862
1124
  backendAvailability,
863
1125
  claudeResolved,
1126
+ cursorResolved,
864
1127
  npxResolved,
1128
+ cursorApiKeyPresent,
1129
+ bridgeCredentialResolved,
865
1130
  };
866
1131
  }
867
- /** Format the doctor report (table or JSON). */
1132
+ /** Format the doctor report (table or JSON). Never prints secret values. */
868
1133
  export function formatScheduleDoctorReport(report, json) {
869
1134
  if (json)
870
1135
  return JSON.stringify(report, null, 2);
@@ -881,10 +1146,82 @@ export function formatScheduleDoctorReport(report, json) {
881
1146
  lines.push(` ${a.available ? "AVAILABLE " : "UNAVAILABLE"} ${a.backend}`);
882
1147
  }
883
1148
  }
884
- lines.push(`claude on PATH: ${report.claudeResolved ? "yes" : "no"}`);
885
- lines.push(`npx on PATH: ${report.npxResolved ? "yes" : "no"}`);
1149
+ lines.push(`claude on PATH: ${report.claudeResolved ? "yes" : "no"}`);
1150
+ lines.push(`cursor-agent on PATH: ${report.cursorResolved ? "yes" : "no"}`);
1151
+ lines.push(`npx on PATH: ${report.npxResolved ? "yes" : "no"}`);
1152
+ lines.push(`CURSOR_API_KEY set: ${report.cursorApiKeyPresent ? "yes" : "no"}`);
1153
+ lines.push(`Bridge credential: ${report.bridgeCredentialResolved ? "resolved" : "not resolved"}`);
886
1154
  return lines.join("\n");
887
1155
  }
1156
+ /** Current time as ISO from the injectable clock. */
1157
+ function nowIso(deps) {
1158
+ return new Date(deps.now ? deps.now() : Date.now()).toISOString();
1159
+ }
1160
+ /**
1161
+ * The hidden `_execute <id>` shim the OS unit runs at fire time (BAPI-351). It
1162
+ * reads the local schedule metadata, validates the stored agent invocation,
1163
+ * records a `started` event, spawns the agent via the list-based `runCommand`
1164
+ * boundary (NEVER a shell string) with the schedule's cwd + baked env, forwards
1165
+ * the agent's stdout/stderr to its own streams (so existing OS log redirection
1166
+ * captures them), then records `completed`/`failed` and returns the agent's exit
1167
+ * code. Run-history writes are best-effort and never mask the agent's exit code;
1168
+ * env values (including any secrets) are never printed.
1169
+ */
1170
+ export async function orchestrateScheduleExecute(options, deps, io) {
1171
+ const metadata = await readScheduleMetadata(options.id, deps.homeDir, deps.platform);
1172
+ if (!metadata) {
1173
+ return { ok: false, exitCode: 1, error: `No schedule found with id '${options.id}'.` };
1174
+ }
1175
+ const agentInvocation = metadata.agent_invocation ?? metadata.invocation;
1176
+ if (!agentInvocation || !agentInvocation.exe) {
1177
+ await appendScheduleRunEvent(options.id, { status: "failed", at: nowIso(deps), message: "missing agent_invocation" }, deps.homeDir, deps.platform).catch(() => undefined);
1178
+ return { ok: false, exitCode: 1, error: `Schedule '${options.id}' has no agent invocation to run.` };
1179
+ }
1180
+ await appendScheduleRunEvent(options.id, { status: "started", at: nowIso(deps) }, deps.homeDir, deps.platform).catch(() => undefined);
1181
+ // Child env: base env + baked PATH + schedule-context variables. No secrets are
1182
+ // synthesized here; only values already present in metadata/env are forwarded.
1183
+ const env = { ...deps.env };
1184
+ if (metadata.env_path) {
1185
+ env.PATH = metadata.env_path;
1186
+ if (deps.platform === "win32")
1187
+ env.Path = metadata.env_path;
1188
+ }
1189
+ env.BRIDGE_GPT_SCHEDULE_ID = metadata.id;
1190
+ if (metadata.command)
1191
+ env.BRIDGE_GPT_COMMAND = metadata.command;
1192
+ if (metadata.args)
1193
+ env.BRIDGE_GPT_COMMAND_ARGS_JSON = JSON.stringify(metadata.args);
1194
+ if (metadata.repo_path)
1195
+ env.BRIDGE_GPT_REPO_PATH = metadata.repo_path;
1196
+ if (metadata.agent)
1197
+ env.BRIDGE_GPT_AGENT = metadata.agent;
1198
+ if (metadata.agent_path)
1199
+ env.BRIDGE_GPT_AGENT_PATH = metadata.agent_path;
1200
+ if (metadata.idea_file)
1201
+ env.BRIDGE_GPT_IDEA_FILE = metadata.idea_file;
1202
+ let result;
1203
+ try {
1204
+ result = await deps.runCommand(agentInvocation.exe, agentInvocation.args, {
1205
+ cwd: metadata.repo_path,
1206
+ env,
1207
+ });
1208
+ }
1209
+ catch (error) {
1210
+ const msg = error instanceof Error ? error.message : String(error);
1211
+ await appendScheduleRunEvent(options.id, { status: "failed", at: nowIso(deps), message: `agent launch failed: ${msg}` }, deps.homeDir, deps.platform).catch(() => undefined);
1212
+ return { ok: false, exitCode: 1, error: `Failed to launch agent: ${msg}` };
1213
+ }
1214
+ if (result.stdout)
1215
+ io.writeStdout(result.stdout);
1216
+ if (result.stderr)
1217
+ io.writeStderr(result.stderr);
1218
+ if (result.exitCode === 0) {
1219
+ await appendScheduleRunEvent(options.id, { status: "completed", at: nowIso(deps), exit_code: 0 }, deps.homeDir, deps.platform).catch(() => undefined);
1220
+ return { ok: true, exitCode: 0 };
1221
+ }
1222
+ await appendScheduleRunEvent(options.id, { status: "failed", at: nowIso(deps), exit_code: result.exitCode }, deps.homeDir, deps.platform).catch(() => undefined);
1223
+ return { ok: false, exitCode: result.exitCode };
1224
+ }
888
1225
  /**
889
1226
  * CLI entry for `schedule-run`. Returns a numeric process exit code. Help → 0;
890
1227
  * parser/validation errors → 1 (printed with usage to stderr); create/list/
@@ -936,6 +1273,17 @@ export async function runScheduleRunCli(argv, overrides = {}) {
936
1273
  log(formatScheduleDoctorReport(report, parsed.options.json));
937
1274
  return report.platformSupported ? 0 : 1;
938
1275
  }
1276
+ case "_execute": {
1277
+ const io = {
1278
+ writeStdout: overrides.writeStdout ?? ((chunk) => process.stdout.write(chunk)),
1279
+ writeStderr: overrides.writeStderr ?? ((chunk) => process.stderr.write(chunk)),
1280
+ };
1281
+ const result = await orchestrateScheduleExecute(parsed.options, deps, io);
1282
+ if (!result.ok && result.error) {
1283
+ errorLog(`Error: ${result.error}`);
1284
+ }
1285
+ return result.exitCode;
1286
+ }
939
1287
  }
940
1288
  }
941
1289
  catch (error) {