@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 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.8.0, protocol 19**.
16
+ Built against **herdr 0.9.0, protocol 22**.
17
17
 
18
- ## How it uses herdr
18
+ [![npm](https://img.shields.io/npm/v/@tonoid/agent-loop?logo=npm)](https://www.npmjs.com/package/@tonoid/agent-loop)
19
+ [![License MIT](https://img.shields.io/badge/License-MIT-green.svg)](./LICENSE)
20
+ [![CI](https://github.com/tonoid/agent-loop/actions/workflows/ci.yml/badge.svg)](https://github.com/tonoid/agent-loop/actions/workflows/ci.yml)
19
21
 
20
- A tick decides what ought to be running. Everything it then does to a worker,
21
- it does through the `herdr` CLI:
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
- | What the loop needs | herdr call |
24
- |---|---|
25
- | the workspace a job's tabs belong in | `workspace list`, matched on the `herdrWorkspace` label |
26
- | a worker | `tab create --workspace <id> --cwd <worktree> --label <job>-<key> --env <VAR>=<account config dir> --no-focus` |
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
- Three consequences worth knowing before you read the code, because each one
38
- shaped it:
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
- **`agent_status` is the whole lifecycle.** A worker has no exit code and no pid
41
- here. `working`, `blocked` and `idle` are the only states the loop can observe,
42
- and every decision the monitor makes is built from one of those, the pane still
43
- being listed, and the worktree still being on disk. `blocked` notifies you once
44
- and then escalates on a timer; an agent that has vanished while its pane is
45
- still up is restarted exactly once, and one whose pane went with it has failed.
46
- A status herdr does not recognise becomes `missing`, which deliberately does
47
- nothing at all: holding cannot kill a live agent or tombstone an item, so a
48
- herdr that adds or renames a state stalls this loop rather than damaging
49
- anything with it.
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
- **`tab create` is the only verb that accepts `--env`.** That single flag is the
52
- entire channel by which the router's account choice reaches a worker, which is
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
- **herdr ids are not stable.** A pane id changes between rounds of the same
58
- logical worker, and a workspace id lasts until the herdr server restarts and
59
- then names somebody else's workspace. Nothing here caches one: the workspace is
60
- looked up by label and the pane by cwd, on every single spawn.
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
- Six of those verbs are reads: `workspace list`, `pane list`, `agent list`,
63
- `agent get`, `agent read` and `api schema`. Every other one is a write. Without
64
- `--live` the gate in `src/adapters/run.ts` permits exactly those six and refuses
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
- `agent-loop check` compares your herdr's protocol number against the one this
69
- was tested on and warns when they differ. It never refuses to run on a
70
- mismatch: a newer herdr is usually fine, and a loop that stops dead at 2am
71
- because a dependency was upgraded is worse than one that says so and carries on.
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
- Two things to know before running it. Workers start with permission prompts
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
- npm i -g @tonoid/agent-loop
101
- agent-loop kinds # prints the job kinds: enough to prove the install
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
- npm is the delivery mechanism, not the runtime. The package ships its
105
- TypeScript sources rather than a bundle, nothing is compiled at install time,
106
- and there are no runtime dependencies to fetch: the tarball is the `src/` tree,
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
- ## Commands
319
-
320
- ```
321
- agent-loop tick [--workspace <name>] [--live] one pass over every workspace
322
- agent-loop check [<workspace folder>] validate config and jobs
323
- agent-loop kinds [<kind>] [--json] a kind's options, or its schema
324
- agent-loop status [--workspace <name>] accounts, quota, paused jobs
325
- agent-loop pause|resume [<job>] --workspace <n> stop spawning; sweep and
326
- monitor keep running
327
- agent-loop adopt <job> [<key>] --workspace <n> record a spawned mark without
328
- spawning: the cutover's import
329
- agent-loop adopt --list --workspace <n> this workspace's marks
330
- ```
331
-
332
- `agent-loop check .` needs no `~/.agent-loop/config.yml`, so a service
333
- repository can run it in its own CI and catch a broken `job.yml` at the commit
334
- that broke it.
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
- ## Maintenance
426
+ ## Development
345
427
 
346
428
  ```
347
- bun test # the whole suite: no network, no gh, no herdr
348
- bun run typecheck
349
- bun run test:live # opts in to a real herdr, which has to be running
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
- The suite is hermetic apart from `test:live`, which is skipped unless
353
- `AGENT_LOOP_LIVE_HERDR=1`. CI runs the typecheck and the suite on every push and
354
- pull request.
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
- Releases are driven by the commit messages. Conventional commits landing on
357
- `main` are read by [release-please](https://github.com/googleapis/release-please),
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
- Publishing works despite this being a bun project because bun is never involved
366
- in it. `npm publish` uploads the sources as they are, and the tests that run
367
- before it are the only step that needs bun. What the version number promises is
368
- therefore the source in `src/`, not a build of it.
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` and never a refusal to run, so
373
- this is bookkeeping rather than a gate: the reason to keep it current is that
374
- the warning is worthless once it is always on.
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`
@@ -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.0.0",
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>",
@@ -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
- export const TESTED_PROTOCOL = 19
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
- const KNOWN: AgentStatus[] = ["working", "blocked", "idle"]
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
- export function makeHerdr(run: Runner): Herdr {
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
- const r = await run([
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", "workerRateSeed",
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,