@tonoid/agent-loop 1.0.0 → 1.1.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/README.md +269 -111
- package/briefs/default/core.md +19 -0
- package/package.json +1 -1
- package/src/adapters/herdr.ts +20 -5
- package/src/cli.ts +4 -4
- package/src/config.ts +19 -1
- package/src/discover.ts +13 -1
- package/src/effects/monitor.ts +14 -0
- package/src/effects/sweep.ts +10 -6
- package/src/engine/monitor.ts +6 -1
- package/src/engine/sweep.ts +63 -5
- package/src/globalstate.ts +25 -0
- package/src/kinds/builder.ts +66 -5
- package/src/kinds/reviewer.ts +16 -2
- package/src/kinds/shared.ts +11 -0
- package/src/overdue.ts +45 -0
- package/src/render.ts +7 -3
- package/src/router/budget.ts +37 -4
- package/src/router/providers/claude.ts +105 -18
- package/src/router/providers/grok.ts +1 -2
- package/src/router/route.ts +136 -13
- package/src/runtime/worker.ts +52 -5
- package/src/types.ts +52 -6
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# agent-loop
|
|
1
|
+
# agent-loop: run coding agents on a schedule, across accounts, unattended
|
|
2
2
|
|
|
3
3
|
Runs autonomous coding agents against your repositories on a schedule,
|
|
4
4
|
unattended, across several provider accounts, without exhausting any of them
|
|
@@ -13,62 +13,77 @@ anywhere to run. If you are looking for something that shells out to an agent
|
|
|
13
13
|
binary and waits on an exit code, this is not it, and [the section
|
|
14
14
|
below](#how-it-uses-herdr) is the fastest way to find that out.
|
|
15
15
|
|
|
16
|
-
Built against **herdr 0.
|
|
16
|
+
Built against **herdr 0.9.0, protocol 22**.
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
[](https://www.npmjs.com/package/@tonoid/agent-loop)
|
|
19
|
+
[](./LICENSE)
|
|
20
|
+
[](https://github.com/tonoid/agent-loop/actions/workflows/ci.yml)
|
|
19
21
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
+
**Who it's for**: anyone already running coding agents by hand who wants them
|
|
23
|
+
to run overnight instead, across more than one provider account, without a
|
|
24
|
+
human deciding which account and without waking up to an exhausted quota.
|
|
22
25
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
| the pane that tab just opened | `pane list`, matched on cwd |
|
|
28
|
-
| the agent itself | `agent start <name> --kind <provider> --pane <id> -- <account startArgs>` |
|
|
29
|
-
| the brief delivered | `agent prompt <pane> <brief> --wait --until working` |
|
|
30
|
-
| whether they are alive | `agent list` for the whole fleet, `agent get <pane>` for one |
|
|
31
|
-
| why it is stuck | `agent read <pane> --source recent-unwrapped --lines <n>` |
|
|
32
|
-
| a composer holding unsent text | `agent send-keys <pane> Enter` |
|
|
33
|
-
| the worker gone | `tab close <tab>` |
|
|
34
|
-
| you, when a worker is blocked | `notification show <title> --body <text>` |
|
|
35
|
-
| a supported herdr | `api schema --json` for the protocol number |
|
|
26
|
+
**Keywords**: unattended Claude Code, autonomous coding agent scheduler, run
|
|
27
|
+
Claude Code on cron, multiple Claude accounts, agent orchestration, git
|
|
28
|
+
worktree per agent, GitHub issue to pull request automation, AI code review
|
|
29
|
+
bot, quota-aware agent router, self-hosted agent runner, herdr.
|
|
36
30
|
|
|
37
|
-
|
|
38
|
-
|
|
31
|
+
> ⚠️ **Disclaimer**: the code in this project was generated with
|
|
32
|
+
> [Claude Code](https://claude.com/claude-code) (Anthropic), then **tested and
|
|
33
|
+
> reviewed manually** by a human. It is provided as is, without warranty.
|
|
34
|
+
> Before pointing it at a repository you care about: read the Safety and
|
|
35
|
+
> Limitations sections below in full, give it its own provider account, and run
|
|
36
|
+
> it without `--live` first, which performs every read and refuses every write.
|
|
37
|
+
> A loop you have never watched dry-run is a loop you do not know. Issues and
|
|
38
|
+
> PRs welcome.
|
|
39
39
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
40
|
+
```
|
|
41
|
+
$ agent-loop status
|
|
42
|
+
|
|
43
|
+
loop: session 1.0% resets in 275m, weekly_all 46.0% resets in 4235m, weekly_scoped 14.0% resets in 4235m
|
|
44
|
+
loop: refresh token expires in 25d
|
|
45
|
+
main: session 8.0% resets in 175m, weekly_all 95.0% resets in 4475m, weekly_scoped 29.0% resets in 4475m
|
|
46
|
+
main: refresh token expires in 13d
|
|
47
|
+
spawns today: 3
|
|
48
|
+
acme: paused nothing
|
|
49
|
+
```
|
|
50
50
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
why the account is decided before the tab exists and can never be changed
|
|
54
|
-
after. It is also why `configEnv` is an account-level setting: `CLAUDE_CONFIG_DIR`
|
|
55
|
-
for Claude Code, `CODEX_HOME` for Codex.
|
|
51
|
+
A tick is one pass over every workspace. This is the whole operator view, and
|
|
52
|
+
what cron writes to the log every two minutes:
|
|
56
53
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
54
|
+
```
|
|
55
|
+
$ agent-loop tick --live
|
|
56
|
+
|
|
57
|
+
SPAWN build b412 on loop (session 13.0% of 90.0 with 156m left -> 1 workers, 0 in flight)
|
|
58
|
+
BUSY digest 20260820-0610 (agent working)
|
|
59
|
+
HOLD review r408 (blocked 12m < 180m)
|
|
60
|
+
DONE digest 20260820-0610 (done() true)
|
|
61
|
+
SWEEP build /home/u/projects/acme/wt-build-b397 (done(b397))
|
|
62
|
+
IDLE review
|
|
63
|
+
TICK acme 2949ms
|
|
64
|
+
TICK total 2987ms
|
|
65
|
+
```
|
|
61
66
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
the rest by name, so a dry tick can survey your entire fleet and is incapable of
|
|
66
|
-
touching it.
|
|
67
|
+
Without `--live` every one of those verbs reads `WOULD spawn`, `WOULD sweep`,
|
|
68
|
+
and nothing is written: the gate in `src/adapters/run.ts` refuses any command
|
|
69
|
+
outside the read allowlist.
|
|
67
70
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
71
|
+
## Install
|
|
72
|
+
|
|
73
|
+
```
|
|
74
|
+
npm i -g @tonoid/agent-loop
|
|
75
|
+
agent-loop kinds # prints the job kinds: enough to prove the install
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
npm is the delivery mechanism, not the runtime. The package ships its
|
|
79
|
+
TypeScript sources rather than a bundle, nothing is compiled at install time,
|
|
80
|
+
and there are no runtime dependencies to fetch: the tarball is the `src/` tree,
|
|
81
|
+
the `briefs/` the engine reads at spawn time, and the docs. The CLI's shebang is
|
|
82
|
+
`#!/usr/bin/env bun`, so **bun has to be on the PATH of whoever runs it**. On a
|
|
83
|
+
box without bun the install succeeds and the first run fails with
|
|
84
|
+
`env: bun: No such file or directory`.
|
|
85
|
+
|
|
86
|
+
To run a checkout as the real command instead, see [Development](#development).
|
|
72
87
|
|
|
73
88
|
## Prerequisites
|
|
74
89
|
|
|
@@ -89,33 +104,23 @@ because a dependency was upgraded is worse than one that says so and carries on.
|
|
|
89
104
|
base is enough and each new worktree needs nothing. `agent-loop check`
|
|
90
105
|
warns for any account missing it.
|
|
91
106
|
|
|
92
|
-
|
|
93
|
-
disabled, which is what makes them autonomous and also means they run
|
|
94
|
-
unsandboxed as you. And all state lives under `~/.agent-loop/`, whatever else
|
|
95
|
-
is on the box.
|
|
96
|
-
|
|
97
|
-
## Install
|
|
107
|
+
## Commands
|
|
98
108
|
|
|
99
109
|
```
|
|
100
|
-
|
|
101
|
-
agent-loop
|
|
110
|
+
agent-loop tick [--workspace <name>] [--live] one pass over every workspace
|
|
111
|
+
agent-loop check [<workspace folder>] validate config and jobs
|
|
112
|
+
agent-loop kinds [<kind>] [--json] a kind's options, or its schema
|
|
113
|
+
agent-loop status [--workspace <name>] accounts, quota, paused jobs
|
|
114
|
+
agent-loop pause|resume [<job>] --workspace <n> stop spawning; sweep and
|
|
115
|
+
monitor keep running
|
|
116
|
+
agent-loop adopt <job> [<key>] --workspace <n> record a spawned mark without
|
|
117
|
+
spawning: the cutover's import
|
|
118
|
+
agent-loop adopt --list --workspace <n> this workspace's marks
|
|
102
119
|
```
|
|
103
120
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
the `briefs/` the engine reads at spawn time, and the docs. The CLI's shebang is
|
|
108
|
-
`#!/usr/bin/env bun`, so **bun has to be on the PATH of whoever runs it**. On a
|
|
109
|
-
box without bun the install succeeds and the first run fails with
|
|
110
|
-
`env: bun: No such file or directory`.
|
|
111
|
-
|
|
112
|
-
From a checkout instead, which is what you want if you are editing it:
|
|
113
|
-
|
|
114
|
-
```
|
|
115
|
-
bun install
|
|
116
|
-
bun link # puts agent-loop in ~/.bun/bin
|
|
117
|
-
agent-loop kinds
|
|
118
|
-
```
|
|
121
|
+
`agent-loop check .` needs no `~/.agent-loop/config.yml`, so a service
|
|
122
|
+
repository can run it in its own CI and catch a broken `job.yml` at the commit
|
|
123
|
+
that broke it.
|
|
119
124
|
|
|
120
125
|
## Configure
|
|
121
126
|
|
|
@@ -220,6 +225,8 @@ label until somebody notices.
|
|
|
220
225
|
kind: routine
|
|
221
226
|
repo: web
|
|
222
227
|
order: 5
|
|
228
|
+
ignoresSpawnCap: true # a scheduled slot is not what the cap is aimed at
|
|
229
|
+
ignoresReserve: true # ...and neither is the reserve, when the slot is the work
|
|
223
230
|
brief: { extends: default/routine, append: ./brief.md }
|
|
224
231
|
options:
|
|
225
232
|
at: ["09:10", "21:10"]
|
|
@@ -227,6 +234,26 @@ options:
|
|
|
227
234
|
doneWhen: ~/reports/{{key}}.md # the artifact that ends the occurrence
|
|
228
235
|
```
|
|
229
236
|
|
|
237
|
+
`ignoresSpawnCap` exempts a job from `maxSpawnsPerDay`, the box-wide runaway
|
|
238
|
+
breaker. Set it on the jobs that spawn a handful of times a day on a schedule:
|
|
239
|
+
the cap is sized for whichever workspace on the box actually loops, and when
|
|
240
|
+
one of those spends the day's budget in an hour, a routine that shares the box
|
|
241
|
+
misses its slot for something it has nothing to do with. Exempt spawns are
|
|
242
|
+
still counted, so they show in the day's total and shorten what the capped jobs
|
|
243
|
+
have left. The account's own `reserve`, `usageMax` and `maxConcurrent` still
|
|
244
|
+
pace an exempt job.
|
|
245
|
+
|
|
246
|
+
`ignoresReserve` is the same argument one layer down. The account `reserve` and
|
|
247
|
+
`usageMax` pace the loop by making it wait for a window to roll, which is the
|
|
248
|
+
right answer for a queue and the wrong one for a clock: a scheduled occurrence
|
|
249
|
+
that waits long enough is not delayed, it is cancelled. On this box a digest job
|
|
250
|
+
was starved on 2263 consecutive ticks across five days, 19 mailings that never
|
|
251
|
+
went out while the loop ticked normally and logged the refusal every two
|
|
252
|
+
minutes. A job with this set spends the reserve rather than skip, and only when
|
|
253
|
+
no account has headroom at all, so the reserve still holds whenever the pacing
|
|
254
|
+
model has any room to give. The account's own worker ceiling still applies:
|
|
255
|
+
running two workers where one fits is a memory decision, not a quota one.
|
|
256
|
+
|
|
230
257
|
Give a routine a `doneWhen` whenever its run produces one. Without it the only
|
|
231
258
|
signal a routine has is its worktree disappearing, and that waits for the next
|
|
232
259
|
slot: a run that finished at 09:38 is nudged at 09:40 and failed at 09:42 for
|
|
@@ -289,6 +316,61 @@ interval against.
|
|
|
289
316
|
Moving existing cron-driven pipelines onto this loop has an order that keeps
|
|
290
317
|
every step reversible: `docs/cutover.md`.
|
|
291
318
|
|
|
319
|
+
## How it uses herdr
|
|
320
|
+
|
|
321
|
+
A tick decides what ought to be running. Everything it then does to a worker,
|
|
322
|
+
it does through the `herdr` CLI:
|
|
323
|
+
|
|
324
|
+
| What the loop needs | herdr call |
|
|
325
|
+
|---|---|
|
|
326
|
+
| the workspace a job's tabs belong in | `workspace list`, matched on the `herdrWorkspace` label |
|
|
327
|
+
| a worker | `tab create --workspace <id> --cwd <worktree> --label <job>-<key> --env <VAR>=<account config dir> --no-focus` |
|
|
328
|
+
| the pane that tab just opened | `pane list`, matched on cwd |
|
|
329
|
+
| the agent itself | `agent start <name> --kind <provider> --pane <id> -- <account startArgs>` |
|
|
330
|
+
| the brief delivered | `agent prompt <pane> <brief> --wait --until working` |
|
|
331
|
+
| whether they are alive | `agent list` for the whole fleet, `agent get <pane>` for one |
|
|
332
|
+
| why it is stuck | `agent read <pane> --source recent-unwrapped --lines <n>` |
|
|
333
|
+
| a composer holding unsent text | `agent send-keys <pane> Enter` |
|
|
334
|
+
| the worker gone | `tab close <tab>` |
|
|
335
|
+
| you, when a worker is blocked | `notification show <title> --body <text>` |
|
|
336
|
+
| a supported herdr | `api schema --json` for the protocol number |
|
|
337
|
+
|
|
338
|
+
Three consequences worth knowing before you read the code, because each one
|
|
339
|
+
shaped it:
|
|
340
|
+
|
|
341
|
+
**`agent_status` is the whole lifecycle.** A worker has no exit code and no pid
|
|
342
|
+
here. `working`, `blocked` and `idle` are the only states the loop can observe,
|
|
343
|
+
and every decision the monitor makes is built from one of those, the pane still
|
|
344
|
+
being listed, and the worktree still being on disk. `blocked` notifies you once
|
|
345
|
+
and then escalates on a timer; an agent that has vanished while its pane is
|
|
346
|
+
still up is restarted exactly once, and one whose pane went with it has failed.
|
|
347
|
+
A status herdr does not recognise becomes `missing`, which deliberately does
|
|
348
|
+
nothing at all: holding cannot kill a live agent or tombstone an item, so a
|
|
349
|
+
herdr that adds or renames a state stalls this loop rather than damaging
|
|
350
|
+
anything with it.
|
|
351
|
+
|
|
352
|
+
**`tab create` is the only verb that accepts `--env`.** That single flag is the
|
|
353
|
+
entire channel by which the router's account choice reaches a worker, which is
|
|
354
|
+
why the account is decided before the tab exists and can never be changed
|
|
355
|
+
after. It is also why `configEnv` is an account-level setting: `CLAUDE_CONFIG_DIR`
|
|
356
|
+
for Claude Code, `CODEX_HOME` for Codex.
|
|
357
|
+
|
|
358
|
+
**herdr ids are not stable.** A pane id changes between rounds of the same
|
|
359
|
+
logical worker, and a workspace id lasts until the herdr server restarts and
|
|
360
|
+
then names somebody else's workspace. Nothing here caches one: the workspace is
|
|
361
|
+
looked up by label and the pane by cwd, on every single spawn.
|
|
362
|
+
|
|
363
|
+
Six of those verbs are reads: `workspace list`, `pane list`, `agent list`,
|
|
364
|
+
`agent get`, `agent read` and `api schema`. Every other one is a write. Without
|
|
365
|
+
`--live` the gate in `src/adapters/run.ts` permits exactly those six and refuses
|
|
366
|
+
the rest by name, so a dry tick can survey your entire fleet and is incapable of
|
|
367
|
+
touching it.
|
|
368
|
+
|
|
369
|
+
`agent-loop check` compares your herdr's protocol number against the one this
|
|
370
|
+
was tested on and warns when they differ. It never refuses to run on a
|
|
371
|
+
mismatch: a newer herdr is usually fine, and a loop that stops dead at 2am
|
|
372
|
+
because a dependency was upgraded is worse than one that says so and carries on.
|
|
373
|
+
|
|
292
374
|
## Workers
|
|
293
375
|
|
|
294
376
|
A worker starts in the account's own config directory, so it inherits whatever
|
|
@@ -315,23 +397,23 @@ answer, the loop sends a `herdr` notification, once per item rather than once
|
|
|
315
397
|
per tick, and only under `--live`. It is the only thing the loop will interrupt
|
|
316
398
|
you for, which is what makes it worth reading.
|
|
317
399
|
|
|
318
|
-
##
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
400
|
+
## Limitations
|
|
401
|
+
|
|
402
|
+
- GitHub only. Labels are the state machine and every read goes through `gh`,
|
|
403
|
+
so there is no GitLab, Gitea or Forgejo support and adding one is not a small
|
|
404
|
+
change.
|
|
405
|
+
- Workers run unsandboxed as you, with permission prompts disabled. That is
|
|
406
|
+
what makes them autonomous. Give the loop its own account and its own
|
|
407
|
+
worktree base, and read `briefs/default/core.md`, which is the contract every
|
|
408
|
+
worker works under.
|
|
409
|
+
- One box. State lives in `~/.agent-loop/`, the daily spawn cap and the
|
|
410
|
+
in-flight count are per machine, and two machines sharing one provider
|
|
411
|
+
account do not know about each other.
|
|
412
|
+
- The `grok` provider is wired but unverified: set `configEnv` on the account
|
|
413
|
+
before routing real work to it. `claude` and `codex` are the tested ones.
|
|
414
|
+
- The herdr protocol is pinned to one tested number. A newer herdr warns and
|
|
415
|
+
runs; a herdr that renames `agent_status` stalls the monitor rather than
|
|
416
|
+
breaking it.
|
|
335
417
|
|
|
336
418
|
## Reading the source
|
|
337
419
|
|
|
@@ -341,38 +423,114 @@ in the repository. The numbers are stable and nothing in the code needs it: the
|
|
|
341
423
|
README and `agent-loop kinds` are the current reference, and every rule the
|
|
342
424
|
spec states is enforced by a test that names it.
|
|
343
425
|
|
|
344
|
-
##
|
|
426
|
+
## Development
|
|
345
427
|
|
|
346
428
|
```
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
bun
|
|
429
|
+
git clone https://github.com/tonoid/agent-loop
|
|
430
|
+
cd agent-loop
|
|
431
|
+
bun install
|
|
432
|
+
bun test
|
|
433
|
+
```
|
|
434
|
+
|
|
435
|
+
The suite is hermetic: no network, no `gh`, no herdr, no writes outside a
|
|
436
|
+
temporary directory. `bun run test:live` opts in to a real herdr and is skipped
|
|
437
|
+
unless `AGENT_LOOP_LIVE_HERDR=1`. CI runs `bun run typecheck` and `bun test` on
|
|
438
|
+
every push and pull request.
|
|
439
|
+
|
|
440
|
+
To run the checkout as the real command, so `git pull` updates the loop:
|
|
441
|
+
|
|
442
|
+
```
|
|
443
|
+
bun link # puts agent-loop in ~/.bun/bin
|
|
350
444
|
```
|
|
351
445
|
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
446
|
+
That is worth knowing before you edit anything: with the checkout linked, cron
|
|
447
|
+
runs your working tree, so an unfinished edit is live within one tick.
|
|
448
|
+
|
|
449
|
+
## Releasing
|
|
355
450
|
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
which keeps a pull request open carrying the next version and the changelog
|
|
359
|
-
entry. Merging it tags the commit, cuts the GitHub release, and publishes to
|
|
360
|
-
npm with provenance. Nothing else is manual, and the only secret involved is
|
|
361
|
-
`NPM_TOKEN` in the repository's Actions secrets. So `feat:` and `fix:` prefixes
|
|
362
|
-
are load-bearing: a commit without one ships no release and appears in no
|
|
363
|
-
changelog.
|
|
451
|
+
Versions come from the commit messages, so land work on `main` with
|
|
452
|
+
[conventional commits](https://www.conventionalcommits.org):
|
|
364
453
|
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
454
|
+
| Prefix | Effect |
|
|
455
|
+
|---|---|
|
|
456
|
+
| `fix: ...` | patch, 1.0.0 to 1.0.1 |
|
|
457
|
+
| `feat: ...` | minor, 1.0.0 to 1.1.0 |
|
|
458
|
+
| `feat!: ...` or a `BREAKING CHANGE:` footer | major, 1.0.0 to 2.0.0 |
|
|
459
|
+
| `docs:`, `chore:`, `test:`, `refactor:` | no release |
|
|
460
|
+
|
|
461
|
+
The `release` workflow runs release-please on every push to `main`. It keeps a
|
|
462
|
+
single release PR open ("chore(main): release X.Y.Z") holding the version bump
|
|
463
|
+
and the new `CHANGELOG.md` section. Nothing publishes while that PR sits there.
|
|
464
|
+
Merging it tags `vX.Y.Z`, cuts the GitHub release, and triggers the publish job,
|
|
465
|
+
which runs the typecheck and the suite and then `npm publish` with the
|
|
466
|
+
`NPM_TOKEN` repository secret.
|
|
467
|
+
|
|
468
|
+
`.release-please-manifest.json` is the source of truth for what ships next, so
|
|
469
|
+
let release-please edit it rather than bumping `package.json` by hand.
|
|
470
|
+
|
|
471
|
+
Published releases carry npm [provenance](https://docs.npmjs.com/generating-provenance-statements),
|
|
472
|
+
which links the tarball to the workflow run that built it. That needs the
|
|
473
|
+
`id-token: write` permission the publish job already declares, and a **public
|
|
474
|
+
repository**: the registry rejects a provenance bundle built from a private one,
|
|
475
|
+
and it does so after the tag and the GitHub release already exist.
|
|
369
476
|
|
|
370
477
|
When herdr changes its protocol, bump `TESTED_PROTOCOL` in
|
|
371
478
|
`src/adapters/herdr.ts` and the version named at the top of this file. A
|
|
372
|
-
mismatch is a warning from `agent-loop check
|
|
373
|
-
|
|
374
|
-
|
|
479
|
+
mismatch is a warning from `agent-loop check`, never a refusal to run, so this
|
|
480
|
+
is bookkeeping: the point is that a warning which is always on is worth nothing.
|
|
481
|
+
|
|
482
|
+
## Contributing
|
|
483
|
+
|
|
484
|
+
Issues and pull requests are welcome:
|
|
485
|
+
[github.com/tonoid/agent-loop/issues](https://github.com/tonoid/agent-loop/issues).
|
|
486
|
+
|
|
487
|
+
A new job kind is the most useful thing you can add. Kinds are a closed
|
|
488
|
+
registry rather than a plugin system (`src/kinds/`): implement `check`, `build`
|
|
489
|
+
and `validateOptions`, register it, and `agent-loop kinds` documents it for
|
|
490
|
+
free. Land work with [conventional commits](https://www.conventionalcommits.org)
|
|
491
|
+
so the release notes write themselves, and run `bun run typecheck && bun test`
|
|
492
|
+
before opening the pull request.
|
|
493
|
+
|
|
494
|
+
## Safety
|
|
495
|
+
|
|
496
|
+
This tool starts AI coding agents and lets them work unattended, so read this
|
|
497
|
+
before pointing it at anything you care about.
|
|
498
|
+
|
|
499
|
+
Workers run with permission prompts disabled, as your user, with no sandbox.
|
|
500
|
+
They create branches, push them, open pull requests, apply labels, leave
|
|
501
|
+
comments, and depending on the job's `mergeMode` they merge. Nobody approves
|
|
502
|
+
any of that at the time it happens: that is the entire point of the loop, and
|
|
503
|
+
it is also the risk. Everything they produce is machine-generated and has had
|
|
504
|
+
no human review unless you review it.
|
|
505
|
+
|
|
506
|
+
They also spend real quota on real accounts. The router holds back whatever
|
|
507
|
+
`reserve` you configure and stops at `maxSpawnsPerDay`, but a misconfigured
|
|
508
|
+
loop can still burn a week's allowance in an afternoon.
|
|
509
|
+
|
|
510
|
+
Give it its own provider account and its own worktree base, keep it away from
|
|
511
|
+
repositories where an unreviewed merge would matter, and treat the pull
|
|
512
|
+
requests it opens as drafts from a fast, tireless colleague who is sometimes
|
|
513
|
+
confidently wrong. The MIT warranty disclaimer is not a formality here.
|
|
514
|
+
|
|
515
|
+
Not affiliated with Anthropic, OpenAI, xAI or GitHub. Claude, Claude Code,
|
|
516
|
+
Codex and Grok are trademarks of their respective owners.
|
|
517
|
+
|
|
518
|
+
## Credits
|
|
519
|
+
|
|
520
|
+
**Created and maintained by [tonoid](https://www.tonoid.com)** - A microstartup
|
|
521
|
+
studio building services like [2sync.com](https://2sync.com) or
|
|
522
|
+
[refurb.me](https://www.refurb.me).
|
|
523
|
+
|
|
524
|
+
| | |
|
|
525
|
+
|---|---|
|
|
526
|
+
| 💼 All tonoïd projects | [tonoid.com/projects](https://www.tonoid.com/projects) |
|
|
527
|
+
| 📬 Contact | hello@tonoid.com |
|
|
528
|
+
| 🐙 GitHub | [github.com/tonoid](https://github.com/tonoid) |
|
|
375
529
|
|
|
376
530
|
## License
|
|
377
531
|
|
|
378
|
-
MIT.
|
|
532
|
+
[MIT](./LICENSE) © [tonoid.com](https://tonoid.com).
|
|
533
|
+
|
|
534
|
+
---
|
|
535
|
+
|
|
536
|
+
**GitHub topics**: `claude-code` `codex` `ai-agents` `autonomous-agents` `agent-orchestration` `herdr` `cron` `scheduler` `git-worktree` `github-automation` `devops` `typescript` `bun` `self-hosted`
|
package/briefs/default/core.md
CHANGED
|
@@ -32,6 +32,25 @@ These are absolute. Breaking one is worse than not finishing the work.
|
|
|
32
32
|
- Never rewrite history that is already pushed, and never `git checkout` a
|
|
33
33
|
branch other than your own.
|
|
34
34
|
|
|
35
|
+
## Absolute paths in shell commands
|
|
36
|
+
|
|
37
|
+
Write every path in a shell command in full, from `/`. Never open a command
|
|
38
|
+
with `cd` into your worktree and then name files relative to it.
|
|
39
|
+
|
|
40
|
+
Nobody is here to answer a prompt. A permission rule that denies reading
|
|
41
|
+
credentials is configured for you, and it holds even under bypass: it is what
|
|
42
|
+
stops an unattended worker reading a secret. When a command's working directory
|
|
43
|
+
cannot be resolved by reading the command itself, which is exactly what a
|
|
44
|
+
leading `cd` does, the paths after it cannot be checked against that rule, and
|
|
45
|
+
the run stops to ask a human who is not there. One such command left a worker
|
|
46
|
+
blocked for the best part of an hour.
|
|
47
|
+
|
|
48
|
+
no cd "{{worktree}}" && grep -n "thing" -A 20 apps/web/lib/x.ts
|
|
49
|
+
yes grep -n "thing" -A 20 {{worktree}}/apps/web/lib/x.ts
|
|
50
|
+
|
|
51
|
+
The same goes for `sed`, `cat`, `head`, `rg` and every other reader. Your
|
|
52
|
+
worktree is at `{{worktree}}`; prefix it and the command runs unattended.
|
|
53
|
+
|
|
35
54
|
## Install first
|
|
36
55
|
|
|
37
56
|
Install the repository's dependencies before you run anything else. A run that
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tonoid/agent-loop",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Schedules autonomous coding agents across provider accounts and runs them as herdr agents in herdr panes.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "melalj <3869766+melalj@users.noreply.github.com>",
|
package/src/adapters/herdr.ts
CHANGED
|
@@ -37,15 +37,31 @@ export type HerdrRead = Herdr
|
|
|
37
37
|
|
|
38
38
|
// The herdr protocol this project was built and tested against. Section 9 of
|
|
39
39
|
// the spec: check it, warn loudly on a mismatch, never refuse to run.
|
|
40
|
-
|
|
40
|
+
//
|
|
41
|
+
// Moved to 22 for herdr 0.9.0, after checking the schema it bundles against
|
|
42
|
+
// every call this adapter makes rather than against the version number alone.
|
|
43
|
+
// AgentStatus still enumerates idle, working, blocked, done and unknown, which
|
|
44
|
+
// is what toStatus below maps; AgentInfo and PaneInfo still require pane_id,
|
|
45
|
+
// tab_id and agent_status, and still carry cwd; WorkspaceInfo still carries
|
|
46
|
+
// workspace_id and label; and tab create, agent start, agent prompt, agent
|
|
47
|
+
// read and notification show all still take the flags and enum values used
|
|
48
|
+
// here. agent list, pane list and workspace list were also read back off a
|
|
49
|
+
// live 0.9.0 and still answer in the envelope the readers here expect.
|
|
50
|
+
export const TESTED_PROTOCOL = 22
|
|
41
51
|
|
|
42
|
-
|
|
52
|
+
// Everything herdr's AgentStatus enum holds except "unknown", which is herdr
|
|
53
|
+
// reporting that it has no status for this agent yet: a starting worker reads
|
|
54
|
+
// that way for a few seconds, so it has to mean "no information" and not "gone".
|
|
55
|
+
const KNOWN: AgentStatus[] = ["working", "blocked", "idle", "done"]
|
|
43
56
|
|
|
44
57
|
function toStatus(s: unknown): AgentStatus {
|
|
45
58
|
return KNOWN.includes(s as AgentStatus) ? (s as AgentStatus) : "missing"
|
|
46
59
|
}
|
|
47
60
|
|
|
48
|
-
|
|
61
|
+
// agent read answers with the terminal text itself rather than a JSON envelope,
|
|
62
|
+
// so it takes the text runner: putting it through the JSON one turns every read
|
|
63
|
+
// into a parse error, and the failure tail a monitor reports into "no transcript".
|
|
64
|
+
export function makeHerdr(run: Runner, runText: (argv: string[]) => Promise<string>): Herdr {
|
|
49
65
|
return {
|
|
50
66
|
async agents() {
|
|
51
67
|
const r = await run(["herdr", "agent", "list"])
|
|
@@ -105,10 +121,9 @@ export function makeHerdr(run: Runner): Herdr {
|
|
|
105
121
|
await run(["herdr", "agent", "send-keys", target, ...keys])
|
|
106
122
|
},
|
|
107
123
|
async agentRead(target, lines) {
|
|
108
|
-
|
|
124
|
+
return runText([
|
|
109
125
|
"herdr", "agent", "read", target, "--source", "recent-unwrapped", "--lines", String(lines),
|
|
110
126
|
])
|
|
111
|
-
return String(r?.result?.output ?? "")
|
|
112
127
|
},
|
|
113
128
|
async agentStatus(target) {
|
|
114
129
|
const r = await run(["herdr", "agent", "get", target])
|
package/src/cli.ts
CHANGED
|
@@ -93,9 +93,9 @@ if (cmd === "check") {
|
|
|
93
93
|
deps: {
|
|
94
94
|
which: (b) => Bun.which(b),
|
|
95
95
|
ghAuth: async () => makeRunners(false).runText(["gh", "auth", "status"]).then(() => true, () => false),
|
|
96
|
-
protocol: () => makeHerdr(makeRunners(false).runJson).protocol(),
|
|
96
|
+
protocol: () => makeHerdr(makeRunners(false).runJson, makeRunners(false).runText).protocol(),
|
|
97
97
|
herdrWorkspaces: async () =>
|
|
98
|
-
(await makeHerdr(makeRunners(false).runJson).workspaces()).map((w) => w.label),
|
|
98
|
+
(await makeHerdr(makeRunners(false).runJson, makeRunners(false).runText).workspaces()).map((w) => w.label),
|
|
99
99
|
readConfig: async (p) => (await Bun.file(p).exists()) ? Bun.file(p).text() : null,
|
|
100
100
|
},
|
|
101
101
|
})
|
|
@@ -187,7 +187,7 @@ const ctxFor = (ws: WorkspaceConfig, marks: ReturnType<typeof openState>) =>
|
|
|
187
187
|
lock: fileLock(),
|
|
188
188
|
gh: makeGh(rj, rt),
|
|
189
189
|
gitFor: (repo) => makeGit(rt, repo),
|
|
190
|
-
herdr: makeHerdr(rj),
|
|
190
|
+
herdr: makeHerdr(rj, rt),
|
|
191
191
|
marks,
|
|
192
192
|
global,
|
|
193
193
|
usageFor: (a, at) => readers[a.provider](a, at),
|
|
@@ -264,7 +264,7 @@ async function memAvailableMb(): Promise<number> {
|
|
|
264
264
|
}
|
|
265
265
|
}
|
|
266
266
|
|
|
267
|
-
const protocol = await makeHerdr(rj).protocol().catch(() => -1)
|
|
267
|
+
const protocol = await makeHerdr(rj, rt).protocol().catch(() => -1)
|
|
268
268
|
if (protocol !== TESTED_PROTOCOL) {
|
|
269
269
|
console.log(`${stamp()} WARN herdr protocol ${protocol}, tested ${TESTED_PROTOCOL}`)
|
|
270
270
|
}
|
package/src/config.ts
CHANGED
|
@@ -12,7 +12,8 @@ const KNOWN_PROVIDERS: Provider[] = ["claude", "codex", "grok"]
|
|
|
12
12
|
// this at the commit that broke it (spec 3.5).
|
|
13
13
|
const CONFIG_KEYS = [
|
|
14
14
|
"accounts", "workspaces", "maxConcurrentPerAccount", "minFreeMb", "usageMax",
|
|
15
|
-
"releaseBefore", "maxSpawnsPerDay", "blockedTimeoutMin", "
|
|
15
|
+
"releaseBefore", "maxSpawnsPerDay", "blockedTimeoutMin", "holdTimeoutMin", "staleAgentMin",
|
|
16
|
+
"workerRunMin", "workerRateSeed",
|
|
16
17
|
]
|
|
17
18
|
const ACCOUNT_KEYS = [
|
|
18
19
|
"id", "provider", "configDir", "reserve", "reservePerWeekday", "weekendWeight", "soleConsumer", "maxConcurrent", "allowWhenUnreadable",
|
|
@@ -28,6 +29,20 @@ export const DEFAULTS = {
|
|
|
28
29
|
releaseBefore: 120,
|
|
29
30
|
maxSpawnsPerDay: 200,
|
|
30
31
|
blockedTimeoutMin: 180,
|
|
32
|
+
// Deliberately the same number as the escalation timeout above: one clock to
|
|
33
|
+
// reason about, and a job whose runs legitimately outlast it is the reason to
|
|
34
|
+
// make this per-job rather than to raise it for the whole box.
|
|
35
|
+
holdTimeoutMin: 180,
|
|
36
|
+
// A finished worker that never exits is invisible to the monitor, because
|
|
37
|
+
// done() has already taken its claim away. Thirty minutes is long enough
|
|
38
|
+
// that a worker still writing its last comment is never cut off, and short
|
|
39
|
+
// enough that a lane does not spend a night starved behind it.
|
|
40
|
+
staleAgentMin: 30,
|
|
41
|
+
// Measured on simo over 2026-08-31..09-05: 154 paired spawn and done lines
|
|
42
|
+
// averaging 16.3 minutes. Rounded up, because the cost of overestimating is
|
|
43
|
+
// one worker fewer and the cost of underestimating is a run that meets its
|
|
44
|
+
// ceiling mid-task.
|
|
45
|
+
workerRunMin: 20,
|
|
31
46
|
workerRateSeed: 0.35,
|
|
32
47
|
} as const
|
|
33
48
|
|
|
@@ -192,6 +207,9 @@ export function parseConfig(text: string): { config: Config; errors: string[] }
|
|
|
192
207
|
releaseBefore: num(raw.releaseBefore, "releaseBefore", DEFAULTS.releaseBefore, errs),
|
|
193
208
|
maxSpawnsPerDay: num(raw.maxSpawnsPerDay, "maxSpawnsPerDay", DEFAULTS.maxSpawnsPerDay, errs),
|
|
194
209
|
blockedTimeoutMin: num(raw.blockedTimeoutMin, "blockedTimeoutMin", DEFAULTS.blockedTimeoutMin, errs),
|
|
210
|
+
holdTimeoutMin: num(raw.holdTimeoutMin, "holdTimeoutMin", DEFAULTS.holdTimeoutMin, errs),
|
|
211
|
+
staleAgentMin: num(raw.staleAgentMin, "staleAgentMin", DEFAULTS.staleAgentMin, errs),
|
|
212
|
+
workerRunMin: num(raw.workerRunMin, "workerRunMin", DEFAULTS.workerRunMin, errs),
|
|
195
213
|
workerRateSeed,
|
|
196
214
|
},
|
|
197
215
|
errors: errs,
|