diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesCronJob.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesCronJob.swift index 11c671e..27b1ef1 100644 --- a/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesCronJob.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesCronJob.swift @@ -28,6 +28,12 @@ public struct HermesCronJob: Identifiable, Sendable, Codable { /// job's prompt. YAML-only field today (no `--context-from` CLI /// flag yet) — Scarf displays it but doesn't write it. public nonisolated let contextFrom: [String]? + /// Hermes v0.13+ — script-only watchdog mode. When `true` the + /// pre-run script runs but the AI turn is skipped. `nil` means the + /// jobs.json file is pre-v0.13 (treat as `false`); `false` is the + /// explicit v0.13+ default. Capability-gated on `hasCronNoAgent` + /// at all write call sites. + public nonisolated let noAgent: Bool? public enum CodingKeys: String, CodingKey { case id, name, prompt, skills, model, schedule, enabled, state, deliver, silent @@ -41,6 +47,7 @@ public struct HermesCronJob: Identifiable, Sendable, Codable { case timeoutSeconds = "timeout_seconds" case workdir case contextFrom = "context_from" + case noAgent = "no_agent" } /// Memberwise init. Swift doesn't synthesize one for us because @@ -66,7 +73,8 @@ public struct HermesCronJob: Identifiable, Sendable, Codable { timeoutSeconds: Int? = nil, silent: Bool? = nil, workdir: String? = nil, - contextFrom: [String]? = nil + contextFrom: [String]? = nil, + noAgent: Bool? = nil ) { self.id = id self.name = name @@ -88,6 +96,7 @@ public struct HermesCronJob: Identifiable, Sendable, Codable { self.silent = silent self.workdir = workdir self.contextFrom = contextFrom + self.noAgent = noAgent } public nonisolated init(from decoder: any Decoder) throws { @@ -112,6 +121,7 @@ public struct HermesCronJob: Identifiable, Sendable, Codable { self.silent = try c.decodeIfPresent(Bool.self, forKey: .silent) self.workdir = try c.decodeIfPresent(String.self, forKey: .workdir) self.contextFrom = try c.decodeIfPresent([String].self, forKey: .contextFrom) + self.noAgent = try c.decodeIfPresent(Bool.self, forKey: .noAgent) } public nonisolated func encode(to encoder: any Encoder) throws { @@ -136,6 +146,7 @@ public struct HermesCronJob: Identifiable, Sendable, Codable { try c.encodeIfPresent(silent, forKey: .silent) try c.encodeIfPresent(workdir, forKey: .workdir) try c.encodeIfPresent(contextFrom, forKey: .contextFrom) + try c.encodeIfPresent(noAgent, forKey: .noAgent) } public nonisolated var stateIcon: String { diff --git a/scarf/scarf/Features/Cron/ViewModels/CronViewModel.swift b/scarf/scarf/Features/Cron/ViewModels/CronViewModel.swift index 002c24b..c80c77f 100644 --- a/scarf/scarf/Features/Cron/ViewModels/CronViewModel.swift +++ b/scarf/scarf/Features/Cron/ViewModels/CronViewModel.swift @@ -146,7 +146,7 @@ final class CronViewModel { } } - func createJob(schedule: String, prompt: String, name: String, deliver: String, skills: [String], script: String, repeatCount: String, workdir: String = "") { + func createJob(schedule: String, prompt: String, name: String, deliver: String, skills: [String], script: String, repeatCount: String, workdir: String = "", noAgent: Bool = false) { var args = ["cron", "create"] if !name.isEmpty { args += ["--name", name] } if !deliver.isEmpty { args += ["--deliver", deliver] } @@ -158,12 +158,25 @@ final class CronViewModel { // know the flag — argparse rejects unknown args, so the form // omits the flag when the field is empty. if !workdir.isEmpty { args += ["--workdir", workdir] } + // v0.13+: --no-agent runs the pre-run script and skips the AI turn. + // Caller (CronView) strips this on pre-v0.13 hosts so the flag is + // never emitted to a Hermes that can't parse it. + if noAgent { args.append("--no-agent") } args.append(schedule) - if !prompt.isEmpty { args.append(prompt) } + // TODO(WS-7-Q5): When --no-agent is set Hermes ignores the prompt arg, + // but argparse still wants positional args to line up with the + // schedule. The plan recommends passing an empty string explicitly so + // the positional parser doesn't treat the prompt as missing — verify + // this behaviour against `hermes cron create --help` on a v0.13 host. + if noAgent { + args.append("") + } else if !prompt.isEmpty { + args.append(prompt) + } runAndReload(args, success: "Job created") } - func updateJob(id: String, schedule: String?, prompt: String?, name: String?, deliver: String?, repeatCount: String?, newSkills: [String]?, clearSkills: Bool, script: String?, workdir: String? = nil) { + func updateJob(id: String, schedule: String?, prompt: String?, name: String?, deliver: String?, repeatCount: String?, newSkills: [String]?, clearSkills: Bool, script: String?, workdir: String? = nil, noAgent: Bool? = nil) { var args = ["cron", "edit", id] if let schedule, !schedule.isEmpty { args += ["--schedule", schedule] } if let prompt, !prompt.isEmpty { args += ["--prompt", prompt] } @@ -180,6 +193,16 @@ final class CronViewModel { // = user cleared an existing workdir; Hermes documents `--workdir ""` // on edit as the explicit clear gesture, mirroring the `--script` shape. if let workdir { args += ["--workdir", workdir] } + // TODO(WS-7-Q4): The toggle-off shape of `--no-agent` on edit is + // unverified. Plan assumes Hermes accepts `--agent` to flip the flag + // back; if the CLI is one-way (`--no-agent` only), the edit-mode + // toggle should disable itself with a tooltip explaining the + // limitation. Send the flag in the assumed shape for now and adjust + // post-integration. + if let noAgent { + if noAgent { args.append("--no-agent") } + else { args.append("--agent") } + } runAndReload(args, success: "Updated") } diff --git a/scarf/scarf/Features/Cron/Views/CronView.swift b/scarf/scarf/Features/Cron/Views/CronView.swift index 214b1dc..4fb0756 100644 --- a/scarf/scarf/Features/Cron/Views/CronView.swift +++ b/scarf/scarf/Features/Cron/Views/CronView.swift @@ -25,6 +25,10 @@ struct CronView: View { capabilitiesStore?.capabilities.hasCronWorkdir ?? false } + private var hasCronNoAgent: Bool { + capabilitiesStore?.capabilities.hasCronNoAgent ?? false + } + var body: some View { VStack(spacing: 0) { pageHeader @@ -47,7 +51,7 @@ struct CronView: View { // polling timer. Same wiring ActivityView uses. .onChange(of: fileWatcher.lastChangeDate) { viewModel.load() } .sheet(isPresented: $viewModel.showCreateSheet) { - CronJobEditor(mode: .create, availableSkills: viewModel.availableSkills, supportsWorkdir: hasCronWorkdir) { form in + CronJobEditor(mode: .create, availableSkills: viewModel.availableSkills, supportsWorkdir: hasCronWorkdir, supportsNoAgent: hasCronNoAgent) { form in viewModel.createJob( schedule: form.schedule, prompt: form.prompt, @@ -56,7 +60,12 @@ struct CronView: View { skills: form.skills, script: form.script, repeatCount: form.repeatCount, - workdir: hasCronWorkdir ? form.workdir : "" + workdir: hasCronWorkdir ? form.workdir : "", + // Mirrors the workdir strip-on-pre-version pattern: pre-v0.13 + // hosts get a hard `false`, so a stale form value (or a + // hand-edited jobs.json round-tripped through edit-mode) + // can't sneak `--no-agent` into a CLI that doesn't grok it. + noAgent: hasCronNoAgent ? form.noAgent : false ) viewModel.showCreateSheet = false } onCancel: { @@ -64,7 +73,7 @@ struct CronView: View { } } .sheet(item: $viewModel.editingJob) { job in - CronJobEditor(mode: .edit(job), availableSkills: viewModel.availableSkills, supportsWorkdir: hasCronWorkdir) { form in + CronJobEditor(mode: .edit(job), availableSkills: viewModel.availableSkills, supportsWorkdir: hasCronWorkdir, supportsNoAgent: hasCronNoAgent) { form in viewModel.updateJob( id: job.id, schedule: form.schedule, @@ -75,7 +84,8 @@ struct CronView: View { newSkills: form.skills, clearSkills: form.clearSkills, script: form.script, - workdir: hasCronWorkdir ? form.workdir : nil + workdir: hasCronWorkdir ? form.workdir : nil, + noAgent: hasCronNoAgent ? form.noAgent : nil ) viewModel.editingJob = nil } onCancel: { @@ -643,6 +653,9 @@ struct CronJobEditor: View { /// v0.12+ workdir flag — fills `--workdir `. Empty string /// preserves the v0.11 behaviour of running with no cwd hint. var workdir: String = "" + /// v0.13+ `--no-agent` flag — script-only watchdog mode. Hermes + /// runs the pre-run script and skips the AI turn. + var noAgent: Bool = false } let mode: Mode @@ -650,6 +663,10 @@ struct CronJobEditor: View { /// Pass `false` on pre-v0.12 hosts; the `--workdir` field is hidden and /// the form's value is dropped when the parent calls `createJob`/`updateJob`. let supportsWorkdir: Bool + /// Pass `false` on pre-v0.13 hosts; the `--no-agent` toggle is hidden + /// and the parent strips the form's value before calling + /// `createJob`/`updateJob`. Mirrors the `supportsWorkdir` pattern. + let supportsNoAgent: Bool let onSave: (FormState) -> Void let onCancel: () -> Void @@ -681,12 +698,25 @@ struct CronJobEditor: View { ) .scrollContentBackground(.hidden) } + .opacity(form.noAgent ? 0.4 : 1.0) + .disabled(form.noAgent) formField("Deliver", text: $form.deliver, placeholder: "origin | local | discord:CHANNEL | telegram:CHAT", mono: true) formField("Repeat", text: $form.repeatCount, placeholder: "Optional count") formField("Script path", text: $form.script, placeholder: "Python script whose stdout is injected", mono: true) if supportsWorkdir { formField("Workdir", text: $form.workdir, placeholder: "Absolute path; pulls AGENTS.md/CLAUDE.md context", mono: true) } + if supportsNoAgent { + Toggle("Run script only (no agent call)", isOn: $form.noAgent) + .scarfStyle(.body) + .tint(ScarfColor.accent) + if form.noAgent { + Text("Watchdog mode — Hermes runs the pre-run script and skips the AI turn. Prompt + skills are ignored.") + .scarfStyle(.caption) + .foregroundStyle(ScarfColor.foregroundMuted) + .padding(.leading, ScarfSpace.s3) + } + } if !availableSkills.isEmpty { VStack(alignment: .leading, spacing: 4) { Text("Skills") @@ -723,6 +753,8 @@ struct CronJobEditor: View { .tint(ScarfColor.accent) } } + .opacity(form.noAgent ? 0.4 : 1.0) + .disabled(form.noAgent) } HStack { Spacer() @@ -746,6 +778,7 @@ struct CronJobEditor: View { form.skills = job.skills ?? [] form.script = job.preRunScript ?? "" form.workdir = job.workdir ?? "" + form.noAgent = job.noAgent ?? false } } }