1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
/// Build the single-line shell command that creates a workbench and launches the agent in tmux.
///
/// The agent's cwd is the workbench (via `tmux -c`), so `{prompt_file}` resolves to `prompt.md`,
/// `{workbench}` to `.`, and `{prompt}` to the prompt's contents passed as one argument. The prompt
/// reaches the agent as a process argument (not keystrokes), so there is no readiness race. The
/// command is `;`-joined (no newlines) so it fits one crontab line.
///
/// `source` controls whether the script records a scheduled firing: a [`TriggerSource::Scheduled`]
/// run (the crontab) appends to `scheduled.log`, while a [`TriggerSource::Manual`] run omits the
/// append so an on-demand trigger never clobbers `last_scheduled_trigger_at`.
pub(crate) fn build_routine_command(
routine: &Routine,
agent: &AgentCommand,
source: TriggerSource,
) -> String {
let rel_dir = crate::routine_storage::routine_rel_dir(routine);
let slug = crate::routine_storage::routine_slug(routine);
let prompt_path = routine_compiled_prompt_path(&rel_dir)
.to_string_lossy()
.into_owned();
let scheduled_log_path = routine_scheduled_log_path(&rel_dir)
.to_string_lossy()
.into_owned();
// Resolve through the same seam the reaper (`cleanup/mod.rs`) and the LOGS view
// (`routines/service.rs`) use, rather than hardcoding `$HOME/.moadim/workbenches`: honoring
// `MOADIM_HOME_OVERRIDE` here keeps the path a run is launched at in sync with the paths those
// consumers scan, instead of drifting the moment either side changes.
let workbenches_base = crate::paths::workbenches_dir()
.to_string_lossy()
.into_owned();
let prompt_file_ref = "prompt.md";
let workbench_ref = ".";
let mut invocation = vec![agent.command.clone()];
for arg in &agent.args {
invocation.push(substitute(arg, workbench_ref, prompt_file_ref));
}
// Routine-level model override, by convention supported as `--model <id>` across the built-in
// agents (`claude`, `codex`, `hermes`). Appended after the agent's own args so it wins over any
// default the agent config sets. `shell_quote` guards against the model ID (user input) breaking
// out of the invocation, which the surrounding `shell_quote(&invocation)` call re-escapes as a
// whole when it embeds this into the cron line.
if let Some(model) = &routine.model {
invocation.push("--model".to_string());
invocation.push(shell_quote(model));
}
let invocation = invocation.join(" ");
let mut stmts = vec![
// Owner-only umask for everything this run creates: the workbench dir, the copied
// `prompt.md`, the appended `CLAUDE.md`, and the tmux-piped `agent.log` (which captures the
// full agent transcript — cloned repo contents, command output, any printed secrets). Set
// before the first `mkdir`/`cp`/`tmux` so those artifacts land `0700`/`0600` instead of the
// login shell's default world-readable umask, matching the daemon's own on-disk posture.
"umask 077".to_string(),
// The crontab invokes this script under a *login* shell (`/bin/sh -l`; see
// `sync::routines::format_routine_line`), so the user's `~/.profile` is sourced first and
// the agent inherits their environment — GH_TOKEN, API keys and the like — which cron's
// minimal env (and, on macOS, the GUI-Keychain-less session) otherwise withholds.
//
// The curated dirs are *appended* to the profile's PATH (`$PATH:<curated>`), not
// substituted for it. The profile-sourced `$PATH` therefore keeps precedence, so the
// version-manager shim dirs a profile prepends (nvm/pyenv/asdf/volta) survive and the agent
// resolves the node/python the user actually selected. The curated list trails as a
// fallback, guaranteeing `tmux` and the agent `command` stay resolvable even when the
// profile's PATH omits their dirs (or the profile sets no PATH at all). `$PATH` is left
// unquoted so the login shell expands it; only the curated suffix is quoted.
format!(
"export PATH=$PATH:{}",
shell_quote(&cron_path(&agent.command))
),
];
// Per-routine env vars (issue #408): the tracked `routine.toml` `[env]` table, overlaid with
// the untracked `routine.local.toml` sidecar (secrets — its keys win). Emitted right after the
// curated PATH export and before anything else runs, so they override any profile-inherited
// value (e.g. a shared `GH_TOKEN`) for this run only, without touching the operator's actual
// shell environment.
stmts.extend(env_export_stmts(routine));
stmts.push(r#"TS="$(date +%s)""#.to_string());
if source == TriggerSource::Scheduled {
// Record this scheduled firing. Appends the Unix timestamp as one line to the routine's
// gitignored `scheduled.log`; the daemon reads the last line back as
// `last_scheduled_trigger_at` on load. Using `>>` (append) preserves the full run history.
// Written before the prompt-copy guard below so an aborted run still records that the
// schedule fired, and best-effort (`|| true`) so a log write failure never blocks launching.
//
// A manual ([`TriggerSource::Manual`]) trigger deliberately omits this append: it shares
// the exact same launch script but is tracked via `last_manual_trigger_at` (recorded
// in-process by `svc_trigger`), so appending here would conflate an on-demand "run now"
// with a genuine scheduled fire.
stmts.push(format!(
r#"printf '%s\n' "$TS" >> {} || true"#,
shell_quote(&scheduled_log_path)
));
}
stmts.extend([
format!("SLUG={}", shell_quote(&slug)),
// Collision-resistant run id. `$TS` alone has one-second granularity, so two runs of the
// *same* routine in the same wall-clock second (a double-clicked "Run now", a `trigger`
// retry, or a manual trigger landing on the scheduled cron fire) would derive an identical
// `$WB` and `$SESS`: the second `tmux new-session` fails with "duplicate session" and that
// run silently no-ops while both clobber the shared workbench files. `$$` is the launching
// shell's PID — distinct across concurrently-live processes — so each run gets a unique id
// even within the same second. POSIX-portable (works under `/bin/sh`/dash), filesystem- and
// shell-safe, and short enough to stay within the single crontab line. `$TS` is kept
// unchanged above for the scheduled-fire sidecar. The PID is joined with `_` (not `-`) so
// `parse_workbench_name` can still recover the slug and the trailing-timestamp: slugs are
// `[a-z0-9-]` only, so `_` is an unambiguous boundary and legacy `{slug}-{secs}` dirs keep
// parsing. (#411)
r#"RID="${TS}_$$""#.to_string(),
format!(r#"WB={}/"$SLUG-$RID""#, shell_quote(&workbenches_base)),
format!(r#"SESS="{TMUX_SESSION_PREFIX}$SLUG-$RID""#),
r#"mkdir -p "$WB""#.to_string(),
]);
// Everything from here on runs with stdout/stderr redirected into the workbench itself, so a
// failure in the setup step or the tmux launch leaves a readable trace instead of being handed
// to cron's mail spool (silently discarded on the headless hosts this daemon targets — see
// #375). `$WB` already exists (created by the `mkdir` above), so the redirect target is valid.
// The `cp`/disclosure guards below still `tee` their own abort reason into `agent.log`
// explicitly; under this wrapper that message also lands in `launch.log`, which is harmless.
let mut inner_stmts = Vec::new();
inner_stmts.extend(system_prompt_stmts(
&crate::paths::user_prompt_path().to_string_lossy(),
&routine.title,
&agent.instructions_file,
));
inner_stmts.extend([
// Fail-fast if the routine's source prompt is missing. The statements are `;`-joined, so a
// bare `cp` failure would be ignored and the agent would launch with an empty
// `"$(cat prompt.md)"` argument — a blank, task-less session. Abort instead, recording the
// reason in the workbench's agent.log (already created via mkdir) and on stderr.
format!(
r#"cp {src} "$WB/prompt.md" || {{ echo "moadim: missing routine prompt {src}; aborting launch" | tee -a "$WB/agent.log" >&2; exit 1; }}"#,
src = shell_quote(&prompt_path)
),
]);
// Pre-clone/fetch each declared repository into the workbench, before the agent's own setup
// step runs, so a `setup` step (or the agent itself) that expects the repos already present
// sees them from its first command (#466). A no-repositories routine emits nothing here,
// leaving today's empty-workbench behaviour untouched.
inner_stmts.extend(clone_repository_stmts(&routine.repositories));
if let Some(setup) = &agent.setup {
// Fail-fast if the agent's setup step fails, mirroring the `cp prompt.md` guard above. The
// statements are `;`-joined (no `set -e`), so a bare `setup` failure would be ignored and
// the agent would launch anyway — typically into the interactive trust/onboarding prompt
// with no stdin, where it hangs until the watchdog reaps it ~1h later with no diagnostic.
// Abort instead, recording the reason in agent.log and on stderr. The setup string is
// inserted verbatim so the agent author controls quoting; `$WB`/`$SESS` are in scope.
inner_stmts.push(format!(
r#"{{ {setup}; }} || {{ echo "moadim: agent setup failed; aborting launch" | tee -a "$WB/agent.log" >&2; exit 1; }}"#
));
}
// Record the agent's exit code once it finishes, so the run-history view (`svc_list_runs`)
// can tell success from failure instead of only "session ended". `tmux new-session` runs a
// single quoted string through the pane's default shell, so `;`-appending here shares that
// same shell and its `$?`. Written to a workbench-*relative* path (`exit_code`, not
// `$WB/exit_code`): `$WB` is a plain (non-exported) shell variable in the launcher script and
// is not inherited by the new shell tmux spawns, but the pane's cwd is already `$WB` (`-c`).
let invocation_with_exit_code = format!(r#"{invocation}; printf '%s' "$?" > exit_code"#);
// `pipe-pane` is chained onto the *same* tmux invocation as `new-session` via `\;` (tmux's own
// multi-command separator, escaped so the outer shell passes it through literally) rather than
// being a separate `;`-joined statement. `new-session -d` starts the agent immediately, so a
// pipe attached by a later, separate `tmux pipe-pane` call misses everything the agent writes
// in the gap between session creation and that second command running — the agent's opening
// banner, initial plan, and any immediate startup crash, silently dropped from `agent.log`
// (#289). Chaining within one invocation attaches the pipe to the pane tmux itself just
// created, before the calling shell moves on, so there is no such window.
//
// Fail loudly if the session can't start — most likely a residual `$SESS` collision. Without
// this guard a `duplicate session` error from tmux is swallowed by the `;`-join and the trigger
// returns success while launching nothing (the silent no-op #411 hardens against). Mirror the
// prompt-copy guard: record the reason in agent.log and on stderr, then exit non-zero.
inner_stmts.push(format!(
r#"tmux new-session -d -s "$SESS" -c "$WB" {} \; pipe-pane -o -t "$SESS" "cat >> \"$WB\"/agent.log" || {{ echo "moadim: failed to start tmux session $SESS (already exists?); aborting launch" | tee -a "$WB/agent.log" >&2; exit 1; }}"#,
shell_quote(&invocation_with_exit_code)
));
stmts.push(format!(
r#"{{ {} ; }} >> "$WB/launch.log" 2>&1"#,
inner_stmts.join("; ")
));
stmts.join("; ")
}