@luckydraw/cumulus 1.0.4 → 1.0.6

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.
Files changed (36) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/gateway/daemon.d.ts.map +1 -1
  3. package/dist/gateway/daemon.js +53 -18
  4. package/dist/gateway/daemon.js.map +1 -1
  5. package/dist/gateway/gateway-agents-mcp.js +133 -0
  6. package/dist/gateway/gateway-agents-mcp.js.map +1 -1
  7. package/dist/gateway/jobs.d.ts +158 -0
  8. package/dist/gateway/jobs.d.ts.map +1 -0
  9. package/dist/gateway/jobs.js +498 -0
  10. package/dist/gateway/jobs.js.map +1 -0
  11. package/dist/gateway/scheduler.d.ts.map +1 -1
  12. package/dist/gateway/scheduler.js +10 -30
  13. package/dist/gateway/scheduler.js.map +1 -1
  14. package/dist/gateway/senders.d.ts +29 -0
  15. package/dist/gateway/senders.d.ts.map +1 -0
  16. package/dist/gateway/senders.js +34 -0
  17. package/dist/gateway/senders.js.map +1 -0
  18. package/dist/gateway/server.d.ts +16 -0
  19. package/dist/gateway/server.d.ts.map +1 -1
  20. package/dist/gateway/server.js +188 -2
  21. package/dist/gateway/server.js.map +1 -1
  22. package/dist/gateway/setup.d.ts.map +1 -1
  23. package/dist/gateway/setup.js +10 -0
  24. package/dist/gateway/setup.js.map +1 -1
  25. package/dist/lib/gateway.d.ts +24 -44
  26. package/dist/lib/gateway.d.ts.map +1 -1
  27. package/dist/lib/gateway.js +47 -97
  28. package/dist/lib/gateway.js.map +1 -1
  29. package/dist/lib/tool-inventory.d.ts.map +1 -1
  30. package/dist/lib/tool-inventory.js +9 -1
  31. package/dist/lib/tool-inventory.js.map +1 -1
  32. package/docs/conditional-continuation.md +102 -147
  33. package/docs/web-app-agent-guide.md +14 -1
  34. package/examples/web-app-agent/README.md +8 -1
  35. package/examples/web-app-agent/thread-config.visitor.example.json +12 -1
  36. package/package.json +1 -1
@@ -1,167 +1,122 @@
1
- # Conditional Thread Continuation — the Watcher-with-Deadline Pattern
1
+ # Conditional Thread Continuation — `run_job`
2
2
 
3
- **Status:** Adopted convention (Karl, 2026-07-26). No gateway code — this pattern composes
4
- two primitives that already exist. A more robust gateway-native mechanism is sketched at
5
- the end for when this outgrows convention.
3
+ **Status:** Gateway-native since task 139 (2026-08-15). Supersedes the
4
+ watcher-with-deadline convention adopted 2026-07-26, which is described at the
5
+ end for anyone reading old threads.
6
6
 
7
7
  ## The problem
8
8
 
9
- A thread kicks off something long-running with an uncertain finish time — a compile, a
10
- test suite, a deploy, a download. The thread should continue **the moment the outcome is
11
- known** (success _or_ failure), not after an arbitrary sleep, and not by burning a
12
- scheduled AI turn every N minutes to poll ("did it finish yet?" cron turns dilute thread
13
- history — the exact failure class tasks 088–092 fixed).
9
+ A thread kicks off something long-running with an uncertain finish time — a
10
+ compile, a test suite, a deploy, a download. The thread should continue **the
11
+ moment the outcome is known** (success _or_ failure), not after an arbitrary
12
+ sleep, and not by burning a scheduled AI turn every N minutes to poll ("did it
13
+ finish yet?" cron turns dilute thread history — the exact failure class tasks
14
+ 088–092 fixed).
14
15
 
15
- ## The two primitives
16
+ ## The answer
16
17
 
17
- ### 1. `POST /api/agents/inject` — immediate continuation
18
+ ```
19
+ run_job("npm run build", label: "build")
20
+ → { started: true, id: "job_a7f3c2d1", logPath: "..." }
21
+ ```
18
22
 
19
- Any process with a valid gateway API key can inject a message into a thread. The thread
20
- starts a new turn immediately; if it's mid-stream, the message queues and delivers at
21
- busy→idle (the task 100 unified queue). Exact shape (verified against
22
- `server.ts handleAgentInject`):
23
+ Then **end the turn**. When the job exits, the gateway starts a new turn on the
24
+ thread carrying the exit code, the working directory, the log path and the tail
25
+ of the output.
23
26
 
24
- ```bash
25
- curl -s -X POST http://localhost:8090/api/agents/inject \
26
- -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
27
- -d '{"targets": "THREAD_NAME", "sender": "build-watcher", "message": "..."}'
28
- ```
27
+ Companions: `list_jobs()`, `job_log(id, tail?)` (works while it is still
28
+ running, so progress can be checked without waiting), `cancel_job(id)`.
29
29
 
30
- - `targets` (string or array) and `sender` and `message` are required. There is no
31
- `target`/`from` — those names will 400.
32
- - Auth: `X-API-Key: <key>` or `Authorization: Bearer <key>`.
33
- - On thundercat, a key is readable from the gateway config:
34
- `KEY=$(jq -r '.apiKeys[0]' "${CUMULUS_DIR:-$HOME/.cumulus}/gateway.config.json")`
35
- Read it through `$CUMULUS_DIR`, never as a literal `~/.cumulus` — a hard-coded path
36
- points a sandboxed run (tests, a second gateway) straight at the production gateway
37
- with the production admin key (task 111).
38
-
39
- ### 2. `schedule_trigger` MCP tool — the time-based floor
40
-
41
- Available in every thread (gateway-agents MCP). `{id, trigger: "once", at: "<ISO datetime>",
42
- message}` fires one injection at a fixed time then auto-removes; `trigger: "cron"` +
43
- `cron` recurs (1-minute resolution). Persisted in thread config — **survives gateway
44
- restarts**, which the watcher does not. Companions: `list_schedules`, `cancel_schedule`.
45
-
46
- ## The pattern
47
-
48
- **Watcher for immediacy, one-shot schedule for the guarantee.** The condition-watcher
49
- doesn't need to live in the gateway: the thread's Claude subprocess runs on the same
50
- machine and can leave a detached process behind.
51
-
52
- ### Step 1 — detach the job with a watcher wrapper
53
-
54
- For a _process-exit_ condition (the common case — build/test/deploy), the wrapper simply
55
- runs the job and fires when it exits, success or error:
56
-
57
- ```bash
58
- KEY=$(jq -r '.apiKeys[0]' "${CUMULUS_DIR:-$HOME/.cumulus}/gateway.config.json")
59
- nohup sh -c '
60
- npm run build > /tmp/build.log 2>&1
61
- rc=$?
62
- curl -s -X POST http://localhost:8090/api/agents/inject \
63
- -H "X-API-Key: '"$KEY"'" -H "Content-Type: application/json" \
64
- -d "{\"targets\":\"cumulus\",\"sender\":\"build-watcher\",
65
- \"message\":\"[watcher] Build finished, exit $rc. Log: /tmp/build.log. First: cancel_schedule(\\\"build-deadline\\\").\"}"
66
- ' >/dev/null 2>&1 &
67
- ```
30
+ ### Write the command plainly
68
31
 
69
- For a condition that isn't a process exit (file appears, port answers, log line shows up),
70
- put an `until` loop before the curl:
32
+ No `&`, no `> log 2>&1`, no `nohup`, no `setsid`. The gateway supplies all of
33
+ that, and adding your own breaks the log capture that the completion report
34
+ reads from. A command ending in `exit N` is fine — the status is captured by an
35
+ `EXIT` trap, not by trailing statements.
71
36
 
72
- ```bash
73
- until <check-command>; do sleep 5; done # e.g. curl -sf http://localhost:3000/health
74
- ```
37
+ ### For a condition that is not a process exit
75
38
 
76
- Guidelines:
77
-
78
- - **`>/dev/null 2>&1 &`** — redirecting _both_ streams is what makes the watcher survive
79
- the Claude turn ending, and it is the only part that does. The turn's subprocess exits
80
- at turn end; anything still holding its inherited stdout/stderr pipes is killed by
81
- SIGPIPE (exit 141) on the next write. `nohup` alone does not save it (it ignores SIGHUP,
82
- the wrong signal, and only auto-redirects when stdout is a terminal — here it is a pipe),
83
- and neither does `setsid` (nothing is signalling the process group). Prefer a real log
84
- file over `/dev/null` when the woken turn will need to diagnose. Surviving the turn is
85
- all this buys — the watcher is still unsupervised and does NOT survive a machine reboot;
86
- that's what step 2 is for.
87
- - **Always report failure too.** The wrapper fires on _outcome_, not on _success_ —
88
- include the exit code and a log path so the woken turn can diagnose without re-running.
89
- - **Tell the woken turn to cancel the deadline** (put it in the message, as above) so the
90
- fallback never double-fires.
91
- - Name the `sender` after the condition (`build-watcher`, `deploy-watcher`) — it prefixes
92
- the injected message and reads clearly in history.
93
- - **Do not reply to the watcher.** Injected messages carry a "Reply using
94
- `send_to_agent(...)`" footer intended for agent-to-agent traffic. A watcher is a script,
95
- not an agent, so replying to it mints an empty thread named after the sender and burns a
96
- turn in it. Measured while verifying task 109: a woken turn replied to `job-watcher` and
97
- a `job-watcher` thread appeared on disk. Act on the report instead.
98
-
99
- ### Step 2 — set the deadline fallback
100
-
101
- In the same turn that spawns the watcher, set a one-shot schedule as the floor:
39
+ Put the wait inside the job. A job _is_ a shell, so the same loop that used to
40
+ go in a watcher goes here, and it inherits supervision and reporting for free:
102
41
 
103
42
  ```
104
- schedule_trigger({
105
- id: "build-deadline",
106
- trigger: "once",
107
- at: "<now + generous timeout, ISO 8601>",
108
- message: "[deadline] The build watcher never reported back. Check /tmp/build.log and whether the process is still running."
109
- })
43
+ run_job("until curl -sf http://localhost:3000/health; do sleep 5; done", label: "wait for health")
110
44
  ```
111
45
 
112
- Why this matters: the watcher is an unsupervised background process. If the machine
113
- reboots, the gateway is restarted mid-job, or the script dies, the thread would otherwise
114
- wait forever. The schedule persists in thread config, so the thread is _guaranteed_ to
115
- wake worst case at the deadline, best case the instant the condition fires.
46
+ ### When NOT to use it
47
+
48
+ A command that finishes within a couple of minutes should just run in the
49
+ foreground. If it runs past the Bash tool's limit the harness backgrounds it and
50
+ notifies the thread, which keeps waiting in-turn — correct for twenty minutes,
51
+ wrong for six hours. `run_job` is for the second case, and for when the thread
52
+ should be free meanwhile.
53
+
54
+ ## Why this is a tool and not a documented recipe
55
+
56
+ Cumulus tried the documented-recipe route three times — task 108 (redirect both
57
+ streams), 109 (watcher + deadline), 116 (the worked one-liner in the content
58
+ store) — and it kept failing in the world, at a standing cost of ~290 prompt
59
+ tokens on every turn of every thread. Three measured reasons:
60
+
61
+ 1. **The cure had to be applied at every layer.** A driver script that redirects
62
+ its own output does not save the children it spawns. @ordimor got two of
63
+ three layers right and lost the job anyway.
64
+ 2. **The watcher could die independently of the job.** It was itself background
65
+ work with its own redirect requirement. Task 109's postmortem found exactly
66
+ that: the watcher never fired.
67
+ 3. **One killer was not curable from the thread side at all.** systemd's default
68
+ `KillMode=control-group` reaps every process in the unit's cgroup when the
69
+ main process exits, and `systemctl reload` makes the gateway exit (Rule #9).
70
+ Neither `setsid`, `nohup`, nor redirecting escapes a cgroup — a cgroup is not
71
+ a session. Every reload killed every background job on the box.
72
+
73
+ `run_job` removes 1 and 2 by construction: the daemon spawns the job with a file
74
+ descriptor rather than a pipe (so SIGPIPE is unreachable, not cured) and outside
75
+ the turn's process tree (so interjecting cannot reach it), and the registry that
76
+ reports completion cannot die independently of the daemon that owns the job.
77
+
78
+ Killer 3 needs `KillMode=process` on the service unit — shipped in the unit
79
+ template generated by `cumulus-gateway setup`. **Without it, jobs still work but
80
+ do not survive a gateway restart.** They are not lost silently either way: on
81
+ startup the registry checks every recorded job, re-adopts the ones still
82
+ running, and reports the rest — with their real exit code if the job got far
83
+ enough to record one, otherwise honestly as `interrupted`, which explicitly says
84
+ the work may or may not have completed.
85
+
86
+ ## `schedule_trigger` is still here, for a different job
87
+
88
+ `schedule_trigger({id, trigger: "once"|"cron", at|cron, message})` injects a
89
+ message into the thread at a **time**. That is deferred reminders, drip
90
+ sequences and genuinely periodic work — not condition-waiting.
91
+
92
+ It is no longer needed as a deadline backstop for background work: that leg
93
+ existed because an unsupervised watcher could vanish, and the registry cannot.
116
94
 
117
- ### Step 3 on wake, cancel the other leg
95
+ ### Anti-pattern: cron polling turns
118
96
 
119
- - Watcher fired → first action: `cancel_schedule("build-deadline")`.
120
- - Deadline fired check the log/process manually; kill any orphaned watcher
121
- (`pkill -f build-watcher-marker` if you tagged the command line).
97
+ Do **not** use `trigger: "cron"` to poll a condition every N minutes. Each poll
98
+ burns a full AI turn and appends "checked, nothing yet" noise to thread history,
99
+ degrading retrieval for every future turn.
122
100
 
123
- ### Anti-pattern: cron polling turns
101
+ ## Superseded: the watcher-with-deadline convention (2026-07-26 → 2026-08-15)
102
+
103
+ Threads used to compose two primitives by hand: a detached `curl` to
104
+ `POST /api/agents/inject` wrapped around the job, plus a one-shot
105
+ `schedule_trigger` as the floor in case the watcher died. Recorded here only so
106
+ older thread history reads coherently — do not write new ones.
107
+
108
+ Its four known limitations are what `run_job` was built to remove:
109
+
110
+ | Limitation of the convention | Status |
111
+ | ------------------------------------------------------------------------- | ------------------------------------------------------------- |
112
+ | No supervision — a bare background process, nothing restarts or tracks it | Fixed: the registry owns the job |
113
+ | No visibility — no way to enumerate or cancel a pending watcher | Fixed: `list_jobs` / `job_log` / `cancel_job` |
114
+ | Needed a gateway API key in the script's command line | Fixed: no key involved at all |
115
+ | Reboot amnesia — schedules survived restarts, watchers did not | Fixed: adoption on startup, or an honest `interrupted` report |
124
116
 
125
- Do **not** use `schedule_trigger` with `trigger: "cron"` to poll a condition every N
126
- minutes. Each poll burns a full AI turn and appends "checked, nothing yet" noise to
127
- thread history, degrading retrieval quality for every future turn. Cron is for genuinely
128
- periodic work (weekly reports, drip follow-ups), not condition-waiting.
129
-
130
- ## Known limitations of this pattern
131
-
132
- These are accepted trade-offs of the convention, and the reason a robust version may be
133
- worth building later:
134
-
135
- 1. **No supervision** — the watcher is a bare background process; nothing restarts it.
136
- The deadline fallback bounds the damage but adds latency in the failure case.
137
- 2. **No visibility** — there is no `list_watches`; a pending watcher is invisible to the
138
- thread, the user, and the dashboard. You can't enumerate or cancel what you can't see.
139
- 3. **Key in the script** — the watcher needs a gateway API key in its environment/command
140
- line. On thundercat (single-user box) this is acceptable; it would not be under
141
- namespace-scoped multi-tenant use (task 097 P7).
142
- 4. **Reboot amnesia** — schedules survive restarts, watchers don't. After a reboot only
143
- the deadline leg remains.
144
-
145
- ## Later: the robust version (`await_condition`, gateway-native)
146
-
147
- If condition-waiting becomes a recurring pattern, the honest fix is a first-class
148
- gateway watcher living beside `scheduler.ts` (per Rule #2 it needs a task doc before any
149
- implementation). Sketch:
150
-
151
- - **Tool:** `await_condition({id, check, interval, timeout, message})` — the gateway
152
- polls `check` (a shell predicate) every `interval` seconds and injects `message` the
153
- moment it exits 0. `timeout` fires a "condition never came true" injection instead of
154
- waiting forever.
155
- - **Persistence:** watches stored in thread config like schedules → survive gateway
156
- restarts and machine reboots (poll loop resumes on startup). This alone removes
157
- limitations 1, 2, and 4.
158
- - **Visibility:** `list_watches` / `cancel_watch` companions, same shape as
159
- `list_schedules` / `cancel_schedule`; dashboard can surface pending watches.
160
- - **Reuse:** fires through the same `sendMessage` path as the scheduler, so queueing,
161
- busy→idle drain, and history semantics are identical to today's injections.
162
- - **Design question to settle in the task doc:** the predicate is the gateway executing a
163
- configured shell command. Fine for admin threads on a single-user box; under P7
164
- namespace scoping it needs an allowlist or per-namespace opt-in before scoped keys can
165
- create watches.
166
-
167
- Until then: watcher + deadline, as above.
117
+ The key point was also a real hazard rather than a theoretical one: the recipe
118
+ read `apiKeys[0]` the gateway's **admin** key and because it was seeded into
119
+ every thread's content store as topic-free boilerplate, it surfaced on
120
+ unrelated queries and taught an app's visitor-facing model to invent HTTP calls
121
+ against the gateway (task 134). `run_job` needs no credential, and the seeded
122
+ recipe has been deleted.
@@ -152,7 +152,7 @@ myapp.config.json (only if the -v file is absent)
152
152
  "claudeModel": "claude-haiku-4-5",
153
153
  "effort": "medium",
154
154
  "alwaysInclude": ["docs/myapp-system-prompt.md"],
155
- "allowedTools": ["Read", "read_file", "search_content", "retrieve_content", "search_history"],
155
+ "allowedTools": ["read_file", "search_content", "retrieve_content", "search_history"],
156
156
  "disallowedTools": ["AskUserQuestion"]
157
157
  }
158
158
  ```
@@ -163,6 +163,19 @@ myapp.config.json (only if the -v file is absent)
163
163
  - `allowedTools` — **the only tools a visitor turn may use.** See below; this is the most important line in the visitor file.
164
164
  - `disallowedTools` — applied on top of the allowlist, and it can only subtract. Keep `AskUserQuestion` here: there is no operator on the other end of a visitor turn, so a question hangs.
165
165
 
166
+ #### `read_file` yes, `Read` no — they are not interchangeable
167
+
168
+ The list above admits `read_file` and leaves out the built-in `Read`, and the omission is
169
+ load-bearing. `Read` is the Claude CLI's own tool; cumulus has no root hook into it, so
170
+ allowing it means a visitor can ask for **any file the gateway user can read** — including
171
+ `~/.cumulus/gateway.config.json`, which holds your admin API key and every provider
172
+ credential. `read_file` is cumulus's own tool, and while `Read` is denied it is **confined
173
+ to that thread's `projectDir`**.
174
+
175
+ Denying `Read` is what switches the confinement on, so the two lines work as a pair. If you
176
+ add `Read` back you give up the confinement entirely and the allowlist no longer bounds the
177
+ filesystem at all; widen `projectDir` instead.
178
+
166
179
  #### `allowedTools` is deny-by-default, and that is the whole point
167
180
 
168
181
  Omit it and the thread is unrestricted — it gets the same harness as your maintainer
@@ -309,7 +309,7 @@ shell, file writes, sub-agent spawning, and the ability to message your other
309
309
  threads — handed to anonymous traffic.
310
310
 
311
311
  ```json
312
- "allowedTools": ["Read", "read_file", "search_content", "retrieve_content", "search_history"]
312
+ "allowedTools": ["read_file", "search_content", "retrieve_content", "search_history"]
313
313
  ```
314
314
 
315
315
  It is **deny-by-default**: anything not named is refused, _including tools a future
@@ -325,6 +325,13 @@ Two things people expect wrongly:
325
325
  this namespace.
326
326
  - **A typo denies.** An entry matching nothing is inert, and inertness is a denial,
327
327
  so a misspelled tool silently disappears. Check the journal after a deploy.
328
+ - **`read_file` and `Read` are not interchangeable.** `Read` is the Claude CLI's own
329
+ tool and cumulus has no root hook into it — allow it and a visitor can ask for any
330
+ file the gateway user can read, including `~/.cumulus/gateway.config.json` and the
331
+ credentials in it. `read_file` is cumulus's own, and while `Read` is denied it is
332
+ confined to that thread's `projectDir`. Denying `Read` is what switches the
333
+ confinement on, so the two work as a pair; widen `projectDir` rather than adding
334
+ `Read` back.
328
335
 
329
336
  Setting it also shrinks that thread's system prompt: sections about tools it can't
330
337
  reach (background work, scheduling, inter-agent messaging) are dropped, because dead
@@ -55,9 +55,20 @@
55
55
  "visitor could message your maintainer threads), schedule_trigger (a visitor",
56
56
  "could arm turns that fire long after they leave), forget_content (destructive).",
57
57
  "",
58
+ "READING FILES — WHY 'read_file' IS HERE AND 'Read' IS NOT.",
59
+ "They are not interchangeable. 'Read' is the Claude CLI's own built-in tool and",
60
+ "cumulus has no root hook into it: allow it and a visitor can ask for any file",
61
+ "the gateway user can read, including ~/.cumulus/gateway.config.json, which",
62
+ "holds your API keys and every provider credential. 'read_file' is cumulus's",
63
+ "own tool, and while Read is denied it is CONFINED to this thread's projectDir",
64
+ "— so a visitor can reach your app's files and nothing above them. Denying Read",
65
+ "is what switches that confinement on, so the two lines work as a pair. If you",
66
+ "add 'Read' back you give up the confinement entirely; prefer widening",
67
+ "projectDir instead.",
68
+ "",
58
69
  "Omit this key entirely to leave a thread unrestricted."
59
70
  ],
60
- "allowedTools": ["Read", "read_file", "search_content", "retrieve_content", "search_history"],
71
+ "allowedTools": ["read_file", "search_content", "retrieve_content", "search_history"],
61
72
 
62
73
  "_disallowedTools": [
63
74
  "Applied ON TOP of the allowlist — it can subtract, never add. Redundant while",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@luckydraw/cumulus",
3
- "version": "1.0.4",
3
+ "version": "1.0.6",
4
4
  "description": "RLM-based CLI chat wrapper for Claude with external history context management",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",