mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 18:44:45 +00:00
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:
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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 <path>`. 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user