@damian87/omp 0.4.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/agents/researcher.md +7 -6
- package/.github/copilot-instructions.md +23 -0
- package/.github/skills/daily-log/SKILL.md +64 -0
- package/.github/skills/goal/SKILL.md +33 -0
- package/.github/skills/schedule/SKILL.md +71 -0
- package/README.md +19 -2
- package/dist/src/cli.js +272 -9
- package/dist/src/cli.js.map +1 -1
- package/dist/src/comms/index.d.ts +116 -0
- package/dist/src/comms/index.js +258 -0
- package/dist/src/comms/index.js.map +1 -0
- package/dist/src/comms/resolve-session.d.ts +35 -0
- package/dist/src/comms/resolve-session.js +53 -0
- package/dist/src/comms/resolve-session.js.map +1 -0
- package/dist/src/daily-log.d.ts +18 -0
- package/dist/src/daily-log.js +138 -0
- package/dist/src/daily-log.js.map +1 -0
- package/dist/src/goal.d.ts +4 -0
- package/dist/src/goal.js +44 -0
- package/dist/src/goal.js.map +1 -0
- package/dist/src/instructions-memory.d.ts +9 -0
- package/dist/src/instructions-memory.js +72 -0
- package/dist/src/instructions-memory.js.map +1 -0
- package/dist/src/mcp/tools/daily-log.d.ts +2 -0
- package/dist/src/mcp/tools/daily-log.js +148 -0
- package/dist/src/mcp/tools/daily-log.js.map +1 -0
- package/dist/src/omp-root.d.ts +1 -0
- package/dist/src/omp-root.js +19 -0
- package/dist/src/omp-root.js.map +1 -0
- package/dist/src/project-memory.d.ts +13 -0
- package/dist/src/project-memory.js +105 -0
- package/dist/src/project-memory.js.map +1 -0
- package/dist/src/schedule/commands.d.ts +34 -0
- package/dist/src/schedule/commands.js +130 -0
- package/dist/src/schedule/commands.js.map +1 -0
- package/dist/src/schedule/installer.d.ts +20 -0
- package/dist/src/schedule/installer.js +76 -0
- package/dist/src/schedule/installer.js.map +1 -0
- package/dist/src/schedule/installers/crontab.d.ts +17 -0
- package/dist/src/schedule/installers/crontab.js +112 -0
- package/dist/src/schedule/installers/crontab.js.map +1 -0
- package/dist/src/schedule/installers/launchd.d.ts +22 -0
- package/dist/src/schedule/installers/launchd.js +125 -0
- package/dist/src/schedule/installers/launchd.js.map +1 -0
- package/dist/src/schedule/installers/systemd.d.ts +15 -0
- package/dist/src/schedule/installers/systemd.js +136 -0
- package/dist/src/schedule/installers/systemd.js.map +1 -0
- package/dist/src/schedule/job-store.d.ts +21 -0
- package/dist/src/schedule/job-store.js +102 -0
- package/dist/src/schedule/job-store.js.map +1 -0
- package/dist/src/schedule/lock.d.ts +9 -0
- package/dist/src/schedule/lock.js +83 -0
- package/dist/src/schedule/lock.js.map +1 -0
- package/dist/src/schedule/paths.d.ts +15 -0
- package/dist/src/schedule/paths.js +36 -0
- package/dist/src/schedule/paths.js.map +1 -0
- package/dist/src/schedule/runner.d.ts +8 -0
- package/dist/src/schedule/runner.js +151 -0
- package/dist/src/schedule/runner.js.map +1 -0
- package/dist/src/schedule/types.d.ts +60 -0
- package/dist/src/schedule/types.js +5 -0
- package/dist/src/schedule/types.js.map +1 -0
- package/dist/src/state.d.ts +17 -0
- package/dist/src/state.js +101 -0
- package/dist/src/state.js.map +1 -0
- package/dist/src/trace.d.ts +19 -0
- package/dist/src/trace.js +74 -0
- package/dist/src/trace.js.map +1 -0
- package/dist/test/catalog.test.d.ts +1 -0
- package/dist/test/catalog.test.js +21 -0
- package/dist/test/catalog.test.js.map +1 -0
- package/dist/test/jira.test.d.ts +1 -0
- package/dist/test/jira.test.js +26 -0
- package/dist/test/jira.test.js.map +1 -0
- package/dist/test/lint.test.d.ts +1 -0
- package/dist/test/lint.test.js +9 -0
- package/dist/test/lint.test.js.map +1 -0
- package/dist/test/sync.test.d.ts +1 -0
- package/dist/test/sync.test.js +15 -0
- package/dist/test/sync.test.js.map +1 -0
- package/docs/research/2026-06-01-schedule-cron-feature.md +346 -0
- package/package.json +1 -1
- package/scripts/lib/daily-log.mjs +155 -0
- package/scripts/lib/hook-output.mjs +2 -1
- package/scripts/lib/omp-root.mjs +15 -0
- package/scripts/lib/project-memory.mjs +21 -0
- package/scripts/lib/schedule-results.mjs +88 -0
- package/scripts/prompt-submit.mjs +14 -2
- package/scripts/session-end.mjs +6 -1
- package/scripts/session-start.mjs +60 -2
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
---
|
|
2
|
+
date: 2026-06-01T08:06:56+01:00
|
|
3
|
+
researcher: Damian Borek
|
|
4
|
+
git_commit: c0f9a66846698b40b4423066ce980f9a1a34b805
|
|
5
|
+
branch: feature/schedule-cron
|
|
6
|
+
repository: oh-my-copilot
|
|
7
|
+
topic: "How to build a local cron/`/schedule` feature for oh-my-copilot (omp)"
|
|
8
|
+
tags: [research, codebase, scheduling, cron, daemon, cli, mode-state, hooks, team-runtime]
|
|
9
|
+
status: complete
|
|
10
|
+
last_updated: 2026-06-01
|
|
11
|
+
last_updated_by: Damian Borek
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
# Research: A local `/schedule` cron feature for oh-my-copilot
|
|
15
|
+
|
|
16
|
+
**Date**: 2026-06-01 08:06:56 +0100
|
|
17
|
+
**Researcher**: Damian Borek
|
|
18
|
+
**Git Commit**: c0f9a66846698b40b4423066ce980f9a1a34b805
|
|
19
|
+
**Branch**: feature/schedule-cron
|
|
20
|
+
**Repository**: oh-my-copilot
|
|
21
|
+
|
|
22
|
+
## Research Question
|
|
23
|
+
|
|
24
|
+
How can we add a local cron-job scheduler to oh-my-copilot that works the same way
|
|
25
|
+
Claude Code's `/schedule` does — i.e. a recurring local job an agent (or the user)
|
|
26
|
+
can register and that fires unattended, e.g. *"check the PR every 15 minutes"*, by
|
|
27
|
+
spawning a fresh non-interactive agent session each tick?
|
|
28
|
+
|
|
29
|
+
This document maps (a) how Claude Code's own `/schedule` works, (b) how oh-my-copilot
|
|
30
|
+
is built today and where a scheduler would plug in, and (c) the concrete
|
|
31
|
+
implementation approaches available, with a recommendation.
|
|
32
|
+
|
|
33
|
+
> Note: per the user's explicit request ("research how we can work out this"), this
|
|
34
|
+
> document includes a forward-looking *Implementation Approaches* section in addition
|
|
35
|
+
> to documenting the current codebase. The "Detailed Findings" sections describe only
|
|
36
|
+
> what exists today.
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## Summary
|
|
41
|
+
|
|
42
|
+
- **Claude Code's CLI `/schedule`/`/loop`/`Cron*` is an *in-process, session-scoped*
|
|
43
|
+
timer** — no system crontab, no launchd, no separate daemon. Jobs live in RAM in the
|
|
44
|
+
running CLI process, support 5-field cron expressions in local time, cap at 50 tasks,
|
|
45
|
+
auto-expire after 3 days, and die when the session exits. The desktop app has a
|
|
46
|
+
*persistent* variant (survives restart, but needs the app open); cloud "routines" run
|
|
47
|
+
server-side. (Sources below.)
|
|
48
|
+
- **oh-my-copilot has no scheduling/timer/cron code today.** The only `setTimeout` uses
|
|
49
|
+
are one-off sleeps and fetch-abort timers — there is no recurring scheduler, no daemon,
|
|
50
|
+
no crontab/launchd/systemd integration anywhere in the repo.
|
|
51
|
+
- **oh-my-copilot already has every building block a scheduler needs**, just not wired
|
|
52
|
+
for time: an imperative CLI dispatch (`src/cli.ts`), a JSON-state "mode" pattern
|
|
53
|
+
(`src/mode-state/*` → `.omp/state/*.json`), agent-spawning code (council spawns
|
|
54
|
+
`copilot --model X -p ... --allow-all-tools`; team spawns agents into tmux panes), and
|
|
55
|
+
a lifecycle hook system.
|
|
56
|
+
- **Two viable designs**: (A) a Claude-Code-parity in-process/detached **Node daemon**
|
|
57
|
+
holding a cron table loaded from JSON; (B) **delegate to the OS scheduler**
|
|
58
|
+
(crontab / launchd / systemd user timers) so jobs survive reboot with no daemon to
|
|
59
|
+
babysit. **Recommended default: a hybrid** — store job definitions as JSON under
|
|
60
|
+
`.omp/state/schedule/`, install one OS-scheduler entry per job that calls
|
|
61
|
+
`omp schedule run --id=<id>`, and put overlap-locking + per-run logging inside that
|
|
62
|
+
run handler.
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
## Detailed Findings
|
|
67
|
+
|
|
68
|
+
### A. How Claude Code's `/schedule` works (external research)
|
|
69
|
+
|
|
70
|
+
Sourced via Perplexity (`sonar-pro`) against the official docs and guides
|
|
71
|
+
(`code.claude.com/docs/en/scheduled-tasks`, `claudefa.st`, MindStudio).
|
|
72
|
+
|
|
73
|
+
There are **three distinct scheduling models** in the Claude ecosystem; only the first
|
|
74
|
+
is the `/schedule`/cron the question refers to:
|
|
75
|
+
|
|
76
|
+
1. **CLI session-scoped tasks** — `/loop`, `/schedule`, and the `CronCreate` /
|
|
77
|
+
`CronList` / `CronDelete` tools.
|
|
78
|
+
- **Mechanism**: an internal timer/wakeup loop *inside the running CLI process*.
|
|
79
|
+
No evidence of system crontab, launchd, or a separate daemon.
|
|
80
|
+
- **Storage**: in-memory only. Docs state "No persistence: restarting Claude Code
|
|
81
|
+
clears all session-scoped tasks." Jobs are not written to disk.
|
|
82
|
+
- **Cron model**: standard **5-field cron expression** (min, hour, dom, month, dow),
|
|
83
|
+
interpreted in **local timezone**. `/loop` accepts natural language
|
|
84
|
+
(`/loop 30m check the build`, `/loop check the build every 2 hours`, default
|
|
85
|
+
every 10 min; units `s/m/h/d`, seconds rounded up to minutes) and converts it to a
|
|
86
|
+
cron string passed to `CronCreate`.
|
|
87
|
+
- **Re-invocation**: when a job is due, the scheduler **injects the stored prompt as a
|
|
88
|
+
new turn into the same conversation** — it does not launch a separate OS process.
|
|
89
|
+
If Claude is mid-response, the job "fires once when idle, not once per missed
|
|
90
|
+
interval" (no catch-up) — classic in-process timer-queue behavior.
|
|
91
|
+
- **Limits/controls**: up to **50 tasks per session**; recurring tasks **auto-expire
|
|
92
|
+
after 3 days**; disable entirely with `CLAUDE_CODE_DISABLE_CRON=1`.
|
|
93
|
+
- **Dependency**: requires the CLI process to stay alive. Close the terminal → jobs
|
|
94
|
+
gone.
|
|
95
|
+
2. **Desktop scheduled tasks** — persistent across app restarts, fire a *fresh session*
|
|
96
|
+
per tick, but require the desktop app to be open. Storage format not publicly
|
|
97
|
+
documented.
|
|
98
|
+
3. **Cloud routines** (MindStudio) — server-side, run without your machine.
|
|
99
|
+
|
|
100
|
+
**Implication for omp**: Claude Code's *CLI* model is the simplest (an in-RAM timer),
|
|
101
|
+
but its biggest limitation — "dies when the session exits" — is exactly what an
|
|
102
|
+
unattended *"check the PR every 15 min"* job needs to avoid. To match the spirit
|
|
103
|
+
("fresh agent session each tick") while being *durable*, omp should lean toward the
|
|
104
|
+
desktop/OS-scheduler behavior, not the ephemeral CLI behavior.
|
|
105
|
+
|
|
106
|
+
### B. oh-my-copilot CLI dispatch — where a subcommand plugs in
|
|
107
|
+
|
|
108
|
+
`src/cli.ts` uses a **single imperative `runCli()`** that parses argv into
|
|
109
|
+
`[group, command, value]` and routes via if/else chains. There is **no formal command
|
|
110
|
+
registry** — each subcommand is a hardcoded branch.
|
|
111
|
+
|
|
112
|
+
Existing subcommand groups (representative): `version`, `list`, `setup`, `doctor`,
|
|
113
|
+
`launch -- <args>`, `team …`, `team api …`, `council "<q>"`, `mcp`,
|
|
114
|
+
`ralph start|status|cancel|tick`, `ultrawork start|status|cancel`,
|
|
115
|
+
`ultraqa start|status|cancel|cycle <verdict>`, `catalog …`, `project inspect`,
|
|
116
|
+
`skill install`, `lint:skills`, `sync:dry-run`, `jira …`.
|
|
117
|
+
|
|
118
|
+
To add a scheduler you would:
|
|
119
|
+
1. Add a `schedule …` branch in `runCli()` (`src/cli.ts`).
|
|
120
|
+
2. Import a handler module (new `src/schedule/*`).
|
|
121
|
+
3. Parse flags with the existing `flagValue()` helper.
|
|
122
|
+
4. Return the standard `CliResult` (`{ ok, exitCode, message, output? }`).
|
|
123
|
+
5. Extend the help text.
|
|
124
|
+
|
|
125
|
+
This is the exact pattern `ralph`/`ultrawork`/`ultraqa` already follow.
|
|
126
|
+
|
|
127
|
+
### C. The mode-state pattern — the closest existing analogue
|
|
128
|
+
|
|
129
|
+
`src/mode-state/{ralph,ultrawork,ultraqa}.ts` + `src/mode-state/paths.ts` implement a
|
|
130
|
+
**persisted-JSON state machine**, which is the nearest thing to a scheduler today:
|
|
131
|
+
|
|
132
|
+
- State is a typed object (`active`, counters, `startedAt` ISO string, `prompt`/
|
|
133
|
+
`objective`/`goal`, `sessionId`, `projectPath`) written to
|
|
134
|
+
`modeStatePath(cwd, "<mode>")` = `{cwd}/.omp/state/<mode>.json`.
|
|
135
|
+
- Functions follow a uniform shape: `start*` (write state), `read*` (load), `cancel*`
|
|
136
|
+
(delete file), and an advance function (`tickRalph`, `recordUltraqaCycle`).
|
|
137
|
+
- **Crucially, none of these have a timer.** "Looping" is driven externally: the caller
|
|
138
|
+
re-invokes `omp ralph tick`, and the `prompt-submit` hook injects the active state as
|
|
139
|
+
context on the *next* user prompt. They are persistence + context-injection patterns,
|
|
140
|
+
**not** event loops.
|
|
141
|
+
|
|
142
|
+
A `schedule.ts` mode would reuse this shape but, unlike the others, needs an *actual
|
|
143
|
+
time trigger* (a daemon or OS scheduler) to advance itself.
|
|
144
|
+
|
|
145
|
+
### D. Agent-spawning mechanics — how omp launches a session today
|
|
146
|
+
|
|
147
|
+
Two existing spawn paths a scheduler could reuse:
|
|
148
|
+
|
|
149
|
+
1. **Council (direct child process)** — `src/council/index.ts:48-50`:
|
|
150
|
+
```ts
|
|
151
|
+
spawn(copilotBin, ["--model", req.model, "-p", req.prompt, "--allow-all-tools"],
|
|
152
|
+
{ stdio: ["ignore", "pipe", "pipe"] })
|
|
153
|
+
```
|
|
154
|
+
with a one-off `setTimeout` kill at `src/council/index.ts:58` to enforce `timeoutMs`.
|
|
155
|
+
**This is the cleanest template for a scheduled, non-interactive, captured run.**
|
|
156
|
+
|
|
157
|
+
2. **Team (tmux panes)** — `src/team/runtime.ts` + `src/team/tmux.ts`. `startTeam()`
|
|
158
|
+
creates `tmux new-session -d … -s omp-team-{name}`, splits panes, then launches the
|
|
159
|
+
worker bin with `tmux send-keys -t {pane} -l -- {bin}` + `C-m`. `resolveWorkerBin`
|
|
160
|
+
picks `claude`/`codex`/`gemini`. This is good for *visible, long-lived* workers but
|
|
161
|
+
heavier than a scheduled tick needs.
|
|
162
|
+
|
|
163
|
+
`monitorTeam()` (`src/team/runtime.ts:211-268`) shows omp's polling idiom: a
|
|
164
|
+
`while` loop with `await sleep(pollInterval)` (default 1000ms, 600s timeout) — **not**
|
|
165
|
+
`setInterval`.
|
|
166
|
+
|
|
167
|
+
### E. Hooks — optional integration surface
|
|
168
|
+
|
|
169
|
+
`hooks/hooks.json` registers 6 events, each `node <plugin>/scripts/<x>.mjs` with a 5s
|
|
170
|
+
timeout, data over stdin as JSON: `SessionStart`, `UserPromptSubmit`, `PreToolUse`,
|
|
171
|
+
`PostToolUse`, `SessionEnd`, `Error`.
|
|
172
|
+
|
|
173
|
+
- `scripts/session-start.mjs` — logs + version check; returns
|
|
174
|
+
`hookSpecificOutput.additionalContext`.
|
|
175
|
+
- `scripts/prompt-submit.mjs` — reads active mode-state files and injects
|
|
176
|
+
"RALPH/ULTRAWORK/ULTRAQA ACTIVE …" continuation context before the prompt.
|
|
177
|
+
|
|
178
|
+
A scheduler could optionally use `session-start` to **surface due/overdue jobs** or even
|
|
179
|
+
opportunistically tick them, but the hooks have a **5s budget** and only fire on session
|
|
180
|
+
events, so they cannot be the primary time trigger.
|
|
181
|
+
|
|
182
|
+
### F. State & path conventions
|
|
183
|
+
|
|
184
|
+
- Project-local root: `{projectRoot}/.omp/state/` (`src/copilot/paths.ts` →
|
|
185
|
+
`resolveCopilotPaths().stateDir`).
|
|
186
|
+
- Mode files: `.omp/state/{ralph,ultrawork,ultraqa}.json`.
|
|
187
|
+
- Team: `.omp/state/team/{name}/{config.json,tasks/*.json,workers/*/…}` with lock files
|
|
188
|
+
(`tasks/{id}.lock`) acquired via exclusive open (`src/team/task-store.ts`).
|
|
189
|
+
- Hook log: `.omp/state/hooks.log` (JSONL).
|
|
190
|
+
|
|
191
|
+
A scheduler fits naturally as `.omp/state/schedule/` (jobs + per-run logs + daemon
|
|
192
|
+
pidfile/locks).
|
|
193
|
+
|
|
194
|
+
### G. Existing timer/cron inventory (grep results)
|
|
195
|
+
|
|
196
|
+
No recurring scheduling exists. Every match is a one-shot:
|
|
197
|
+
|
|
198
|
+
- `src/council/index.ts:58` — `setTimeout` kill (model spawn timeout).
|
|
199
|
+
- `src/team/runtime.ts:32` — `setTimeout` `sleep()` helper.
|
|
200
|
+
- `src/team/tmux.ts:90` — `setTimeout` `sleep()` helper.
|
|
201
|
+
- `scripts/lib/version-check.mjs:35` — `setTimeout` fetch-abort (3s).
|
|
202
|
+
|
|
203
|
+
No `setInterval`, `cron`, `crontab`, `launchd`, `systemd`, `node-cron`, `schedule`.
|
|
204
|
+
|
|
205
|
+
---
|
|
206
|
+
|
|
207
|
+
## Implementation Approaches (forward-looking — the user's explicit ask)
|
|
208
|
+
|
|
209
|
+
External research (Perplexity `sonar-pro`) compared the scheduling/persistence layer
|
|
210
|
+
options for a cross-platform macOS+Linux Node CLI that must survive reboot.
|
|
211
|
+
|
|
212
|
+
### Option A — In-process / detached Node daemon (Claude-Code-parity)
|
|
213
|
+
|
|
214
|
+
A long-lived `omp schedule daemon` process holds an in-memory cron table loaded from
|
|
215
|
+
JSON and spawns agent sessions at each tick (via the council-style `spawn`).
|
|
216
|
+
|
|
217
|
+
- **Scheduling lib**: `croner` or `node-cron` (both lightweight, 5/6-field cron, no
|
|
218
|
+
built-in persistence — you load jobs from your own JSON). `node-schedule` is older;
|
|
219
|
+
`bree` adds a worker pool; `agenda` adds durable persistence **but requires MongoDB**
|
|
220
|
+
(too heavy for a CLI).
|
|
221
|
+
- **Daemonization**: `child_process.spawn(process.execPath, [daemonScript], { detached: true, stdio: 'ignore' }).unref()`.
|
|
222
|
+
- **Lifecycle**: pidfile at `.omp/state/schedule/daemon.pid`; `start`/`stop`/`status`
|
|
223
|
+
via signal-0 liveness checks + `SIGTERM`.
|
|
224
|
+
- **Pros**: cross-platform-identical behavior; full control; matches Claude Code's
|
|
225
|
+
"fires a fresh session" model.
|
|
226
|
+
- **Cons**: **does not survive reboot on its own** — you still need launchd/systemd to
|
|
227
|
+
relaunch the daemon at boot. You own crash recovery, logging, restart policy. More
|
|
228
|
+
code.
|
|
229
|
+
|
|
230
|
+
### Option B — Delegate to the OS scheduler (durable, recommended base)
|
|
231
|
+
|
|
232
|
+
Register one OS-native entry per job; each entry runs `omp schedule run --id=<id>`.
|
|
233
|
+
|
|
234
|
+
- **Linux (systemd --user)**: write `~/.config/systemd/user/omp-<id>.{service,timer}`
|
|
235
|
+
(`Type=oneshot` service; timer `OnCalendar=*:0/15` or `OnUnitActiveSec=15min`,
|
|
236
|
+
`Persistent=true`), then `systemctl --user daemon-reload && systemctl --user enable --now omp-<id>.timer`.
|
|
237
|
+
- **macOS (launchd)**: write `~/Library/LaunchAgents/com.omp.<id>.plist` with
|
|
238
|
+
`ProgramArguments=[omp, schedule, run, --id=<id>]` and `StartInterval`/
|
|
239
|
+
`StartCalendarInterval`, then `launchctl bootout gui/$UID/com.omp.<id> || true` +
|
|
240
|
+
`launchctl bootstrap gui/$UID <plist>`.
|
|
241
|
+
- **Cross-platform fallback (crontab)**: manage a delimited block
|
|
242
|
+
(`# BEGIN omp-jobs … # END omp-jobs`) via `crontab -l` → rewrite → `crontab -`.
|
|
243
|
+
- **Pros**: **survives reboot for free**; battle-tested; no daemon to babysit; each run
|
|
244
|
+
is a clean process.
|
|
245
|
+
- **Cons**: three code paths to template + parse; 1-minute granularity (cron); no
|
|
246
|
+
built-in overlap prevention (must add locking).
|
|
247
|
+
|
|
248
|
+
### Option C (recommended) — Hybrid: JSON job store + OS scheduler + locked run handler
|
|
249
|
+
|
|
250
|
+
Combine B's durability with omp's existing JSON-state idiom:
|
|
251
|
+
|
|
252
|
+
1. **`omp schedule add --id=<id> --cron="*/15 * * * *" --prompt="check PR #42" [--bin copilot] [--cwd .]`**
|
|
253
|
+
→ writes `.omp/state/schedule/jobs/<id>.json` (reusing the mode-state JSON pattern),
|
|
254
|
+
then installs the platform-appropriate OS entry (systemd/launchd/crontab) that calls
|
|
255
|
+
`omp schedule run --id=<id>`.
|
|
256
|
+
2. **`omp schedule run --id=<id>`** (invoked by the OS scheduler):
|
|
257
|
+
- Acquire a per-job **lock file** (`.omp/state/schedule/<id>.lock`, exclusive
|
|
258
|
+
`open(..., 'wx')` — the same technique `task-store.ts` already uses) to **prevent
|
|
259
|
+
overlapping runs**; exit early if locked.
|
|
260
|
+
- Spawn the agent with the **council template**:
|
|
261
|
+
`spawn(bin, ["-p", prompt, "--allow-all-tools"], {...})`.
|
|
262
|
+
- Capture stdout/stderr to `.omp/state/schedule/logs/<id>/<timestamp>.log`; record
|
|
263
|
+
exit code + `lastRunAt`/`lastStatus` back into the job JSON.
|
|
264
|
+
- Release the lock in `finally`.
|
|
265
|
+
3. **`omp schedule list`** → read job JSONs (source of truth) and annotate with the
|
|
266
|
+
installed OS entry's status.
|
|
267
|
+
4. **`omp schedule remove --id=<id>`** → delete the JSON + uninstall the OS entry.
|
|
268
|
+
5. **`omp schedule run-now --id=<id>`** → manual one-off trigger (same run handler).
|
|
269
|
+
|
|
270
|
+
This keeps the scheduler's *state* in omp's familiar `.omp/state/` JSON world while
|
|
271
|
+
delegating the *time trigger* and *reboot persistence* to the OS — and reuses the
|
|
272
|
+
existing exclusive-lock and child-spawn idioms already in the codebase.
|
|
273
|
+
|
|
274
|
+
### Recommendation matrix
|
|
275
|
+
|
|
276
|
+
| Approach | Survives reboot | Daemon to babysit | New deps | Cross-platform code paths | Matches "fresh session/tick" | Effort |
|
|
277
|
+
|---|---|---|---|---|---|---|
|
|
278
|
+
| A: Node daemon (croner/node-cron) | Only if OS-launched at boot | Yes | 1 small lib | 1 (uniform) | Yes | High |
|
|
279
|
+
| B: OS scheduler only | **Yes** | No | None | 3 (systemd/launchd/cron) | Yes | Medium |
|
|
280
|
+
| **C: Hybrid (JSON store + OS scheduler + locked run)** | **Yes** | No | None | 3 (shared run handler) | Yes | Medium |
|
|
281
|
+
| Agenda (Mongo) | Yes | Yes | Mongo | 1 | Yes | High / heavy |
|
|
282
|
+
|
|
283
|
+
**Default recommendation: Option C.** It is the simplest design that is durable across
|
|
284
|
+
reboot, needs no extra runtime dependency, reuses existing omp patterns (mode-state JSON,
|
|
285
|
+
exclusive locks, council-style spawn), and faithfully reproduces the
|
|
286
|
+
"spawn a fresh agent session each tick" behavior the user asked for — without inheriting
|
|
287
|
+
Claude Code CLI's "dies with the session" limitation. Option A's uniform daemon can be
|
|
288
|
+
added later as a Windows / no-systemd fallback if needed.
|
|
289
|
+
|
|
290
|
+
---
|
|
291
|
+
|
|
292
|
+
## Code References
|
|
293
|
+
|
|
294
|
+
- `src/cli.ts` — imperative `runCli()` dispatch; where a `schedule` branch + help entry go.
|
|
295
|
+
- `src/mode-state/ralph.ts`, `ultrawork.ts`, `ultraqa.ts` — JSON-state mode pattern to mirror for `schedule.ts`.
|
|
296
|
+
- `src/mode-state/paths.ts` — `modeStatePath(cwd, mode)` → `.omp/state/<mode>.json`.
|
|
297
|
+
- `src/council/index.ts:48-50` — `spawn(copilotBin, ["--model", m, "-p", prompt, "--allow-all-tools"])`: the run-handler spawn template.
|
|
298
|
+
- `src/council/index.ts:58` — one-off `setTimeout` kill (per-run timeout pattern).
|
|
299
|
+
- `src/team/runtime.ts:211-268` — `monitorTeam()` poll loop (`await sleep()` idiom).
|
|
300
|
+
- `src/team/tmux.ts:55-87` — tmux session/pane/send-keys API (heavier alt spawn path).
|
|
301
|
+
- `src/team/task-store.ts:63-78` — exclusive lock-file acquisition (reuse for overlap prevention).
|
|
302
|
+
- `src/copilot/paths.ts` — `resolveCopilotPaths().stateDir` = `{projectRoot}/.omp/state`.
|
|
303
|
+
- `hooks/hooks.json`, `scripts/prompt-submit.mjs`, `scripts/session-start.mjs` — hook surfaces for optionally surfacing due jobs (5s budget; not a primary trigger).
|
|
304
|
+
|
|
305
|
+
## Architecture Documentation (patterns observed)
|
|
306
|
+
|
|
307
|
+
- **CLI**: single-file imperative dispatch returning a uniform `CliResult`; subcommands
|
|
308
|
+
are hardcoded branches, not a registry.
|
|
309
|
+
- **Mode state**: typed JSON written to `.omp/state/*.json`; `start/read/cancel/advance`
|
|
310
|
+
functions; "looping" is external + hook-injected, never timer-driven.
|
|
311
|
+
- **Agent spawn**: non-interactive `-p … --allow-all-tools` via `child_process.spawn`
|
|
312
|
+
(council) or tmux `send-keys` (team).
|
|
313
|
+
- **Concurrency safety**: exclusive lock files via `open(..., 'wx')`.
|
|
314
|
+
- **State root**: project-local `.omp/state/` for everything persistent.
|
|
315
|
+
|
|
316
|
+
## External References (Perplexity sonar-pro, retrieved 2026-06-01)
|
|
317
|
+
|
|
318
|
+
Claude Code scheduling:
|
|
319
|
+
- https://code.claude.com/docs/en/scheduled-tasks (official)
|
|
320
|
+
- https://claudefa.st/blog/guide/development/scheduled-tasks
|
|
321
|
+
- https://www.mindstudio.ai/blog/claude-code-routines-scheduled-cloud-tasks/
|
|
322
|
+
|
|
323
|
+
Node schedulers / daemon patterns:
|
|
324
|
+
- https://blog.logrocket.com/comparing-best-node-js-schedulers/
|
|
325
|
+
- https://betterstack.com/community/guides/scaling-nodejs/best-nodejs-schedulers/
|
|
326
|
+
- https://blog.appsignal.com/2023/09/06/job-schedulers-for-node-bull-or-agenda.html
|
|
327
|
+
|
|
328
|
+
## Open Questions
|
|
329
|
+
|
|
330
|
+
- **Trigger surface**: should the agent register jobs from *inside* a Copilot session
|
|
331
|
+
(a `/schedule` skill that shells out to `omp schedule add`), from the shell CLI, or
|
|
332
|
+
both? (Repo convention per project memory: prefer `omp` CLI subcommands + hooks over
|
|
333
|
+
MCP tools.)
|
|
334
|
+
- **Job scope**: per-project (`.omp/state/schedule/` in cwd) vs a user-global registry
|
|
335
|
+
(`~/.omp/...`)? OS-scheduler entries are user-global, so cwd must be captured per job.
|
|
336
|
+
- **Which agent bin** is the scheduled default (`copilot` vs `claude`/`codex`/`gemini`)
|
|
337
|
+
and how flags are passed for non-interactive, permission-bypassed runs.
|
|
338
|
+
- **Output delivery**: per-run log files only, or also notify (e.g. write to a team
|
|
339
|
+
mailbox / append to a daily log) when a run finds something actionable?
|
|
340
|
+
- **Windows**: out of scope for v1 (research targeted macOS+Linux); Task Scheduler or the
|
|
341
|
+
Option-A daemon would be the path if needed later.
|
|
342
|
+
|
|
343
|
+
## Related Research
|
|
344
|
+
|
|
345
|
+
- Project memory: `feedback_omp_cli_over_mcp.md` — for omp features prefer `omp` CLI
|
|
346
|
+
subcommands + hooks over MCP tools (informs the trigger-surface question above).
|
package/package.json
CHANGED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { ompRoot } from "./omp-root.mjs";
|
|
4
|
+
|
|
5
|
+
const DAY_FILE_RE = /^\d{4}-\d{2}-\d{2}\.md$/;
|
|
6
|
+
const DEFAULT_NUDGE =
|
|
7
|
+
'Your last session made progress but recorded nothing in the daily log — run `omp daily-log add "<text>"` to capture what changed and any key decisions, so this session has that context.';
|
|
8
|
+
|
|
9
|
+
// A session with at least this many user prompts counts as "did real work".
|
|
10
|
+
const WORK_THRESHOLD = 3;
|
|
11
|
+
|
|
12
|
+
function pad(n) {
|
|
13
|
+
return String(n).padStart(2, "0");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function todayStr(d = new Date()) {
|
|
17
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function dailyDir(directory) {
|
|
21
|
+
return join(ompRoot(directory), ".omp", "memory", "daily");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function dayFile(directory, date = todayStr()) {
|
|
25
|
+
return join(dailyDir(directory), `${date}.md`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Today's Goal section text, or null when unset/empty. */
|
|
29
|
+
export function readTodayGoal(directory) {
|
|
30
|
+
try {
|
|
31
|
+
const p = dayFile(directory);
|
|
32
|
+
if (!existsSync(p)) return null;
|
|
33
|
+
const text = readFileSync(p, "utf8");
|
|
34
|
+
const m = text.match(/##\s+Goal\s*\n([\s\S]*?)(?=\n##\s|\n#\s|$)/i);
|
|
35
|
+
const goal = m ? m[1].trim() : "";
|
|
36
|
+
return goal || null;
|
|
37
|
+
} catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The repo's durable objective from .omp/goal.md, or null when unset. */
|
|
43
|
+
export function readRepoGoal(directory) {
|
|
44
|
+
try {
|
|
45
|
+
const p = join(ompRoot(directory), ".omp", "goal.md");
|
|
46
|
+
if (!existsSync(p)) return null;
|
|
47
|
+
const text = readFileSync(p, "utf8");
|
|
48
|
+
const lines = (text.charCodeAt(0) === 0xfeff ? text.slice(1) : text).split("\n");
|
|
49
|
+
// Strip only our own `# Repo Goal` header so a hand-authored goal isn't lost.
|
|
50
|
+
if (/^#\s+Repo Goal\s*$/i.test(lines[0] ?? "")) lines.shift();
|
|
51
|
+
const goal = lines.join("\n").trim();
|
|
52
|
+
return goal || null;
|
|
53
|
+
} catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Count day-files + total log bullets within the last `days` days (inclusive). */
|
|
59
|
+
export function recentEntryStats(directory, days = 7) {
|
|
60
|
+
try {
|
|
61
|
+
const dir = dailyDir(directory);
|
|
62
|
+
if (!existsSync(dir)) return { files: 0, entries: 0 };
|
|
63
|
+
const cutoff = todayStr(new Date(Date.now() - days * 86400000));
|
|
64
|
+
const files = readdirSync(dir).filter((f) => DAY_FILE_RE.test(f) && f.slice(0, 10) >= cutoff);
|
|
65
|
+
let entries = 0;
|
|
66
|
+
for (const f of files) {
|
|
67
|
+
try {
|
|
68
|
+
entries += (readFileSync(join(dir, f), "utf8").match(/^\s*-\s+/gm) || []).length;
|
|
69
|
+
} catch {
|
|
70
|
+
// skip unreadable day file
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return { files: files.length, entries };
|
|
74
|
+
} catch {
|
|
75
|
+
return { files: 0, entries: 0 };
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function statePath(directory) {
|
|
80
|
+
return join(ompRoot(directory), ".omp", "state", "daily-log.json");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function readState(directory) {
|
|
84
|
+
const fresh = { date: todayStr(), prompts: 0, entriesAtStart: 0, pendingNudge: false, pendingReason: "" };
|
|
85
|
+
try {
|
|
86
|
+
const p = statePath(directory);
|
|
87
|
+
if (!existsSync(p)) return fresh;
|
|
88
|
+
const parsed = JSON.parse(readFileSync(p, "utf8"));
|
|
89
|
+
// Ignore valid-but-wrong JSON (null, number, array) so callers can't throw.
|
|
90
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return { ...fresh, ...parsed };
|
|
91
|
+
} catch {
|
|
92
|
+
// start fresh on read/parse failure
|
|
93
|
+
}
|
|
94
|
+
return fresh;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function writeState(directory, state) {
|
|
98
|
+
try {
|
|
99
|
+
const p = statePath(directory);
|
|
100
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
101
|
+
const tmp = `${p}.tmp.${process.pid}.${Date.now()}`;
|
|
102
|
+
writeFileSync(tmp, JSON.stringify(state, null, 2), "utf8");
|
|
103
|
+
renameSync(tmp, p);
|
|
104
|
+
} catch {
|
|
105
|
+
// best effort
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Called at SessionStart ("a new session continuing from an existing one").
|
|
111
|
+
* Returns a one-line flush nudge when the PRIOR session did work but logged
|
|
112
|
+
* nothing (else ""), then resets the per-session baseline. Never throws.
|
|
113
|
+
*/
|
|
114
|
+
export function startSession(directory) {
|
|
115
|
+
const prior = readState(directory);
|
|
116
|
+
const flush = prior.pendingNudge ? prior.pendingReason || DEFAULT_NUDGE : "";
|
|
117
|
+
writeState(directory, {
|
|
118
|
+
date: todayStr(),
|
|
119
|
+
prompts: 0,
|
|
120
|
+
entriesAtStart: recentEntryStats(directory, 0).entries,
|
|
121
|
+
pendingNudge: false,
|
|
122
|
+
pendingReason: "",
|
|
123
|
+
});
|
|
124
|
+
return flush;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Called at UserPromptSubmit. Increments the per-session work counter.
|
|
129
|
+
* Deliberately does NOT reset on day rollover — startSession owns the baseline,
|
|
130
|
+
* so a session that spans midnight still counts all its prompts as one session.
|
|
131
|
+
*/
|
|
132
|
+
export function recordPrompt(directory) {
|
|
133
|
+
const state = readState(directory);
|
|
134
|
+
state.prompts = (state.prompts || 0) + 1;
|
|
135
|
+
writeState(directory, state);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Called at SessionEnd. Arms a nudge for the NEXT SessionStart when this session
|
|
140
|
+
* did real work (>= WORK_THRESHOLD prompts) but added no daily-log entries.
|
|
141
|
+
* Never throws.
|
|
142
|
+
*/
|
|
143
|
+
export function endSession(directory) {
|
|
144
|
+
const state = readState(directory);
|
|
145
|
+
const sameDay = state.date === todayStr();
|
|
146
|
+
const added = recentEntryStats(directory, 0).entries - (state.entriesAtStart || 0);
|
|
147
|
+
const didWork = (state.prompts || 0) >= WORK_THRESHOLD;
|
|
148
|
+
// Only arm when the session started and ended on the same calendar day. Across
|
|
149
|
+
// a midnight boundary the entriesAtStart baseline refers to a different
|
|
150
|
+
// day-file, so the delta is unreliable — stay quiet rather than risk a
|
|
151
|
+
// spurious nudge.
|
|
152
|
+
state.pendingNudge = sameDay && didWork && added <= 0;
|
|
153
|
+
state.pendingReason = state.pendingNudge ? DEFAULT_NUDGE : "";
|
|
154
|
+
writeState(directory, state);
|
|
155
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { appendFileSync, mkdirSync } from "node:fs";
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
|
+
import { ompRoot } from "./omp-root.mjs";
|
|
3
4
|
|
|
4
5
|
export function printContinue(hookEventName, additionalContext = "") {
|
|
5
6
|
const output = additionalContext
|
|
@@ -17,7 +18,7 @@ export function failOpen() {
|
|
|
17
18
|
}
|
|
18
19
|
|
|
19
20
|
export function appendHookLog(directory, hookName, payload) {
|
|
20
|
-
const logFile = join(directory, ".omp", "state", "hooks.log");
|
|
21
|
+
const logFile = join(ompRoot(directory), ".omp", "state", "hooks.log");
|
|
21
22
|
try {
|
|
22
23
|
mkdirSync(dirname(logFile), { recursive: true });
|
|
23
24
|
appendFileSync(
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
// Plain-Node mirror of src/omp-root.ts for the hooks: walk up from `start` to the
|
|
5
|
+
// nearest project marker (.git, then package.json) so memory is scoped to the
|
|
6
|
+
// real project, not the literal cwd. Falls back to `start` when none is found.
|
|
7
|
+
export function ompRoot(start) {
|
|
8
|
+
let dir = resolve(start);
|
|
9
|
+
while (true) {
|
|
10
|
+
if (existsSync(join(dir, ".git")) || existsSync(join(dir, "package.json"))) return dir;
|
|
11
|
+
const parent = dirname(dir);
|
|
12
|
+
if (parent === dir) return resolve(start);
|
|
13
|
+
dir = parent;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { ompRoot } from "./omp-root.mjs";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Must-follow directives from .omp/project-memory.json. These are injected at
|
|
7
|
+
* SessionStart unconditionally — rules are never relevance-gated, so the agent
|
|
8
|
+
* cannot skip them. (Notes stay on-demand; only directives are pushed.)
|
|
9
|
+
* Best-effort, never throws.
|
|
10
|
+
*/
|
|
11
|
+
export function readDirectives(directory) {
|
|
12
|
+
try {
|
|
13
|
+
const p = join(ompRoot(directory), ".omp", "project-memory.json");
|
|
14
|
+
if (!existsSync(p)) return [];
|
|
15
|
+
const data = JSON.parse(readFileSync(p, "utf8"));
|
|
16
|
+
const list = Array.isArray(data?.directives) ? data.directives : [];
|
|
17
|
+
return list.filter((d) => typeof d === "string" && d.trim() !== "").map((d) => d.trim());
|
|
18
|
+
} catch {
|
|
19
|
+
return [];
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import {
|
|
2
|
+
closeSync,
|
|
3
|
+
existsSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
openSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
readSync,
|
|
8
|
+
readdirSync,
|
|
9
|
+
renameSync,
|
|
10
|
+
statSync,
|
|
11
|
+
writeFileSync,
|
|
12
|
+
} from "node:fs";
|
|
13
|
+
import { dirname, join } from "node:path";
|
|
14
|
+
|
|
15
|
+
function readCursor(cursorPath) {
|
|
16
|
+
if (!existsSync(cursorPath)) return 0;
|
|
17
|
+
try {
|
|
18
|
+
return Number(JSON.parse(readFileSync(cursorPath, "utf8")).bytesRead) || 0;
|
|
19
|
+
} catch {
|
|
20
|
+
return 0;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function advanceCursor(cursorPath, bytes) {
|
|
25
|
+
mkdirSync(dirname(cursorPath), { recursive: true });
|
|
26
|
+
const tmp = `${cursorPath}.tmp.${process.pid}.${Date.now()}`;
|
|
27
|
+
writeFileSync(tmp, JSON.stringify({ bytesRead: bytes }), "utf8");
|
|
28
|
+
renameSync(tmp, cursorPath);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Scan unseen scheduled-run results across all jobs and return a banner string,
|
|
33
|
+
* advancing each job's byte-offset cursor. Bounded by maxEntries (total) and
|
|
34
|
+
* maxBytes (per file) so the SessionStart hook stays within its time budget.
|
|
35
|
+
* Append-only: never rewrites the results JSONL — only advances cursors.
|
|
36
|
+
*/
|
|
37
|
+
export function scanScheduleResults(directory, opts = {}) {
|
|
38
|
+
const maxEntries = opts.maxEntries ?? 10;
|
|
39
|
+
const maxBytes = opts.maxBytes ?? 16_384;
|
|
40
|
+
const resultsDir = join(directory, ".omp", "state", "schedule", "results");
|
|
41
|
+
if (!existsSync(resultsDir)) return "";
|
|
42
|
+
|
|
43
|
+
const lines = [];
|
|
44
|
+
let budget = maxEntries;
|
|
45
|
+
|
|
46
|
+
for (const entry of readdirSync(resultsDir, { withFileTypes: true })) {
|
|
47
|
+
if (budget <= 0) break;
|
|
48
|
+
if (!entry.isFile() || !entry.name.endsWith(".jsonl")) continue;
|
|
49
|
+
const id = entry.name.slice(0, -".jsonl".length);
|
|
50
|
+
const resultsPath = join(resultsDir, entry.name);
|
|
51
|
+
const cursorPath = join(resultsDir, `${id}.offset`);
|
|
52
|
+
|
|
53
|
+
let cursor = readCursor(cursorPath);
|
|
54
|
+
const size = statSync(resultsPath).size;
|
|
55
|
+
if (cursor > size) cursor = 0; // truncated/rotated → re-read from start
|
|
56
|
+
if (cursor >= size) continue;
|
|
57
|
+
|
|
58
|
+
const remaining = Math.min(size - cursor, maxBytes);
|
|
59
|
+
const fd = openSync(resultsPath, "r");
|
|
60
|
+
const buf = Buffer.alloc(remaining);
|
|
61
|
+
try {
|
|
62
|
+
readSync(fd, buf, 0, remaining, cursor);
|
|
63
|
+
} finally {
|
|
64
|
+
closeSync(fd);
|
|
65
|
+
}
|
|
66
|
+
const text = buf.toString("utf8");
|
|
67
|
+
const lastNewline = text.lastIndexOf("\n");
|
|
68
|
+
if (lastNewline === -1) continue;
|
|
69
|
+
|
|
70
|
+
const completeLines = text.slice(0, lastNewline + 1).split("\n").filter(Boolean);
|
|
71
|
+
let consumedBytes = 0;
|
|
72
|
+
for (const line of completeLines) {
|
|
73
|
+
if (budget <= 0) break;
|
|
74
|
+
consumedBytes += Buffer.byteLength(`${line}\n`, "utf8");
|
|
75
|
+
budget -= 1;
|
|
76
|
+
try {
|
|
77
|
+
const r = JSON.parse(line);
|
|
78
|
+
const summary = String(r.summary ?? "").slice(0, 100);
|
|
79
|
+
lines.push(`- ${id} @ ${r.ts}: ${r.status} — ${summary}`);
|
|
80
|
+
} catch {
|
|
81
|
+
// skip unparseable line but still advance past it
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
advanceCursor(cursorPath, cursor + consumedBytes);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return lines.length ? `[SCHEDULE RESULTS]\n${lines.join("\n")}` : "";
|
|
88
|
+
}
|