agents-pod 0.1.0 → 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/CHANGELOG.md CHANGED
@@ -4,6 +4,34 @@ All notable changes to agents-pod. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project
5
5
  adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.2.0] — 2026-11-04
8
+
9
+ Work that arrives in a chat can now be picked up there, and answered there,
10
+ without a resident process holding a socket: a **Source** drains a conversation
11
+ into a pod once per pass, a **Deliverer** posts a pod's **Outbox** back, and a
12
+ **Delivery Address** rides the Goal so a Worker can reply without asking anyone.
13
+
14
+ ### Added
15
+
16
+ - **IM intake and outbound — Source, Deliverer, Outbox, and Delivery Address** —
17
+ a pull-based intake and file-based reply path, with platform credentials and
18
+ API behavior kept in operator commands.
19
+ - **Sources and drains** — `source add|list|rm`, cursor-based JSONL drains with
20
+ platform ids and routing fields, bounded execution, claim-before-cursor
21
+ commits, and two-generation claim retention.
22
+ - **Source Listeners and standing Pod intake** — `listener add --source`, batched
23
+ `TASKS.md` Inbox delivery with Waiting state, the `{{reply_to}}` template
24
+ placeholder, and `--project` for template-materialized Pods.
25
+ - **Outbox delivery** — `deliver add|list|rm`, one operator deliverer, named
26
+ per-wake-up entry files carrying `{to, text}`, stable delivery uuids, bounded
27
+ wrapper and reconcile flushes, same-uuid retries, one-hour dead-lettering,
28
+ and retained `*.dead` entries for expired or invalid input.
29
+
30
+ ### Fixed
31
+
32
+ - Wake-up outcomes are recorded under the registry lock, so concurrent passes
33
+ and wrappers no longer overwrite each other's registry updates.
34
+
7
35
  ## [0.1.0] — 2026-11-03
8
36
 
9
37
  First release: one pod, many wake-ups, and the machinery that keeps a standing
package/README.md CHANGED
@@ -4,9 +4,11 @@ Supervision for long-running coding agents: spawn a **pod** — a standing
4
4
  unit that outlives every context window — and wake **agents** inside it,
5
5
  again and again, each wake-up fresh, documents carrying the work across.
6
6
 
7
- > **Status: `0.1.0` — first release.** The CLI runs (spawn, wake, cancel,
8
- > logs, reconcile, listeners, notifications) and the Pi extension ships its two
9
- > planes. Wake-ups are **Pi only** in this release. Interfaces may still move
7
+ > **Status: `0.2.0` — IM intake and outbound.** The CLI runs (spawn, wake,
8
+ > cancel, logs, reconcile, listeners, sources, deliverers, notifications) and the
9
+ > Pi extension ships its two planes. A pod can now be fed from a chat and answer
10
+ > into it, through operator commands that keep credentials and sockets out of
11
+ > core. Wake-ups are **Pi only** in this release. Interfaces may still move
10
12
  > before `1.0`. The design lives in `CONTEXT.md` and `docs/adr/`; a reader's
11
13
  > companion — the design principles and an explained vocabulary — in
12
14
  > [docs/DESIGN.md](docs/DESIGN.md).
@@ -73,11 +75,11 @@ agent (planner, coder, reviewer; pi, claude, codex) across its life
73
75
 
74
76
  **Wake-ups are the context unit; the pod isolates them.** One **wake-up** =
75
77
  one agent, one fresh context window: read the pod documents first, work,
76
- record (handoff, decisions, task state), exit with an outcome. Nothing
77
- crosses wake-ups except documents — agents in a pod share its documents,
78
- never one another's windows. Context exhaustion ends a wake-up, never the
79
- pod; waking again is routine, not recovery. The store's contract is three
80
- slots, two hard lines ([ADR 0008](docs/adr/0008-document-store-contract.md)).
78
+ record (handoff, decisions, task state), exit with an outcome. Agents in a
79
+ pod share its documents, never one another's windows; pending Outbox entries
80
+ are files, not context. Context exhaustion ends a wake-up, never the pod;
81
+ waking again is routine, not recovery. The store's contract is three slots,
82
+ two hard lines ([ADR 0008](docs/adr/0008-document-store-contract.md)).
81
83
 
82
84
  **A wrapper owns each wake-up's liveness.** Every wake-up gets a tiny
83
85
  **wrapper** — detached, session and process-group leader — that spawns the
@@ -148,10 +150,16 @@ agents-pod agent add <id> --type <pi|claude|codex> [--model <id>] [--instruction
148
150
  agents-pod agent edit <id> # $EDITOR on the stored definition
149
151
  agents-pod agent rm <id> # pods left referencing it park at reconcile (ADR 0012)
150
152
  agents-pod agent list [--json] # named agents: type, model, role
151
- agents-pod listener add --inbox <dir|file> [--pod <podId> | --template <file>]
152
- [--match <substr>] [--max-concurrent <n>]
153
+ agents-pod listener add (--inbox <dir|file> | --source <name>) (--pod <podId> | --template <file>)
154
+ [--match <substr>] [--max-concurrent <n>] [--project <dir>]
153
155
  agents-pod listener list [--json]
154
156
  agents-pod listener rm <listenerId>
157
+ agents-pod source add <name> --cmd <path> [--timeout <s>]
158
+ agents-pod source list [--json]
159
+ agents-pod source rm <name>
160
+ agents-pod deliver add <name> --cmd <path> [--timeout <s>]
161
+ agents-pod deliver list [--json]
162
+ agents-pod deliver rm <name>
155
163
  ```
156
164
 
157
165
  `spawn` claims a pod id, writes the pod record, wakes the first agent, and
@@ -222,21 +230,28 @@ policy, per record:
222
230
  - terminal → no-op (terminal is terminal: `wake` refuses; continuation is
223
231
  a new pod).
224
232
 
225
- After pods, the same pass materializes listeners: scan each inbox, claim new
226
- items by id (registry-first, so a lost tick cannot double-claim), then
227
- either spawn a pod from the listener's template or wake the bound standing
228
- pod — bounded by `--max-concurrent` and the per-project pod cap.
233
+ After pods, the same pass materializes listeners: use each Source's drained
234
+ items or scan each inbox, claim new items by id (registry-first, so a lost tick
235
+ cannot double-claim), then either spawn a pod from the listener's template —
236
+ bounded by `--max-concurrent` and the per-project pod cap — or append a batch
237
+ to the bound standing pod and use the ordinary capacity-checked wake path.
229
238
 
230
239
  An inbox is a directory (one item per top-level non-dot file; claimable once
231
240
  its mtime and size hold across two passes) or one append-only JSONL file
232
- (`body` required; malformed lines skipped with a warning). Item ids are
233
- filenames or line numbers — never content — claimed per inbox; templates
234
- bind `{{id}}` `{{title}}` `{{body}}` `{{path}}` (ADR 0013).
241
+ (`body` required, `title` optional; malformed lines skipped with a warning).
242
+ A JSONL line may also carry the routing fields `chat_id`, `root_id` and
243
+ `thread_id`; any other key is ignored. Item ids are filenames or line
244
+ numbers — never content — claimed per inbox; templates bind five
245
+ placeholders: `{{id}}` `{{title}}` `{{body}}` `{{path}}` and `{{reply_to}}`.
246
+ The last placeholder carries the Delivery Address consumed by the outbound
247
+ contract below (ADRs 0013, 0026).
235
248
 
236
249
  For a standing pod, claimed items land in its `TASKS.md` `## Inbox`
237
- section — appended only while no wake-up is live, then one batch, one wake
238
- on the ordinary wake path; the agent serves entries and flips their
239
- checkboxes (ADR 0014).
250
+ section — below the agent-owned `## Waiting`, and appended only while no
251
+ wake-up is live, then one batch, one wake on the ordinary wake path; the
252
+ agent serves entries and flips their checkboxes. A block with a Delivery
253
+ Address carries ` — to: <Delivery Address>` on its checkbox line, never in
254
+ the copied body (ADRs 0014, 0026).
240
255
 
241
256
  Overlapping passes: one TTL'd single-flight lock (`reconcile.lock`, stale
242
257
  after 5 minutes by wall clock — never PID liveness) skips fresh-held ticks
@@ -249,6 +264,164 @@ and done — through the copied channels (webhook/email/system) in
249
264
  `~/.agents/pods/config.json`; once per episode; individual outcomes
250
265
  never notify (ADR 0021).
251
266
 
267
+ ## IM intake and outbound
268
+
269
+ The operator owns platform I/O. A **Source** is an executable pull drain; the
270
+ single **deliverer** is an executable push command. Source definitions and
271
+ their committed cursors live in `~/.agents/pods/sources.json`; Listener claims
272
+ live in `~/.agents/pods/listeners.json`; the deliverer lives in
273
+ `~/.agents/pods/deliverer.json`. `source add` and `deliver add` resolve `--cmd`
274
+ against the add command's cwd and require an executable regular file.
275
+ `--timeout` is a positive number of seconds, default `10`. Their add/list/rm
276
+ commands return `0` on success; invalid arguments, an unknown or duplicate
277
+ name, removing an in-use Source, or adding a second deliverer returns `2`.
278
+
279
+ `listener add --source <name>` takes either `--template <file>` or
280
+ `--pod <podId>`. With `--template`, `--project <dir>` selects the new Pods'
281
+ project and defaults to the reconcile invocation's cwd; with `--pod` it is a
282
+ configuration error (exit `2`), because the bound Pod keeps its own project.
283
+
284
+ ### Writing a Source drain
285
+
286
+ Each reconcile pass invokes every distinct Source consumed by a Listener once,
287
+ before any Pod work and before taking the registry lock. The command receives
288
+ no arguments. Its cwd is the reconcile invocation's project (its cwd, not a
289
+ Listener's `--project`), and it inherits that invocation's environment plus:
290
+
291
+ - `AGENTS_POD_SOURCE_ID` — the registered Source name;
292
+ - `AGENTS_POD_CURSOR` — the last committed cursor, or the empty string when no
293
+ cursor has been committed.
294
+
295
+ The command writes JSONL to stdout; stdin is closed and stderr is ignored. A
296
+ successful stream contains zero or more items followed by an optional cursor
297
+ as the last non-empty line:
298
+
299
+ ```jsonl
300
+ {"id":"message-123","body":"Please review this","title":"Review request","chat_id":"chat-1","root_id":"root-1","thread_id":"thread-1"}
301
+ {"cursor":"next-page-token"}
302
+ ```
303
+
304
+ - `id` is a non-empty string and `body` is a string; both are required. The id
305
+ is the platform's stable deduplication key.
306
+ - `title` is optional. A missing or blank title defaults to the trimmed first
307
+ line of `body`.
308
+ - `chat_id`, `root_id` and `thread_id` are optional routing strings. Non-empty
309
+ values are trimmed; blank or non-string values are ignored. They feed
310
+ `{{reply_to}}`. Source items have no filesystem path, so `{{path}}` is empty.
311
+ Unknown keys are ignored.
312
+ - The final `{"cursor":"…"}` line takes a string. Omitting it leaves the
313
+ stored cursor unchanged. Blank lines are ignored. A malformed JSON line, an
314
+ invalid item, or an invalid final cursor is skipped with one
315
+ `~/.agents/pods/cron.log` warning and is not claimed; other valid lines still
316
+ count.
317
+
318
+ Exit `0` commits the successful drain. Claims use `source:<name>` as their
319
+ inbox namespace and the item's platform id, shared by every Listener on that
320
+ Source. Claims are written to `listeners.json` before the Source's cursor is
321
+ atomically written to `sources.json`, so a crash between those writes replays
322
+ into id deduplication. A committed drain also advances the record's
323
+ `lastDrainAt`; claims from the current and previous successful drain
324
+ generations are retained and older timestamped claims are pruned. Claims with
325
+ no generation timestamp are retained. For a Source bound to a standing Pod,
326
+ the claimed batch must also be appended to `TASKS.md` before cursor and
327
+ `lastDrainAt` commit; a live wake-up holds that append, so the next pass drains
328
+ from the unchanged cursor again.
329
+
330
+ A non-zero exit, termination by a signal, spawn failure, timeout, or total
331
+ stdout larger than **1 MiB** abandons the whole drain: no items are claimed,
332
+ neither cursor nor `lastDrainAt` moves, no claims are pruned, and exactly one
333
+ `cron.log` line records the failure. Reconcile still exits `0`; the next pass
334
+ retries the whole drain from the same cursor (ADR 0024).
335
+
336
+ ### Writing the deliverer
337
+
338
+ Exactly one deliverer may be configured; remove it before adding a replacement.
339
+ A Wake-up writes one JSON file per outgoing message in its own Pod:
340
+
341
+ ```text
342
+ <project>/.agents/pods/<podId>/outbox/<wakeup>-<seq>-<name>.json
343
+ ```
344
+
345
+ `<wakeup>` is the positive integer wake-up number, `<seq>` is a zero-padded
346
+ increasing sequence such as `001`, `002`, and `<name>` is a filename-safe slug.
347
+ Files are delivered serially in filename order. Each file contains a JSON
348
+ object with a non-empty string `to` and a string `text`:
349
+
350
+ ```json
351
+ {"to":"<Delivery Address>","text":"The reply text"}
352
+ ```
353
+
354
+ `to` is the **Delivery Address** rendered by `{{reply_to}}` or carried on a
355
+ standing Pod's Inbox checkbox line. The worker copies it verbatim; the
356
+ deliverer parses it; core never reads it back. Its shape is:
357
+
358
+ ```text
359
+ chat=<chat_id>;affinity=<root_id ?? item id>[;thread=<thread_id>]
360
+ ```
361
+
362
+ An item with no `chat_id` renders the empty string, which is not a valid `to`
363
+ and cannot be delivered. When `thread` is present, the deliverer can use it to
364
+ reply in that thread.
365
+
366
+ The Wrapper invokes the deliverer once per current-wake-up entry after the
367
+ agent exits and before the Pod becomes idle. Reconcile retries entries for
368
+ every non-active Pod, outside the registry lock. Each invocation receives the
369
+ entry file's bytes verbatim on stdin, takes no arguments, runs with the Pod's
370
+ project as cwd, and ignores stdout and stderr. It inherits the operator
371
+ environment plus exactly these core-provided values:
372
+
373
+ - `AGENTS_POD_POD_ID` — the owning Pod id;
374
+ - `AGENTS_POD_WAKEUP_N` — the wake-up number parsed from the filename;
375
+ - `AGENTS_POD_OUTBOX_ENTRY` — the entry filename, not its full path;
376
+ - `AGENTS_POD_OUTBOX_UUID` — the 50-character lowercase hexadecimal idempotency
377
+ key derived from Pod id, wake-up number, and filename. Pass this same value
378
+ to the platform on every retry.
379
+
380
+ One flush per Pod makes at most **10 delivery attempts** within a shared
381
+ **10-second budget**, in filename order. The configured per-invocation timeout
382
+ defaults to 10 seconds and is additionally capped by the flush's remaining
383
+ budget. Entries left beyond either bound wait for a later pass.
384
+
385
+ Exit `0` is the only acknowledgement: core deletes the entry and writes no
386
+ completion marker. A non-zero exit, signal, spawn failure, or timeout leaves
387
+ the file unchanged, writes one `cron.log` line, and lets the next pass retry
388
+ with the same uuid. Delivery failure does not change the Wake-up outcome,
389
+ failure streak, or notifications.
390
+
391
+ An entry whose mtime is more than **one hour** old is not delivered: core
392
+ renames it by appending `.dead`, logs one line, and keeps its contents
393
+ unchanged. A malformed entry, a filename with no valid leading wake-up number,
394
+ or a future-wake-up entry seen by a Wrapper is likewise dead-lettered on first
395
+ sight. `*.dead` files are never scanned again or automatically removed. With
396
+ no deliverer configured, the Wrapper flush is a no-op; each reconcile pass
397
+ writes one `cron.log` line per Pod with waiting entries, and the same one-hour
398
+ aging still applies (ADR 0025).
399
+
400
+ ### Role Instructions for replying agents
401
+
402
+ Save the following as the replying Agent's instructions file and pass it to
403
+ `agent add --instructions <file>`:
404
+
405
+ ```text
406
+ When you reply to a Conversation, write one JSON file per reply into your own
407
+ Pod's outbox at .agents/pods/$AGENTS_POD_POD_ID/outbox/ under the Pod project.
408
+ Name each file $AGENTS_POD_WAKEUP_N-<seq>-<name>.json, using a zero-padded
409
+ increasing <seq> (001, 002, ...) and a filename-safe <name>. Write
410
+ {"to":"<Delivery Address>","text":"<reply>"}; copy the address verbatim from
411
+ Reply-To in the Goal or from "to:" on the item's TASKS.md Inbox checkbox line.
412
+ Do not write an entry when that address is empty.
413
+
414
+ If you ask a question in a thread, reply in that same thread by reusing its
415
+ Delivery Address, including the thread component. Ask at most two clarification
416
+ rounds per uncertainty; then proceed on a stated assumption and record that
417
+ assumption in HANDOFF.md.
418
+
419
+ Core deletes an entry only after the deliverer exits 0. A failed delivery keeps
420
+ the file, logs one line, and is retried on the next pass with the same uuid; an
421
+ entry older than one hour is renamed with a .dead suffix and kept. Do not
422
+ rename, delete, or rewrite pending or dead entries yourself.
423
+ ```
424
+
252
425
  ## Storage
253
426
 
254
427
  ```text
@@ -256,12 +429,15 @@ never notify (ADR 0021).
256
429
  ├── pods.json # global pod registry: definitions + state, 0600
257
430
  ├── agents.json # named agents: adapter type, model, role instructions, 0600
258
431
  ├── listeners.json # listeners, bounds, claimed item ids, 0600
432
+ ├── sources.json # named drain commands, committed cursors, lastDrainAt
433
+ ├── deliverer.json # the single outbound delivery command
259
434
  ├── inboxes/<id>/ # default listener inboxes; any dir/file may serve instead
260
435
  └── registry.lock # short-lived registry write lock, 0600
261
436
 
262
437
  <project>/.agents/pods/
263
438
  └── <podId>/
264
- ├── docs/ # HANDOFF.md · DECISIONS.md · TASKS.md — the only cross-wake state (ADR 0008)
439
+ ├── docs/ # HANDOFF.md · DECISIONS.md · TASKS.md — cross-wake work memory (ADR 0008)
440
+ ├── outbox/ # pending <wakeup>-<seq>-<name>.json replies and retained *.dead entries
265
441
  ├── output.log # every wake-up, append-only, wake-up delimited; trimmed idle by
266
442
  │ # reconcile: cap 10 MB, keep 2 MB, oldest sections dropped (ADR 0016), 0600
267
443
  ├── heartbeat # touched by the live wake-up's wrapper every 30s; mtime = last beat;
@@ -317,7 +493,18 @@ adapted from agents-cron); listeners — inbox directory and JSONL file
317
493
  sources, per-item pods from templates, bound standing pods; wake admission
318
494
  honouring `--after` dependencies and the per-project cap; the Pi extension's
319
495
  two planes (ADR 0010) — `/pod` commands, `spawn_pod`, and `pod_handoff`.
320
- No default timeout anywhere — liveness is heartbeat-based or it is nothing.
496
+ No default Wake-up timeout — liveness is heartbeat-based or it is nothing.
497
+
498
+ **Shipped in `0.2.0`.** Chat as an intake and an outlet, both as operator
499
+ commands rather than a daemon: sources (`source add|list|rm`) drained once per
500
+ pass with a committed cursor and claim-before-cursor ordering; source-fed
501
+ Listeners, batched `## Inbox` delivery to a standing pod, and `## Waiting` above
502
+ it; outbox delivery (`deliver add|list|rm`) at wake-up exit and in every pass,
503
+ with stable uuids, one-hour dead-lettering and retained `*.dead` entries; the
504
+ Affinity Key and the `{{reply_to}}` Delivery Address riding the Goal or an Inbox
505
+ line; `listener add --project` for tuning intake and workers apart. A wrapper's
506
+ outcome is now recorded under the registry lock, so a concurrent pass or wrapper
507
+ cannot overwrite it and force the same wake-up to run twice (ADR 0018).
321
508
 
322
509
  **Next.** The Claude Code and Codex adapters (`--type` is refused today, not
323
510
  silently downgraded). Default pod expiry and guarded fan-out — the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-pod",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Supervision for long-running coding agents: detached pods, heartbeat liveness, cron-ticked reconcile, and a Pi extension for spawning and handoff",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "type": "module",