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
|
/// 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")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user