lobstah 0.1.1 → 0.2.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 +17 -3
- package/dist/main.js +1160 -204
- package/dist/runner.js +2 -1
- package/docs/configuration.md +122 -0
- package/docs/design.md +669 -0
- package/docs/lobsterman.md +211 -0
- package/docs/openclaw.md +76 -0
- package/docs/pickup.md +358 -0
- package/docs/vocabulary.md +139 -0
- package/package.json +2 -1
package/docs/design.md
ADDED
|
@@ -0,0 +1,669 @@
|
|
|
1
|
+
# Lobstah — design
|
|
2
|
+
|
|
3
|
+
**Local executor for coding agents.**
|
|
4
|
+
|
|
5
|
+
Lobstah takes a dispatch descriptor and a brief, allocates an isolated worktree,
|
|
6
|
+
runs a coding agent, supervises it until it finishes or dies, and writes status
|
|
7
|
+
and evidence to disk.
|
|
8
|
+
|
|
9
|
+
It has no network interface, no credentials, and no knowledge of any tracker.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Problem
|
|
14
|
+
|
|
15
|
+
Teams delegating work to coding agents have two bad options.
|
|
16
|
+
|
|
17
|
+
**Cloud sandboxes** — the hosted coding-agent services — cannot
|
|
18
|
+
reach a private toolchain, unpushed branches, local services, or credentials that
|
|
19
|
+
never leave a laptop. Work that needs the developer's actual environment cannot
|
|
20
|
+
run there.
|
|
21
|
+
|
|
22
|
+
**Running agents locally by hand** works, and nobody knows what is happening. A
|
|
23
|
+
Claude Code session in a terminal gives no answer to "is it still working, is it
|
|
24
|
+
stuck, or did it die." The failure mode is a session wedged for forty minutes on
|
|
25
|
+
a question nobody saw.
|
|
26
|
+
|
|
27
|
+
Lobstah is the supervision layer for the second option.
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## Goals
|
|
32
|
+
|
|
33
|
+
- Run coding agents on a machine the developer controls
|
|
34
|
+
- Report liveness accurately, without screen scraping
|
|
35
|
+
- Distinguish a dead process from a wedged one and treat them differently
|
|
36
|
+
- Isolate concurrent work so parallel tasks cannot collide
|
|
37
|
+
- Recover from crashes without human intervention, within bounds
|
|
38
|
+
- Work standalone, with no knowledge of any particular dispatcher
|
|
39
|
+
|
|
40
|
+
## Non-goals
|
|
41
|
+
|
|
42
|
+
Lobstah must stay uninteresting past its job. It has:
|
|
43
|
+
|
|
44
|
+
- No network listener, no outbound HTTP, no OAuth
|
|
45
|
+
- No Linear, Slack, GitHub, or tracker integration
|
|
46
|
+
- No verdicts, review UI, or artifact rendering
|
|
47
|
+
- No decomposition, claims, or intent model
|
|
48
|
+
- No merge decisions
|
|
49
|
+
- No hosted service
|
|
50
|
+
|
|
51
|
+
The moment Lobstah grows something that makes a team feel covered, it stops
|
|
52
|
+
being a component and starts competing with the thing it feeds.
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
## Architecture
|
|
57
|
+
|
|
58
|
+
TypeScript daemon, one repository, packages factored so a new harness does not
|
|
59
|
+
touch supervision.
|
|
60
|
+
|
|
61
|
+
```
|
|
62
|
+
lobstah/
|
|
63
|
+
packages/
|
|
64
|
+
core/ queue contract, state machine, types
|
|
65
|
+
supervisor/ process liveness, wedge detection, restart ladder
|
|
66
|
+
worktree/ allocation, isolation, cleanup
|
|
67
|
+
runner/ per-dispatch child process; drives an adapter
|
|
68
|
+
adapters/
|
|
69
|
+
claude/ @anthropic-ai/claude-agent-sdk
|
|
70
|
+
codex/ @openai/codex-sdk
|
|
71
|
+
apps/
|
|
72
|
+
cli/ lobstah dispatch | status | cancel | daemon
|
|
73
|
+
node/ OpenClaw node plugin
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
**Why not shell.** A bash fleet is the right shape when the supervisor is an
|
|
77
|
+
LLM — the scripts are a tool surface for a model. Lobstah's consumer is a program
|
|
78
|
+
reading files, so the CLI wrapper earns nothing, and the delicate parts —
|
|
79
|
+
generation-token validation under a lock, atomic sequence allocation, three-source
|
|
80
|
+
state reconciliation — are straightforward in a typed language and fragile in
|
|
81
|
+
shell.
|
|
82
|
+
|
|
83
|
+
### The two-process split
|
|
84
|
+
|
|
85
|
+
The daemon does not run agents in-process. Every SDK in this category spawns its
|
|
86
|
+
harness CLI as a subprocess of the calling process, so an in-process session dies
|
|
87
|
+
with the daemon and is invisible to anything else.
|
|
88
|
+
|
|
89
|
+
```
|
|
90
|
+
daemon ──spawn──> runner (one per dispatch) ──SDK──> harness CLI
|
|
91
|
+
│ │
|
|
92
|
+
│ └── writes state/<uuid>.*
|
|
93
|
+
└── supervises runner by pid, reconciles from state files
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
- Daemon crash leaves runners alive and state files intact. It reconciles on
|
|
97
|
+
restart.
|
|
98
|
+
- Runner crash is visible through ordinary process supervision, and the state
|
|
99
|
+
files record how far it got.
|
|
100
|
+
- The SDK improves what happens inside the runner. It is not the durability
|
|
101
|
+
layer.
|
|
102
|
+
|
|
103
|
+
**Lobstah persists no conversation state.** Every harness already writes its own
|
|
104
|
+
transcript — Claude Code under `~/.claude/projects/`, Codex per thread. State
|
|
105
|
+
files track the dispatch: work item, worktree, verb, evidence. On resume, fork
|
|
106
|
+
the harness's own session rather than replaying a transcript into a new one.
|
|
107
|
+
|
|
108
|
+
### Adapters
|
|
109
|
+
|
|
110
|
+
Day 1 is Claude Code and Codex. Two implementations is the minimum that
|
|
111
|
+
validates the interface rather than shaping it around one harness, and between
|
|
112
|
+
them they cover most of the installed base.
|
|
113
|
+
|
|
114
|
+
Both expose the same conceptual API, which is what makes the abstraction real:
|
|
115
|
+
|
|
116
|
+
| | Claude Agent SDK | Codex SDK |
|
|
117
|
+
|---|---|---|
|
|
118
|
+
| Start | `query()` | `codex.startThread()` |
|
|
119
|
+
| Turn | async message generator | `thread.run()` |
|
|
120
|
+
| Stream | messages + hooks | `thread.runStreamed()` |
|
|
121
|
+
| Continue | resume by session id | `run()` again on the thread |
|
|
122
|
+
| Providers | Anthropic, Bedrock, Vertex, Foundry | OpenAI |
|
|
123
|
+
|
|
124
|
+
The adapter interface normalizes to: `start`, `run`, `resume`, `cancel`, and a
|
|
125
|
+
typed event stream carrying turn boundaries and tool-call boundaries.
|
|
126
|
+
|
|
127
|
+
**Tool-call granularity differs and both are usable.** Codex emits `item.started`
|
|
128
|
+
and `item.completed` around each `command_execution` or `mcp_tool_call`, so a
|
|
129
|
+
hanging call is an open interval you can name. Claude's `PostToolUse` fires after
|
|
130
|
+
completion, so a hang shows as silence. The adapter maps both to "last tool
|
|
131
|
+
activity at T," and the wedge threshold stays global.
|
|
132
|
+
|
|
133
|
+
Claude has the richer intervention surface — `PreToolUse` interception,
|
|
134
|
+
`SubagentStart` and `SubagentStop`, and 12+ hooks against Codex's six event
|
|
135
|
+
types. Anything relying on interception is Claude-only and must degrade rather
|
|
136
|
+
than fail on Codex.
|
|
137
|
+
|
|
138
|
+
Normalize cancellation explicitly. The Codex SDK's own ports document divergence
|
|
139
|
+
here — the Go port sends SIGTERM with a 2s grace then SIGKILL where the
|
|
140
|
+
TypeScript SDK sends a single SIGTERM. Differences like that leak into restart
|
|
141
|
+
behavior if the adapter layer does not absorb them.
|
|
142
|
+
|
|
143
|
+
OpenCode is the Day 2 candidate. It has a TypeScript SDK, a client/server
|
|
144
|
+
architecture that may be easier to supervise than subprocess spawning, and 75+
|
|
145
|
+
providers including local models, which makes agent-agnosticism true rather than
|
|
146
|
+
aspirational.
|
|
147
|
+
|
|
148
|
+
### Auth boundary
|
|
149
|
+
|
|
150
|
+
Anthropic does not permit third-party developers to offer claude.ai Pro or Max
|
|
151
|
+
login or subscription rate limits for products built on the Agent SDK.
|
|
152
|
+
Subscription usage of the SDK and `claude -p` is metered against the signed-in
|
|
153
|
+
plan's limits.
|
|
154
|
+
|
|
155
|
+
So Lobstah never handles a harness login. The user authenticates their own CLI
|
|
156
|
+
on their own machine; Lobstah invokes the authenticated binary and nothing else.
|
|
157
|
+
No reading, persisting, refreshing, or forwarding of harness tokens — a
|
|
158
|
+
non-secret route marker at most, with the harness owning its token lifecycle.
|
|
159
|
+
|
|
160
|
+
For shared automation the operator supplies an API key through repo config, which
|
|
161
|
+
is per-machine rather than brokered.
|
|
162
|
+
|
|
163
|
+
---
|
|
164
|
+
|
|
165
|
+
## OpenClaw node plugin
|
|
166
|
+
|
|
167
|
+
OpenClaw already solves the undifferentiated parts of remote dispatch. It has
|
|
168
|
+
a WebSocket control plane. Nodes declare `role: node` with explicit caps and
|
|
169
|
+
commands. Device pairing needs identity plus approval plus token. Auth fails
|
|
170
|
+
closed. Each node has an exec allowlist. Transport is Tailscale or SSH, not
|
|
171
|
+
public exposure.
|
|
172
|
+
|
|
173
|
+
Lobstah ships a node plugin that advertises a run capability and translates
|
|
174
|
+
inbound commands into queue descriptors. The core does not know OpenClaw exists.
|
|
175
|
+
|
|
176
|
+
```
|
|
177
|
+
OpenClaw Gateway ──WS──> lobstah node plugin ──writes──> queue/
|
|
178
|
+
<── <──reads── state/
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
What the plugin adds over OpenClaw's own Claude session continuation, which is
|
|
182
|
+
one-shot, rejects attachments, and has no workspace isolation:
|
|
183
|
+
|
|
184
|
+
- Per-work-item worktree allocation branched from trunk
|
|
185
|
+
- Dead-versus-wedged classification and the restart ladder
|
|
186
|
+
- Dispatch queue semantics with a concurrency ceiling
|
|
187
|
+
- Work-item-shaped evidence collection
|
|
188
|
+
|
|
189
|
+
Two independent integration surfaces driving one core is the test of whether
|
|
190
|
+
the queue contract is a real interface. If both drive it without the core knowing
|
|
191
|
+
which, it holds.
|
|
192
|
+
|
|
193
|
+
Depending on any gateway means inheriting its release cadence and its security
|
|
194
|
+
surface. The plugin is therefore additive. The file queue and CLI remain the primary path, and
|
|
195
|
+
nothing in `core` may import from `apps/node`.
|
|
196
|
+
|
|
197
|
+
---
|
|
198
|
+
|
|
199
|
+
## Queue contract
|
|
200
|
+
|
|
201
|
+
The dispatch surface is a directory. No port, no protocol, no authentication.
|
|
202
|
+
|
|
203
|
+
```
|
|
204
|
+
~/.lobstah/
|
|
205
|
+
queue/ <uuid>.json pending descriptors
|
|
206
|
+
active/ <uuid>/ claimed, in flight
|
|
207
|
+
done/ <uuid>/
|
|
208
|
+
state/ <uuid>.status append-only, six verbs
|
|
209
|
+
<uuid>.evidence branch, commits, PRs, CI refs
|
|
210
|
+
<uuid>.events hook telemetry
|
|
211
|
+
inbox/ <uuid>/NNN.msg instructions to a running agent
|
|
212
|
+
<uuid>/handled/ acknowledgement by rename
|
|
213
|
+
chores/ queue/ active/ done/ state/ second lane: internal dispatches
|
|
214
|
+
executor.json capabilities + heartbeat
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
### Descriptor
|
|
218
|
+
|
|
219
|
+
```json
|
|
220
|
+
{
|
|
221
|
+
"id": "6f3a...",
|
|
222
|
+
"repo": "myapp",
|
|
223
|
+
"brief": "...",
|
|
224
|
+
|
|
225
|
+
"harness": "claude",
|
|
226
|
+
"model": "opus",
|
|
227
|
+
"effort": "high",
|
|
228
|
+
"limits": { "maxTurns": 200, "maxBudgetUsd": 5, "wallClockSecs": 3600 },
|
|
229
|
+
"flags": ["--add-dir", "../shared"],
|
|
230
|
+
"env": { "NODE_ENV": "test" },
|
|
231
|
+
"followUp": "9c41..."
|
|
232
|
+
}
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
`id`, `repo`, and `brief` are required. Everything below the break is optional
|
|
236
|
+
and resolves through a precedence chain:
|
|
237
|
+
|
|
238
|
+
```
|
|
239
|
+
descriptor > repo config > global config > adapter default
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
**Structured fields are cross-harness concepts.** `model`, `effort`, and `limits`
|
|
243
|
+
mean something in every harness and translate differently in each. Claude takes a
|
|
244
|
+
thinking budget, Codex takes a reasoning effort level, others take neither. The
|
|
245
|
+
adapter owns the translation, and an unsupported value is dropped with a warning
|
|
246
|
+
rather than failing the dispatch.
|
|
247
|
+
|
|
248
|
+
**`flags` is the escape hatch**, appended verbatim to the harness invocation. It
|
|
249
|
+
couples the descriptor to one harness, so it should be rare. A descriptor using
|
|
250
|
+
only structured fields routes to any machine; one using `flags` routes only to
|
|
251
|
+
machines running that harness.
|
|
252
|
+
|
|
253
|
+
**`env` is per-dispatch environment**, merged over the repo's own environment.
|
|
254
|
+
|
|
255
|
+
**`followUp` forks an earlier dispatch's harness session** instead of starting
|
|
256
|
+
cold — the restart ladder's first rung, exposed to callers. The runner resumes
|
|
257
|
+
by the prior dispatch's session identity (Claude by session id, Codex by
|
|
258
|
+
thread id) and the original transcript survives untouched. Use it when the
|
|
259
|
+
follow-up is about choices that session made, as review feedback is. Skip it
|
|
260
|
+
when the follow-up is about the world changing after the session exited — a
|
|
261
|
+
rebase conflict is about commits the session never saw, so its transcript is
|
|
262
|
+
replay cost without signal.
|
|
263
|
+
|
|
264
|
+
**`id` is the only correlation handle.** Lobstah uses it as a directory name and
|
|
265
|
+
a status filename. Whoever dispatched holds the mapping from UUID to work item,
|
|
266
|
+
claims, and tracker — the dispatcher holds that table. Standalone, the human
|
|
267
|
+
knows what they dispatched. Nothing on device needs to reconstruct it, and an on-device
|
|
268
|
+
model to hold a mapping is more machinery than a lookup.
|
|
269
|
+
|
|
270
|
+
**`repo` must be structured, not prose.** Worktree allocation happens before the
|
|
271
|
+
agent starts, so the repo cannot be something the agent reads out of the brief
|
|
272
|
+
later. Parsing it from prose would put an LLM in the allocation path, which is
|
|
273
|
+
the failure it exists to avoid.
|
|
274
|
+
|
|
275
|
+
It is an opaque key, not a URL or a path. The key names a **workspace
|
|
276
|
+
definition** in local config: a git repository plus the execution context around
|
|
277
|
+
it — trunk branch, setup commands, environment, harness defaults. The dispatcher
|
|
278
|
+
names the key; resolution is local.
|
|
279
|
+
|
|
280
|
+
That keeps the descriptor free of machine-specific detail and lets the same
|
|
281
|
+
descriptor route to any machine advertising the key. It also allows one git
|
|
282
|
+
repository to back several keys — `myapp` and `myapp-perf` pointing at the same
|
|
283
|
+
checkout with different setup and limits.
|
|
284
|
+
|
|
285
|
+
If a key carries an `origin`, Lobstah clones on first use. Without one, an
|
|
286
|
+
unresolvable key fails the dispatch immediately rather than at agent start.
|
|
287
|
+
|
|
288
|
+
### Claiming
|
|
289
|
+
|
|
290
|
+
Atomic rename from `queue/` to `active/`. That is what makes concurrent writers
|
|
291
|
+
safe without a lock.
|
|
292
|
+
|
|
293
|
+
**Lobstah decides how many to claim.** The queue holds descriptors and has no
|
|
294
|
+
concept of capacity. Concurrency limits live in Lobstah's config, so a writer
|
|
295
|
+
draining a backlog into the directory cannot over-fill the machine.
|
|
296
|
+
|
|
297
|
+
### Watching
|
|
298
|
+
|
|
299
|
+
Watch as an optimization, poll as the guarantee. `fsevents` and `inotify` both
|
|
300
|
+
drop events under load and across network mounts. A directory scan every few
|
|
301
|
+
seconds is the contract; the watcher only reduces latency.
|
|
302
|
+
|
|
303
|
+
### Cancellation
|
|
304
|
+
|
|
305
|
+
A `cancel` file in `active/<uuid>/`, checked by the supervisor loop between
|
|
306
|
+
polls. Latency equals the loop interval, which is acceptable for a task measured
|
|
307
|
+
in minutes.
|
|
308
|
+
|
|
309
|
+
### Capabilities
|
|
310
|
+
|
|
311
|
+
`executor.json` advertises what this machine can serve and when it was last
|
|
312
|
+
alive:
|
|
313
|
+
|
|
314
|
+
```json
|
|
315
|
+
{
|
|
316
|
+
"machineId": "chris-mbp",
|
|
317
|
+
"repos": ["myapp", "lobstah"],
|
|
318
|
+
"harnesses": ["claude", "codex"],
|
|
319
|
+
"maxConcurrent": 2,
|
|
320
|
+
"version": "0.4.1",
|
|
321
|
+
"heartbeat": "2026-09-01T14:22:03Z"
|
|
322
|
+
}
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
A dispatcher reads this to route. A stale heartbeat is how it knows the machine
|
|
326
|
+
is offline. Lobstah writes it and never reads anything back.
|
|
327
|
+
|
|
328
|
+
### The chore lane
|
|
329
|
+
|
|
330
|
+
`chores/` mirrors the primary lane — same descriptor schema, same claiming,
|
|
331
|
+
same supervision, its own `queue/`, `active/`, `done/`, and `state/`.
|
|
332
|
+
|
|
333
|
+
A chore is an agent run the system originates for its own operation —
|
|
334
|
+
judgment applied to mechanics, with no human request behind it. The lane is
|
|
335
|
+
bounded on both sides. Anything deterministic never becomes a dispatch at all
|
|
336
|
+
— that is daemon or caller code. Anything a human asked for, or that changes
|
|
337
|
+
what a human will review beyond mechanics, is work in the primary lane. A rebase to unblock a merge is the founding case; restacking a stack's
|
|
338
|
+
descendants after a squash-merge is the same shape. Chores have their own
|
|
339
|
+
concurrency ceiling (default 1; `maxConcurrent` governs the primary lane
|
|
340
|
+
only), are hidden from `ls` and `status` unless asked for, and age out of
|
|
341
|
+
`done/` on a short retention. The daemon treats the two lanes identically past
|
|
342
|
+
admission; the split is queue hygiene, not a second contract.
|
|
343
|
+
|
|
344
|
+
### Inbox delivery
|
|
345
|
+
|
|
346
|
+
Writing to `inbox/<uuid>/` queues a message. Delivery is a separate question, and
|
|
347
|
+
headless execution shapes the answer.
|
|
348
|
+
|
|
349
|
+
A multiplexer-based fleet can type a doorbell into a tmux pane because its
|
|
350
|
+
workers are interactive TUIs sitting at a prompt. A Lobstah agent runs as `claude -p` with stdout
|
|
351
|
+
redirected, so there is no composer to type into and no prompt to interrupt. The
|
|
352
|
+
process runs many internal turns and exits once.
|
|
353
|
+
|
|
354
|
+
Three delivery points, in ascending cost:
|
|
355
|
+
|
|
356
|
+
**Between turns.** The runner drains the inbox into the next `run()` on the same
|
|
357
|
+
thread. Deterministic — guaranteed delivered and guaranteed read. Latency is the
|
|
358
|
+
remainder of the current turn. Works identically on both adapters, so this is the
|
|
359
|
+
default.
|
|
360
|
+
|
|
361
|
+
**Mid-turn pull, through the CLI.** The agent runs `lobstah inbox <id>` at its
|
|
362
|
+
own checkpoints, instructed by the injected contract. One transport for every
|
|
363
|
+
harness, and cheaper than MCP tools — tool schemas ride in the context every
|
|
364
|
+
turn, a CLI call costs the command string. It depends on the agent choosing
|
|
365
|
+
to look.
|
|
366
|
+
|
|
367
|
+
**Mid-turn push, through hooks.** Claude's `PreToolUse` can return additional
|
|
368
|
+
context, injecting the message before the next tool call. No agent cooperation
|
|
369
|
+
and one tool call of latency. Claude-only — Codex exposes events without
|
|
370
|
+
interception — so it must degrade to pull rather than fail.
|
|
371
|
+
|
|
372
|
+
Start with between-runs delivery. Mid-run steering matters less for a headless
|
|
373
|
+
fleet, because a headless run that needs redirecting is usually better
|
|
374
|
+
cancelled and re-dispatched with a corrected brief.
|
|
375
|
+
|
|
376
|
+
Acknowledgement stays the same regardless: a move into `handled/`, which is a
|
|
377
|
+
side effect that cannot be faked.
|
|
378
|
+
|
|
379
|
+
---
|
|
380
|
+
|
|
381
|
+
## CLI
|
|
382
|
+
|
|
383
|
+
The CLI never talks to the daemon. Writes are files; reads are files. That is the
|
|
384
|
+
main practical benefit of the directory contract.
|
|
385
|
+
|
|
386
|
+
| Command | Action |
|
|
387
|
+
|---|---|
|
|
388
|
+
| `lobstah dispatch --repo myapp --brief ./b.md` | Writes a descriptor to `queue/` |
|
|
389
|
+
| `lobstah status [uuid]` | Reads and reconciles from `state/` |
|
|
390
|
+
| `lobstah logs <uuid> [--follow]` | Tails `state/<uuid>.events` |
|
|
391
|
+
| `lobstah send <uuid> "<msg>"` | Writes an inbox record |
|
|
392
|
+
| `lobstah cancel <uuid>` | Writes a cancel marker to `active/<uuid>/` |
|
|
393
|
+
| `lobstah ls` | Lists queue, active, and recent done |
|
|
394
|
+
| `lobstah daemon` | Starts the supervisor loop |
|
|
395
|
+
|
|
396
|
+
`status` performs the same three-source reconciliation the supervisor does —
|
|
397
|
+
CI run state, then busy state, then the status log — so a human and the bridge
|
|
398
|
+
see the same answer.
|
|
399
|
+
|
|
400
|
+
Every command except `daemon` works with the daemon stopped. Dispatches written
|
|
401
|
+
while it is down are claimed when it comes back.
|
|
402
|
+
|
|
403
|
+
It should also utilize TOON for any output and also be self-documenting for any agent to utilize it.
|
|
404
|
+
|
|
405
|
+
---
|
|
406
|
+
|
|
407
|
+
## Callers
|
|
408
|
+
|
|
409
|
+
Five, all writing the same descriptor into the same directory.
|
|
410
|
+
|
|
411
|
+
| Caller | Path |
|
|
412
|
+
|---|---|
|
|
413
|
+
| CLI | `lobstah dispatch --repo myapp --brief ./b.md` |
|
|
414
|
+
| Remote bridge | drains a remote dispatcher's queue, writes descriptors, posts state back |
|
|
415
|
+
| OpenClaw node plugin | translates gateway commands into descriptors |
|
|
416
|
+
| Pickup add-on | polls Linear/GitHub, no webhooks — see [pickup.md](pickup.md) |
|
|
417
|
+
| Anything else | a cron job, a shell script, a different tracker |
|
|
418
|
+
|
|
419
|
+
The bridge and the node plugin are the only pieces holding credentials, and
|
|
420
|
+
neither lives in `core`. Anyone can write a third without touching Lobstah, which
|
|
421
|
+
is what makes the separation real rather than nominal.
|
|
422
|
+
|
|
423
|
+
---
|
|
424
|
+
|
|
425
|
+
## Lifecycle
|
|
426
|
+
|
|
427
|
+
### 1. Claim
|
|
428
|
+
|
|
429
|
+
Rename the descriptor into `active/<uuid>/`. Write the brief to
|
|
430
|
+
`active/<uuid>/brief.md` and treat that file as the durable instruction. Never
|
|
431
|
+
the session transcript.
|
|
432
|
+
|
|
433
|
+
### 2. Worktree allocation
|
|
434
|
+
|
|
435
|
+
One worktree per dispatch, branched from trunk. Never reuse a worktree across
|
|
436
|
+
dispatches, and never allocate a second worktree for a UUID whose first is
|
|
437
|
+
unaccounted for.
|
|
438
|
+
|
|
439
|
+
This prevents convenience stacking, where an agent finishing one task and
|
|
440
|
+
starting the next in the same worktree produces a branch containing the previous
|
|
441
|
+
task's diff. That contaminates evidence and falsely serializes merges.
|
|
442
|
+
|
|
443
|
+
### 3. Spawn the runner
|
|
444
|
+
|
|
445
|
+
The daemon spawns one runner per dispatch with `setsid`, then records its pid and
|
|
446
|
+
process start time. The runner drives the adapter; the daemon supervises the
|
|
447
|
+
runner and never touches the harness directly.
|
|
448
|
+
|
|
449
|
+
```ts
|
|
450
|
+
// inside the runner
|
|
451
|
+
const thread = await adapter.start({
|
|
452
|
+
id: uuid, cwd: worktree, brief, model, effort, limits, flags, env,
|
|
453
|
+
});
|
|
454
|
+
for await (const ev of thread.stream()) {
|
|
455
|
+
appendEvent(ev); // state/<uuid>.events
|
|
456
|
+
}
|
|
457
|
+
```
|
|
458
|
+
|
|
459
|
+
- **The dispatch UUID is the session identity.** Claude takes it as
|
|
460
|
+
`--session-id`; Codex issues a thread id the adapter records on
|
|
461
|
+
`thread.started`. Either way the handle exists before the first token.
|
|
462
|
+
- **`setsid`** so the whole group is killable. Harnesses spawn children — bash
|
|
463
|
+
calls, MCP servers — that a bare `kill` orphans.
|
|
464
|
+
- **Process start time** defeats pid reuse.
|
|
465
|
+
- **Limits** map to `maxBudgetUsd` and turn caps through the adapter, plus a
|
|
466
|
+
Lobstah-owned wall-clock ceiling the SDKs do not provide.
|
|
467
|
+
|
|
468
|
+
### 4. Supervise
|
|
469
|
+
|
|
470
|
+
Three signal levels, kept separate. None derived from another.
|
|
471
|
+
|
|
472
|
+
| Level | Source | Question |
|
|
473
|
+
|---|---|---|
|
|
474
|
+
| Runner liveness | pid + start time | Does the process exist |
|
|
475
|
+
| Agent activity | SDK event stream | Is a turn or tool call in flight |
|
|
476
|
+
| Task state | agent-declared status | What is the work doing |
|
|
477
|
+
|
|
478
|
+
**Events, not scraping.** The adapter writes normalized events to
|
|
479
|
+
`state/<uuid>.events` from the SDK's typed stream. No JSONL parsing and no shell
|
|
480
|
+
hooks for telemetry.
|
|
481
|
+
|
|
482
|
+
Tool-call granularity differs by harness and both are usable:
|
|
483
|
+
|
|
484
|
+
| | Claude | Codex |
|
|
485
|
+
|---|---|---|
|
|
486
|
+
| Turn boundary | message stream + `Stop` | `turn.started` / `turn.completed` |
|
|
487
|
+
| Tool start | not surfaced | `item.started` |
|
|
488
|
+
| Tool end | `PostToolUse` | `item.completed` |
|
|
489
|
+
| Hang appears as | silence | an open interval |
|
|
490
|
+
|
|
491
|
+
Codex names the hanging call; Claude only shows absence. The adapter normalizes
|
|
492
|
+
both to `lastToolActivityAt`, so the wedge threshold stays global.
|
|
493
|
+
|
|
494
|
+
**Classification rule.** Missing, malformed, stale, or unverified data is
|
|
495
|
+
`unknown`, never `idle`. Absence of signal never means done.
|
|
496
|
+
|
|
497
|
+
**Progress signals**, descending strength: tool-activity timestamp, event-file
|
|
498
|
+
growth, worktree mtime. Codex's `file_change` items give the third signal
|
|
499
|
+
directly; for Claude it stays a filesystem check, which is what covers a long
|
|
500
|
+
build that completes no tool call.
|
|
501
|
+
|
|
502
|
+
### 5. Report
|
|
503
|
+
|
|
504
|
+
**Status** is append-only, six verbs, nothing else:
|
|
505
|
+
|
|
506
|
+
```
|
|
507
|
+
working | needs-decision | blocked | paused | done | failed
|
|
508
|
+
```
|
|
509
|
+
|
|
510
|
+
The consumer is a program, not a model. An LLM-supervised fleet tolerates odd
|
|
511
|
+
status lines because an LLM reads them; a bridge cannot. **The verb is validated at the write
|
|
512
|
+
path and anything outside the set is rejected**, rather than relying on the brief
|
|
513
|
+
to hold.
|
|
514
|
+
|
|
515
|
+
The agent learns the contract the way every dispatch learns everything: the
|
|
516
|
+
runner injects the status and inbox protocol into the prompt it composes.
|
|
517
|
+
Nothing is installed repo-side, and the contract versions with the daemon
|
|
518
|
+
instead of drifting per repo.
|
|
519
|
+
|
|
520
|
+
The log is an event log, not a state field. An agent that resumes silently writes
|
|
521
|
+
nothing, so the last line goes stale. Current state is reconciled in precedence
|
|
522
|
+
order: CI run state, then busy state, then the status log as a last resort.
|
|
523
|
+
|
|
524
|
+
**Instructions** flow the other way through `inbox/<uuid>/`. Sequenced records
|
|
525
|
+
written by atomic rename, acknowledged by moving into `handled/`. The
|
|
526
|
+
acknowledgement is a side effect the agent had to perform anyway, so it cannot be
|
|
527
|
+
faked. Notification is best-effort and retryable; the record is the delivery.
|
|
528
|
+
|
|
529
|
+
### 6. Complete
|
|
530
|
+
|
|
531
|
+
Collect evidence into `state/<uuid>.evidence` — branch, commits, PR URL if
|
|
532
|
+
opened, CI references, transcript path — then move `active/<uuid>/` to `done/`.
|
|
533
|
+
|
|
534
|
+
`done` means the brief is fulfilled — not that the work item is finished. A PR
|
|
535
|
+
still faces review, feedback rounds, and merge, and "merged" is a forge
|
|
536
|
+
concept core is forbidden to know. A work item therefore spans dispatches —
|
|
537
|
+
implementation, feedback follow-ups, a rebase chore — correlated by whoever
|
|
538
|
+
dispatched them. **The dispatch is the unit of supervision, not the unit of
|
|
539
|
+
work.** Work-item completion belongs to the dispatcher's ledger and the
|
|
540
|
+
tracker's own lifecycle.
|
|
541
|
+
|
|
542
|
+
---
|
|
543
|
+
|
|
544
|
+
## Restart policy
|
|
545
|
+
|
|
546
|
+
Dead and wedged get opposite treatment.
|
|
547
|
+
|
|
548
|
+
**Dead.** Process gone, or the foreground group contains only shells.
|
|
549
|
+
Auto-restart, unattended. Preconditions, all hard:
|
|
550
|
+
|
|
551
|
+
- The endpoint is *positively* agent-free. Ambiguous and unreadable never qualify.
|
|
552
|
+
- The worktree is intact and still the recorded one.
|
|
553
|
+
- The prior runner's event stream is closed and its generation retired before
|
|
554
|
+
the replacement is armed, so a late write cannot land in the new incarnation.
|
|
555
|
+
|
|
556
|
+
**Wedged.** Alive, no tool event past threshold. Never restart automatically.
|
|
557
|
+
Bound it, then work a ladder:
|
|
558
|
+
|
|
559
|
+
1. Resume the harness's own session with a nudge — Claude by session id, Codex
|
|
560
|
+
by thread id. Worktree plus conversation is the cheapest recovery. Fork rather
|
|
561
|
+
than mutate, so the original transcript survives for postmortem.
|
|
562
|
+
2. Fresh session with the original brief plus a progress note from
|
|
563
|
+
`git log --oneline` and `git status --short`. A wedge caused by the
|
|
564
|
+
conversation will re-wedge on resume.
|
|
565
|
+
3. Stop. Report `failed` with preserved work.
|
|
566
|
+
|
|
567
|
+
Bounded attempt count per dispatch. A silently re-restarting session against a
|
|
568
|
+
task nobody is watching is the failure mode that costs real money.
|
|
569
|
+
|
|
570
|
+
Never kill a process that already exited, and never race a restart against a
|
|
571
|
+
completed run. Two branches, kept separate in code.
|
|
572
|
+
|
|
573
|
+
---
|
|
574
|
+
|
|
575
|
+
## Load-bearing mechanisms
|
|
576
|
+
|
|
577
|
+
Hard-won, language-independent patterns from the fleet supervisors that came
|
|
578
|
+
before, reimplemented rather than ported.
|
|
579
|
+
|
|
580
|
+
| Mechanism | Purpose |
|
|
581
|
+
|---|---|
|
|
582
|
+
| Atomic rename for claim and sequence | Concurrency safety without locks |
|
|
583
|
+
| Acknowledgement by required side effect | An ack that cannot be faked |
|
|
584
|
+
| Generation tokens on hooks | A hook outliving its incarnation fails closed |
|
|
585
|
+
| Three-level state, strict precedence | `unknown` never collapses to `idle` |
|
|
586
|
+
| Positively-agent-free precondition | A false `dead` verdict launches a duplicate |
|
|
587
|
+
| Durable record, retryable notification | Delivery survives a lost notification |
|
|
588
|
+
|
|
589
|
+
Package factoring keeps adapters per harness and transport separated from
|
|
590
|
+
decisions — per-harness `case` statements scattered across call sites are the
|
|
591
|
+
cost of not doing this.
|
|
592
|
+
|
|
593
|
+
---
|
|
594
|
+
|
|
595
|
+
## Authentication and routing
|
|
596
|
+
|
|
597
|
+
Subscription authentication is individual. A shared host running a team's work on
|
|
598
|
+
one seat routes other people's requests through one person's seat.
|
|
599
|
+
|
|
600
|
+
**Consequence:** Lobstah is per-user. One daemon, one machine, one seat.
|
|
601
|
+
|
|
602
|
+
A shared orchestrator dispatches to per-user machines through their bridges.
|
|
603
|
+
Online and offline become routing conditions rather than edge cases, resolved
|
|
604
|
+
from `executor.json` heartbeats.
|
|
605
|
+
|
|
606
|
+
Lobstah holds no credentials for anything. The bridge, the node plugin, and the
|
|
607
|
+
operator's own repo config hold them.
|
|
608
|
+
|
|
609
|
+
*Anthropic's position has moved twice this year. Verify against current terms
|
|
610
|
+
before this becomes load-bearing.*
|
|
611
|
+
|
|
612
|
+
---
|
|
613
|
+
|
|
614
|
+
## Configuration
|
|
615
|
+
|
|
616
|
+
Local file. Version-controllable, greppable, no network dependency.
|
|
617
|
+
|
|
618
|
+
```toml
|
|
619
|
+
[repos.myapp]
|
|
620
|
+
path = "~/src/myapp"
|
|
621
|
+
origin = "git@github.com:you/myapp.git" # optional; enables clone on first use
|
|
622
|
+
trunk = "main"
|
|
623
|
+
setup = ["pnpm install"]
|
|
624
|
+
env = { TURBO_TELEMETRY_DISABLED = "1" }
|
|
625
|
+
|
|
626
|
+
[repos.myapp.harness]
|
|
627
|
+
default = "claude"
|
|
628
|
+
model = "opus"
|
|
629
|
+
effort = "high"
|
|
630
|
+
|
|
631
|
+
[harness] # global fallback
|
|
632
|
+
default = "claude"
|
|
633
|
+
model = "sonnet"
|
|
634
|
+
|
|
635
|
+
[limits]
|
|
636
|
+
maxConcurrent = 2
|
|
637
|
+
wedgeThresholdSecs = 600
|
|
638
|
+
maxRestartAttempts = 2
|
|
639
|
+
wallClockSecs = 3600
|
|
640
|
+
```
|
|
641
|
+
|
|
642
|
+
Harness settings appear at both levels. A descriptor overrides the repo, which
|
|
643
|
+
overrides the global, which overrides the adapter default.
|
|
644
|
+
|
|
645
|
+
---
|
|
646
|
+
|
|
647
|
+
## Distribution
|
|
648
|
+
|
|
649
|
+
MIT. Standalone-installable, useful with nothing else installed.
|
|
650
|
+
|
|
651
|
+
Positioning is "open source supervisor for local coding agents." The README has
|
|
652
|
+
to stand alone; if that README cannot be written, the component is not ready to
|
|
653
|
+
ship.
|
|
654
|
+
|
|
655
|
+
Two distribution paths off one core:
|
|
656
|
+
|
|
657
|
+
| Path | Surface |
|
|
658
|
+
|---|---|
|
|
659
|
+
| Standalone | `npm i -g lobstah`, file queue, CLI |
|
|
660
|
+
| OpenClaw plugin | agent tools + a chat command, installed into an existing gateway |
|
|
661
|
+
|
|
662
|
+
A remote dispatcher integrates the same way anything does: by writing
|
|
663
|
+
descriptors and reading state.
|
|
664
|
+
|
|
665
|
+
Support posture: issues accepted, no response-time commitment, no roadmap input,
|
|
666
|
+
contributions merged on the maintainer's schedule. Security reports and
|
|
667
|
+
dependency CVEs answered regardless.
|
|
668
|
+
|
|
669
|
+
---
|