@luckydraw/cumulus 1.0.3 → 1.0.5
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/CHANGELOG.md +19 -0
- package/dist/gateway/daemon.d.ts.map +1 -1
- package/dist/gateway/daemon.js +53 -18
- package/dist/gateway/daemon.js.map +1 -1
- package/dist/gateway/gateway-agents-mcp.js +133 -0
- package/dist/gateway/gateway-agents-mcp.js.map +1 -1
- package/dist/gateway/jobs.d.ts +158 -0
- package/dist/gateway/jobs.d.ts.map +1 -0
- package/dist/gateway/jobs.js +497 -0
- package/dist/gateway/jobs.js.map +1 -0
- package/dist/gateway/scheduler.d.ts.map +1 -1
- package/dist/gateway/scheduler.js +9 -30
- package/dist/gateway/scheduler.js.map +1 -1
- package/dist/gateway/server.d.ts +16 -0
- package/dist/gateway/server.d.ts.map +1 -1
- package/dist/gateway/server.js +183 -2
- package/dist/gateway/server.js.map +1 -1
- package/dist/gateway/setup.d.ts.map +1 -1
- package/dist/gateway/setup.js +10 -0
- package/dist/gateway/setup.js.map +1 -1
- package/dist/lib/config.d.ts +11 -0
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/config.js +21 -0
- package/dist/lib/config.js.map +1 -1
- package/dist/lib/gateway.d.ts +35 -45
- package/dist/lib/gateway.d.ts.map +1 -1
- package/dist/lib/gateway.js +98 -117
- package/dist/lib/gateway.js.map +1 -1
- package/dist/lib/tool-inventory.d.ts +140 -0
- package/dist/lib/tool-inventory.d.ts.map +1 -0
- package/dist/lib/tool-inventory.js +317 -0
- package/dist/lib/tool-inventory.js.map +1 -0
- package/docs/conditional-continuation.md +102 -147
- package/docs/web-app-agent-guide.md +74 -3
- package/examples/web-app-agent/README.md +40 -8
- package/examples/web-app-agent/thread-config.visitor.example.json +30 -2
- package/package.json +1 -1
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool inventory + allowlist compilation (task 137).
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS
|
|
5
|
+
* A thread that should only be able to do a few things (a public web-app visitor
|
|
6
|
+
* panel) previously had to be configured with a DENYLIST — @cdda hand-enumerated 47
|
|
7
|
+
* tool names to get a safe surface. That inverts maintenance onto every app author:
|
|
8
|
+
* any tool cumulus or the Claude CLI adds in a future release is silently granted to
|
|
9
|
+
* every visitor on every deployed app until each author notices and edits their list.
|
|
10
|
+
*
|
|
11
|
+
* The fix is an allowlist. But `--allowedTools` on the Claude CLI is an AUTO-APPROVE
|
|
12
|
+
* list, not a restriction — measured under three permission modes, `--allowedTools Glob`
|
|
13
|
+
* failed to block Bash every time, and cumulus spawns with `--permission-mode
|
|
14
|
+
* bypassPermissions` where an auto-approve list is a guaranteed no-op. `--disallowedTools`
|
|
15
|
+
* DOES restrict there (also measured). So an allowlist has to be COMPILED into a denylist:
|
|
16
|
+
*
|
|
17
|
+
* denylist = (everything that exists) − (what this thread may use)
|
|
18
|
+
*
|
|
19
|
+
* which is why this module has to know the full inventory.
|
|
20
|
+
*
|
|
21
|
+
* WHERE THE INVENTORY COMES FROM (three sources, because no single one is complete)
|
|
22
|
+
* 1. Built-in CLI tools — read from the `system:init` stream event, which carries the
|
|
23
|
+
* CLI's own `tools[]`. Probed once per process (see probeBuiltinTools).
|
|
24
|
+
* 2. cumulus's own MCP tools — derived from the definitions cumulus itself ships.
|
|
25
|
+
* 3. Namespace `extraMcpServers` — NOT knowable here, and deliberately not closed over.
|
|
26
|
+
* Those are the app author's own shim tools, granted to that namespace on purpose.
|
|
27
|
+
*
|
|
28
|
+
* The seed list is a FLOOR, not merely a fallback: the inventory is always the union of
|
|
29
|
+
* the probe and the seed, so a probe that returns a short list (different cwd, different
|
|
30
|
+
* plugin set, a future CLI that changes the event shape) can never quietly widen what a
|
|
31
|
+
* constrained thread is allowed to reach.
|
|
32
|
+
*/
|
|
33
|
+
import { spawn } from 'child_process';
|
|
34
|
+
import * as os from 'os';
|
|
35
|
+
import { getToolDefinitions } from '../mcp/tool-handler.js';
|
|
36
|
+
/**
|
|
37
|
+
* Built-in Claude CLI tools, as reported by `system:init` on CLI 2.1.217.
|
|
38
|
+
*
|
|
39
|
+
* This is a FLOOR for the inventory, not a fallback — see the module comment. Adding a
|
|
40
|
+
* name here can only ever deny more on an allowlisted thread; it has no effect on a
|
|
41
|
+
* thread without `allowedTools`. Safe to update from a probe of a newer CLI.
|
|
42
|
+
*/
|
|
43
|
+
export const SEED_BUILTIN_TOOLS = [
|
|
44
|
+
'Bash',
|
|
45
|
+
'CronCreate',
|
|
46
|
+
'CronDelete',
|
|
47
|
+
'CronList',
|
|
48
|
+
'DesignSync',
|
|
49
|
+
'Edit',
|
|
50
|
+
'EnterWorktree',
|
|
51
|
+
'ExitWorktree',
|
|
52
|
+
'Monitor',
|
|
53
|
+
'NotebookEdit',
|
|
54
|
+
'PushNotification',
|
|
55
|
+
'Read',
|
|
56
|
+
'RemoteTrigger',
|
|
57
|
+
'ReportFindings',
|
|
58
|
+
'ScheduleWakeup',
|
|
59
|
+
'SendMessage',
|
|
60
|
+
'Skill',
|
|
61
|
+
'Task',
|
|
62
|
+
'TaskCreate',
|
|
63
|
+
'TaskGet',
|
|
64
|
+
'TaskList',
|
|
65
|
+
'TaskOutput',
|
|
66
|
+
'TaskStop',
|
|
67
|
+
'TaskUpdate',
|
|
68
|
+
'ToolSearch',
|
|
69
|
+
'WebFetch',
|
|
70
|
+
'WebSearch',
|
|
71
|
+
'Workflow',
|
|
72
|
+
'Write',
|
|
73
|
+
];
|
|
74
|
+
/**
|
|
75
|
+
* Tools served by the `gateway-agents` MCP server.
|
|
76
|
+
*
|
|
77
|
+
* Duplicated here rather than imported because `gateway-agents-mcp.ts` calls `main()`
|
|
78
|
+
* at top level — it is a stdio binary, and importing it would start a server. A source
|
|
79
|
+
* tripwire test asserts this list matches the tool definitions in that file, so the two
|
|
80
|
+
* cannot drift (same shape as task 132's artifact tripwire).
|
|
81
|
+
*
|
|
82
|
+
* `broadcast` is absent on purpose: it is deliberately not served (task 135).
|
|
83
|
+
*/
|
|
84
|
+
export const GATEWAY_AGENTS_TOOL_NAMES = [
|
|
85
|
+
'cancel_job',
|
|
86
|
+
'cancel_schedule',
|
|
87
|
+
'create_plastic_app',
|
|
88
|
+
'job_log',
|
|
89
|
+
'list_agents',
|
|
90
|
+
'list_emails',
|
|
91
|
+
'list_jobs',
|
|
92
|
+
'list_schedules',
|
|
93
|
+
'notify_user',
|
|
94
|
+
'run_job',
|
|
95
|
+
'schedule_trigger',
|
|
96
|
+
'send_email',
|
|
97
|
+
'send_to_agent',
|
|
98
|
+
'update_pipeline',
|
|
99
|
+
'upload_media',
|
|
100
|
+
];
|
|
101
|
+
/** MCP server keys cumulus attaches itself (see generateMcpConfig). */
|
|
102
|
+
const CUMULUS_HISTORY_SERVER = 'cumulus-history';
|
|
103
|
+
const GATEWAY_AGENTS_SERVER = 'gateway-agents';
|
|
104
|
+
/** How long to wait for the CLI to emit its init event before giving up on the probe. */
|
|
105
|
+
const PROBE_TIMEOUT_MS = 15_000;
|
|
106
|
+
/** Fully-qualified names of the MCP tools cumulus ships itself. */
|
|
107
|
+
export function cumulusMcpToolNames() {
|
|
108
|
+
const history = getToolDefinitions().map(t => `mcp__${CUMULUS_HISTORY_SERVER}__${t.name}`);
|
|
109
|
+
const agents = GATEWAY_AGENTS_TOOL_NAMES.map(n => `mcp__${GATEWAY_AGENTS_SERVER}__${n}`);
|
|
110
|
+
return [...history, ...agents];
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Ask the Claude CLI what built-in tools it has, by reading the `tools[]` array off its
|
|
114
|
+
* `system:init` stream event and killing the subprocess immediately.
|
|
115
|
+
*
|
|
116
|
+
* Measured cost: init arrives ~871ms in and is the ONLY event before the kill — no
|
|
117
|
+
* assistant output, no result event, so nothing comes back from the model. A prompt is
|
|
118
|
+
* required: with stdin closed the CLI exits before emitting init (0 lines), and merely
|
|
119
|
+
* holding stdin open emits nothing at all (20s). Hence the one-character `x`.
|
|
120
|
+
*
|
|
121
|
+
* Runs in a neutral cwd so a project's own settings cannot shape the answer, and the
|
|
122
|
+
* result is unioned with the seed floor by the caller either way.
|
|
123
|
+
*
|
|
124
|
+
* Rejects on timeout, spawn failure, or a missing/empty `tools[]`. Never throws
|
|
125
|
+
* synchronously; the caller treats any rejection as "use the floor".
|
|
126
|
+
*
|
|
127
|
+
* `claudePath` is supplied by the caller, which already has it resolved. Taking it as a
|
|
128
|
+
* parameter rather than importing `resolveClaudeCli` avoids a module cycle: that function
|
|
129
|
+
* lives in gateway.ts, which imports this module.
|
|
130
|
+
*/
|
|
131
|
+
export function probeBuiltinTools(claudePath) {
|
|
132
|
+
return new Promise((resolve, reject) => {
|
|
133
|
+
const bin = claudePath || 'claude';
|
|
134
|
+
// Same env hygiene as the real spawn: CLAUDE* vars would leak the parent session.
|
|
135
|
+
const cleanEnv = Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith('CLAUDE') && key !== 'CLAUDECODE'));
|
|
136
|
+
let child;
|
|
137
|
+
try {
|
|
138
|
+
child = spawn(bin, [
|
|
139
|
+
'--print',
|
|
140
|
+
'--output-format',
|
|
141
|
+
'stream-json',
|
|
142
|
+
'--verbose',
|
|
143
|
+
'--permission-mode',
|
|
144
|
+
'bypassPermissions',
|
|
145
|
+
'x',
|
|
146
|
+
], { stdio: ['ignore', 'pipe', 'ignore'], cwd: os.tmpdir(), env: cleanEnv });
|
|
147
|
+
}
|
|
148
|
+
catch (err) {
|
|
149
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
let settled = false;
|
|
153
|
+
let buf = '';
|
|
154
|
+
const finish = (fn) => {
|
|
155
|
+
if (settled)
|
|
156
|
+
return;
|
|
157
|
+
settled = true;
|
|
158
|
+
clearTimeout(timer);
|
|
159
|
+
try {
|
|
160
|
+
child.kill('SIGKILL');
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
// already gone
|
|
164
|
+
}
|
|
165
|
+
fn();
|
|
166
|
+
};
|
|
167
|
+
const timer = setTimeout(() => finish(() => reject(new Error('tool inventory probe timed out'))), PROBE_TIMEOUT_MS);
|
|
168
|
+
// Don't hold the event loop open on a probe nobody is waiting for.
|
|
169
|
+
timer.unref?.();
|
|
170
|
+
child.stdout?.on('data', (chunk) => {
|
|
171
|
+
buf += chunk.toString();
|
|
172
|
+
let nl;
|
|
173
|
+
while ((nl = buf.indexOf('\n')) >= 0) {
|
|
174
|
+
const line = buf.slice(0, nl);
|
|
175
|
+
buf = buf.slice(nl + 1);
|
|
176
|
+
if (!line.trim())
|
|
177
|
+
continue;
|
|
178
|
+
let parsed;
|
|
179
|
+
try {
|
|
180
|
+
parsed = JSON.parse(line);
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
if (parsed.type !== 'system' || parsed.subtype !== 'init')
|
|
186
|
+
continue;
|
|
187
|
+
const tools = Array.isArray(parsed.tools)
|
|
188
|
+
? parsed.tools.filter((t) => typeof t === 'string')
|
|
189
|
+
: [];
|
|
190
|
+
if (tools.length === 0) {
|
|
191
|
+
finish(() => reject(new Error('init event carried no tools')));
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
finish(() => resolve(tools));
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
child.on('error', err => finish(() => reject(err)));
|
|
199
|
+
// 'close', not 'exit': close fires after stdio has drained, so a CLI that prints init
|
|
200
|
+
// and exits immediately still resolves. On 'exit' the reject could win the race.
|
|
201
|
+
child.on('close', () => finish(() => reject(new Error('CLI exited before emitting init'))));
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
/** Memoized so the probe runs at most once per gateway process. */
|
|
205
|
+
let inventoryPromise = null;
|
|
206
|
+
/**
|
|
207
|
+
* The full set of tool names a spawned turn could otherwise reach.
|
|
208
|
+
*
|
|
209
|
+
* Deliberately NOT threaded through `MessagePipelineOptions`: task 113 is the recorded
|
|
210
|
+
* case of a field added to one of four spawn paths and silently doing nothing in the
|
|
211
|
+
* other three, and here a miss would fail OPEN. Module-scope memoization means the single
|
|
212
|
+
* args-assembly site is the only consumer and it cannot be half-wired.
|
|
213
|
+
*/
|
|
214
|
+
export function getToolInventory(claudePath) {
|
|
215
|
+
if (!inventoryPromise) {
|
|
216
|
+
inventoryPromise = probeBuiltinTools(claudePath)
|
|
217
|
+
.catch(err => {
|
|
218
|
+
console.warn(`[Gateway] Tool inventory probe failed (${err instanceof Error ? err.message : err}); ` +
|
|
219
|
+
'using the seed floor. Allowlisted threads stay closed.');
|
|
220
|
+
return [];
|
|
221
|
+
})
|
|
222
|
+
.then(probed => {
|
|
223
|
+
const all = new Set([...SEED_BUILTIN_TOOLS, ...probed, ...cumulusMcpToolNames()]);
|
|
224
|
+
return [...all].sort();
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
return inventoryPromise;
|
|
228
|
+
}
|
|
229
|
+
/** Test seam: drop the memo so a suite can probe with a stub. */
|
|
230
|
+
export function resetToolInventoryCache() {
|
|
231
|
+
inventoryPromise = null;
|
|
232
|
+
}
|
|
233
|
+
/** The bare tool name of an `mcp__server__tool` entry, or the name itself. */
|
|
234
|
+
export function bareToolName(name) {
|
|
235
|
+
if (!name.startsWith('mcp__'))
|
|
236
|
+
return name;
|
|
237
|
+
const parts = name.split('__');
|
|
238
|
+
return parts.length > 2 ? parts.slice(2).join('__') : name;
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Compile an allowlist into the denylist the CLI actually honours.
|
|
242
|
+
*
|
|
243
|
+
* Returns `existingDisallowed` UNCHANGED when no allowlist is configured — additive, so
|
|
244
|
+
* every thread that does not opt in behaves exactly as before.
|
|
245
|
+
*
|
|
246
|
+
* An allowlist entry matches an inventory entry exactly, or as its bare name, so
|
|
247
|
+
* `read_file` allows `mcp__cumulus-history__read_file` (the system prompt calls it
|
|
248
|
+
* `read_file`; a user should not have to know the prefix). An entry that matches nothing
|
|
249
|
+
* is inert — and inertness DENIES, so a typo fails closed.
|
|
250
|
+
*
|
|
251
|
+
* `disallowedTools` still applies on top: it can subtract from an allowlist but never add.
|
|
252
|
+
*/
|
|
253
|
+
export function compileDisallowedTools(allowedTools, inventory, existingDisallowed) {
|
|
254
|
+
if (!allowedTools)
|
|
255
|
+
return existingDisallowed;
|
|
256
|
+
const allowed = new Set(allowedTools);
|
|
257
|
+
const denied = new Set(existingDisallowed ?? []);
|
|
258
|
+
for (const name of inventory) {
|
|
259
|
+
if (allowed.has(name) || allowed.has(bareToolName(name)))
|
|
260
|
+
continue;
|
|
261
|
+
denied.add(name);
|
|
262
|
+
}
|
|
263
|
+
return denied.size ? [...denied].sort() : undefined;
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Is `name` (a bare tool name) reachable on this thread?
|
|
267
|
+
*
|
|
268
|
+
* Deliberately inventory-INDEPENDENT: it reads the thread's stated intent, so it gives the
|
|
269
|
+
* same answer on the Claude CLI path and the direct-provider path, whose real inventories
|
|
270
|
+
* differ. Available iff the allowlist admits it (or there is no allowlist) and the denylist
|
|
271
|
+
* does not name it.
|
|
272
|
+
*/
|
|
273
|
+
export function toolAvailable(name, policy) {
|
|
274
|
+
const denied = (policy.disallowedTools ?? []).some(d => d === name || bareToolName(d) === name);
|
|
275
|
+
if (denied)
|
|
276
|
+
return false;
|
|
277
|
+
if (!policy.allowedTools)
|
|
278
|
+
return true;
|
|
279
|
+
return policy.allowedTools.some(a => a === name || bareToolName(a) === name);
|
|
280
|
+
}
|
|
281
|
+
export function promptCapabilities(policy) {
|
|
282
|
+
return {
|
|
283
|
+
// Both halves are load-bearing since task 139: the section describes
|
|
284
|
+
// `run_job`, and run_job itself is refused server-side for a thread that
|
|
285
|
+
// cannot run shell. Keeping it for a thread with only one of the two would
|
|
286
|
+
// describe something unreachable.
|
|
287
|
+
backgroundWork: toolAvailable('Bash', policy) && toolAvailable('run_job', policy),
|
|
288
|
+
scheduling: toolAvailable('schedule_trigger', policy),
|
|
289
|
+
interAgent: toolAvailable('send_to_agent', policy),
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
/** Section header prefixes, keyed by the capability that keeps them. */
|
|
293
|
+
const GATED_SECTIONS = [
|
|
294
|
+
['backgroundWork', 'BACKGROUND WORK:'],
|
|
295
|
+
['scheduling', 'SCHEDULING'],
|
|
296
|
+
['interAgent', 'INTER-AGENT MESSAGING:'],
|
|
297
|
+
];
|
|
298
|
+
/**
|
|
299
|
+
* Remove prompt sections whose tools this thread cannot reach.
|
|
300
|
+
*
|
|
301
|
+
* Sections in SYSTEM_PROMPT_TEMPLATE are blank-line-delimited blocks whose first line is a
|
|
302
|
+
* header, so this is a split/filter/join — lossless when nothing is gated (regression-locked),
|
|
303
|
+
* and a no-op on a custom template that has no such headers.
|
|
304
|
+
*
|
|
305
|
+
* Only ever REMOVES text, so an unconstrained thread's prompt is byte-identical and the task
|
|
306
|
+
* 117 static-prompt budget cannot be breached by this function.
|
|
307
|
+
*/
|
|
308
|
+
export function applyCapabilityGates(template, caps) {
|
|
309
|
+
const dropPrefixes = GATED_SECTIONS.filter(([cap]) => !caps[cap]).map(([, prefix]) => prefix);
|
|
310
|
+
if (dropPrefixes.length === 0)
|
|
311
|
+
return template;
|
|
312
|
+
return template
|
|
313
|
+
.split('\n\n')
|
|
314
|
+
.filter(block => !dropPrefixes.some(prefix => block.startsWith(prefix)))
|
|
315
|
+
.join('\n\n');
|
|
316
|
+
}
|
|
317
|
+
//# sourceMappingURL=tool-inventory.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tool-inventory.js","sourceRoot":"","sources":["../../src/lib/tool-inventory.ts"],"names":[],"mappings":"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"}
|
|
@@ -1,167 +1,122 @@
|
|
|
1
|
-
# Conditional Thread Continuation —
|
|
1
|
+
# Conditional Thread Continuation — `run_job`
|
|
2
2
|
|
|
3
|
-
**Status:**
|
|
4
|
-
|
|
5
|
-
|
|
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
|
|
10
|
-
test suite, a deploy, a download. The thread should continue **the
|
|
11
|
-
known** (success _or_ failure), not after an arbitrary
|
|
12
|
-
scheduled AI turn every N minutes to poll ("did it
|
|
13
|
-
history — the exact failure class tasks
|
|
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
|
|
16
|
+
## The answer
|
|
16
17
|
|
|
17
|
-
|
|
18
|
+
```
|
|
19
|
+
run_job("npm run build", label: "build")
|
|
20
|
+
→ { started: true, id: "job_a7f3c2d1", logPath: "..." }
|
|
21
|
+
```
|
|
18
22
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
25
|
-
|
|
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
|
-
|
|
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
|
-
|
|
70
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
###
|
|
95
|
+
### Anti-pattern: cron polling turns
|
|
118
96
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
thread
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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.
|