feat(cron): add --no-agent watchdog toggle gated on hasCronNoAgent

Adds a "Run script only (no agent call)" toggle to the cron job
editor. When ON, the prompt + skills sections dim + disable
visually but stay rendered (no layout shift mid-edit), the
script field stays fully active, and the form passes
`noAgent: true` to `createJob`/`updateJob`. The toggle is hidden
on pre-v0.13 hosts via `supportsNoAgent: hasCronNoAgent` and
defensively stripped at the call site (`hasCronNoAgent ?
form.noAgent : false` on create, `: nil` on edit) — same shape
as the v0.12 `workdir` strip.

Read-side: `HermesCronJob.noAgent: Bool?` is decoded via
`decodeIfPresent` so pre-v0.13 jobs.json files round-trip
unchanged. The display rule `job.noAgent == true` treats
`nil` and `false` identically — a script-only job must opt in.

Write-side:
- `createJob` appends `--no-agent` and passes an empty positional
  prompt (per WS-7-Q5) to keep argparse happy when the prompt is
  the trailing positional.
- `updateJob` sends `--no-agent` / `--agent` to flip the flag in
  edit mode (per WS-7-Q4 — verify the toggle-off spelling on
  integration; if Hermes is one-way, disable the toggle in edit
  mode with a tooltip).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-05-09 18:43:03 +02:00
parent c81a8a56e8
commit fd33b714e3
3 changed files with 75 additions and 8 deletions
@@ -28,6 +28,12 @@ public struct HermesCronJob: Identifiable, Sendable, Codable {
/// job's prompt. YAML-only field today (no `--context-from` CLI /// job's prompt. YAML-only field today (no `--context-from` CLI
/// flag yet) Scarf displays it but doesn't write it. /// flag yet) Scarf displays it but doesn't write it.
public nonisolated let contextFrom: [String]? 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 { public enum CodingKeys: String, CodingKey {
case id, name, prompt, skills, model, schedule, enabled, state, deliver, silent 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 timeoutSeconds = "timeout_seconds"
case workdir case workdir
case contextFrom = "context_from" case contextFrom = "context_from"
case noAgent = "no_agent"
} }
/// Memberwise init. Swift doesn't synthesize one for us because /// Memberwise init. Swift doesn't synthesize one for us because
@@ -66,7 +73,8 @@ public struct HermesCronJob: Identifiable, Sendable, Codable {
timeoutSeconds: Int? = nil, timeoutSeconds: Int? = nil,
silent: Bool? = nil, silent: Bool? = nil,
workdir: String? = nil, workdir: String? = nil,
contextFrom: [String]? = nil contextFrom: [String]? = nil,
noAgent: Bool? = nil
) { ) {
self.id = id self.id = id
self.name = name self.name = name
@@ -88,6 +96,7 @@ public struct HermesCronJob: Identifiable, Sendable, Codable {
self.silent = silent self.silent = silent
self.workdir = workdir self.workdir = workdir
self.contextFrom = contextFrom self.contextFrom = contextFrom
self.noAgent = noAgent
} }
public nonisolated init(from decoder: any Decoder) throws { 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.silent = try c.decodeIfPresent(Bool.self, forKey: .silent)
self.workdir = try c.decodeIfPresent(String.self, forKey: .workdir) self.workdir = try c.decodeIfPresent(String.self, forKey: .workdir)
self.contextFrom = try c.decodeIfPresent([String].self, forKey: .contextFrom) 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 { 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(silent, forKey: .silent)
try c.encodeIfPresent(workdir, forKey: .workdir) try c.encodeIfPresent(workdir, forKey: .workdir)
try c.encodeIfPresent(contextFrom, forKey: .contextFrom) try c.encodeIfPresent(contextFrom, forKey: .contextFrom)
try c.encodeIfPresent(noAgent, forKey: .noAgent)
} }
public nonisolated var stateIcon: String { public nonisolated var stateIcon: String {
@@ -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"] var args = ["cron", "create"]
if !name.isEmpty { args += ["--name", name] } if !name.isEmpty { args += ["--name", name] }
if !deliver.isEmpty { args += ["--deliver", deliver] } if !deliver.isEmpty { args += ["--deliver", deliver] }
@@ -158,12 +158,25 @@ final class CronViewModel {
// know the flag argparse rejects unknown args, so the form // know the flag argparse rejects unknown args, so the form
// omits the flag when the field is empty. // omits the flag when the field is empty.
if !workdir.isEmpty { args += ["--workdir", workdir] } 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) 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") 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] var args = ["cron", "edit", id]
if let schedule, !schedule.isEmpty { args += ["--schedule", schedule] } if let schedule, !schedule.isEmpty { args += ["--schedule", schedule] }
if let prompt, !prompt.isEmpty { args += ["--prompt", prompt] } if let prompt, !prompt.isEmpty { args += ["--prompt", prompt] }
@@ -180,6 +193,16 @@ final class CronViewModel {
// = user cleared an existing workdir; Hermes documents `--workdir ""` // = user cleared an existing workdir; Hermes documents `--workdir ""`
// on edit as the explicit clear gesture, mirroring the `--script` shape. // on edit as the explicit clear gesture, mirroring the `--script` shape.
if let workdir { args += ["--workdir", workdir] } 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") runAndReload(args, success: "Updated")
} }
+37 -4
View File
@@ -25,6 +25,10 @@ struct CronView: View {
capabilitiesStore?.capabilities.hasCronWorkdir ?? false capabilitiesStore?.capabilities.hasCronWorkdir ?? false
} }
private var hasCronNoAgent: Bool {
capabilitiesStore?.capabilities.hasCronNoAgent ?? false
}
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
pageHeader pageHeader
@@ -47,7 +51,7 @@ struct CronView: View {
// polling timer. Same wiring ActivityView uses. // polling timer. Same wiring ActivityView uses.
.onChange(of: fileWatcher.lastChangeDate) { viewModel.load() } .onChange(of: fileWatcher.lastChangeDate) { viewModel.load() }
.sheet(isPresented: $viewModel.showCreateSheet) { .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( viewModel.createJob(
schedule: form.schedule, schedule: form.schedule,
prompt: form.prompt, prompt: form.prompt,
@@ -56,7 +60,12 @@ struct CronView: View {
skills: form.skills, skills: form.skills,
script: form.script, script: form.script,
repeatCount: form.repeatCount, 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 viewModel.showCreateSheet = false
} onCancel: { } onCancel: {
@@ -64,7 +73,7 @@ struct CronView: View {
} }
} }
.sheet(item: $viewModel.editingJob) { job in .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( viewModel.updateJob(
id: job.id, id: job.id,
schedule: form.schedule, schedule: form.schedule,
@@ -75,7 +84,8 @@ struct CronView: View {
newSkills: form.skills, newSkills: form.skills,
clearSkills: form.clearSkills, clearSkills: form.clearSkills,
script: form.script, script: form.script,
workdir: hasCronWorkdir ? form.workdir : nil workdir: hasCronWorkdir ? form.workdir : nil,
noAgent: hasCronNoAgent ? form.noAgent : nil
) )
viewModel.editingJob = nil viewModel.editingJob = nil
} onCancel: { } onCancel: {
@@ -643,6 +653,9 @@ struct CronJobEditor: View {
/// v0.12+ workdir flag fills `--workdir <path>`. Empty string /// v0.12+ workdir flag fills `--workdir <path>`. Empty string
/// preserves the v0.11 behaviour of running with no cwd hint. /// preserves the v0.11 behaviour of running with no cwd hint.
var workdir: String = "" 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 let mode: Mode
@@ -650,6 +663,10 @@ struct CronJobEditor: View {
/// Pass `false` on pre-v0.12 hosts; the `--workdir` field is hidden and /// 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`. /// the form's value is dropped when the parent calls `createJob`/`updateJob`.
let supportsWorkdir: Bool 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 onSave: (FormState) -> Void
let onCancel: () -> Void let onCancel: () -> Void
@@ -681,12 +698,25 @@ struct CronJobEditor: View {
) )
.scrollContentBackground(.hidden) .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("Deliver", text: $form.deliver, placeholder: "origin | local | discord:CHANNEL | telegram:CHAT", mono: true)
formField("Repeat", text: $form.repeatCount, placeholder: "Optional count") formField("Repeat", text: $form.repeatCount, placeholder: "Optional count")
formField("Script path", text: $form.script, placeholder: "Python script whose stdout is injected", mono: true) formField("Script path", text: $form.script, placeholder: "Python script whose stdout is injected", mono: true)
if supportsWorkdir { if supportsWorkdir {
formField("Workdir", text: $form.workdir, placeholder: "Absolute path; pulls AGENTS.md/CLAUDE.md context", mono: true) 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 { if !availableSkills.isEmpty {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text("Skills") Text("Skills")
@@ -723,6 +753,8 @@ struct CronJobEditor: View {
.tint(ScarfColor.accent) .tint(ScarfColor.accent)
} }
} }
.opacity(form.noAgent ? 0.4 : 1.0)
.disabled(form.noAgent)
} }
HStack { HStack {
Spacer() Spacer()
@@ -746,6 +778,7 @@ struct CronJobEditor: View {
form.skills = job.skills ?? [] form.skills = job.skills ?? []
form.script = job.preRunScript ?? "" form.script = job.preRunScript ?? ""
form.workdir = job.workdir ?? "" form.workdir = job.workdir ?? ""
form.noAgent = job.noAgent ?? false
} }
} }
} }