@agentproto/agent-runtime 0.1.0-alpha.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.
@@ -0,0 +1,301 @@
1
+ # Architecture — @agentproto/agent-runtime
2
+
3
+ A small port/adapter kernel for running swarms of agents over swappable
4
+ conversation substrates. The kernel is transport-agnostic — it knows
5
+ nothing about specific chat servers, dispatchers, or executors; it only
6
+ routes through port interfaces.
7
+
8
+ ## The one cycle
9
+
10
+ ```
11
+ ┌───────────────────┐
12
+ │ substrate.read │ 1. snapshot recent turns
13
+ └─────────┬─────────┘
14
+
15
+
16
+ ┌───────────────────────────┐
17
+ │ dispatcher.selectNext() │ 2. decide who speaks next
18
+ └─────────────┬─────────────┘
19
+ │ participantIds[]
20
+ ┌───────────┴───────────┐
21
+ │ │
22
+ ▼ ▼
23
+ ┌──────────────────┐ ┌──────────────────┐
24
+ │ state.read(p) │ │ executor.execute │ 3. produce reply
25
+ └────────┬─────────┘ │ Turn │
26
+ │ └────────┬─────────┘
27
+ └──────────► input ─────┘
28
+
29
+
30
+ ┌────────────────────────┐
31
+ │ substrate.append() │ 4. write reply turn
32
+ └────────────┬───────────┘
33
+
34
+
35
+ ┌─────────────────────┐
36
+ │ state.write(p) │ 5. persist state diff
37
+ └──────────┬──────────┘
38
+
39
+
40
+ ┌────────────────────────┐
41
+ │ lifecycle.onTurnEnd │ 6. fire hooks
42
+ └────────────────────────┘
43
+ ```
44
+
45
+ `runTurn(ports, opts)` executes one cycle. Callers loop it for
46
+ continuous operation. The kernel has no concept of "running" — it's
47
+ strictly synchronous-per-cycle. Long-running behavior lives in the
48
+ caller (the `agentproto run-swarm` verb wraps it in a `do-while`).
49
+
50
+ ## Ports
51
+
52
+ ### `Substrate`
53
+
54
+ The conversation store. Append-only, oldest-first read semantics.
55
+
56
+ ```ts
57
+ interface Substrate {
58
+ readonly kind: string
59
+ append(turn: TurnInput): Promise<Turn>
60
+ read(since?: TurnId): Promise<readonly Turn[]>
61
+ }
62
+ ```
63
+
64
+ Reference adapter: `FileSubstrate` — a markdown journal at a path. Each
65
+ turn is delimited by `=== TURN id=… participant=… ts=… ===`.
66
+
67
+ Other implementations: chat servers (Slack, Discord), MCP-bridged
68
+ threads, REST APIs. Ship as plugin packages.
69
+
70
+ **`since` contract:** if `since` is provided but not in the window the
71
+ adapter fetched, the adapter SHOULD throw (telling the caller to raise
72
+ its fetch window) rather than silently returning everything — otherwise
73
+ the dispatcher's cursor can re-fire on already-handled turns.
74
+
75
+ ### `Dispatcher`
76
+
77
+ Decides which participants speak next, given the recent turns and the
78
+ participant roster.
79
+
80
+ ```ts
81
+ interface Dispatcher {
82
+ readonly kind: string
83
+ selectNext(input: DispatcherInput): Promise<readonly ParticipantId[]>
84
+ }
85
+ ```
86
+
87
+ Reference adapter: `MentionDispatcher` — selects participants whose
88
+ `displayName` is `@-mentioned` in the most recent turn. Tracks an
89
+ in-memory cursor so it doesn't re-fire on the same trigger across
90
+ cycles.
91
+
92
+ Other implementations: round-robin, topic-routed, LLM-decided. The
93
+ dispatcher is the pluggable "who speaks next?" decision.
94
+
95
+ ### `ParticipantExecutor`
96
+
97
+ Produces a turn for a given participant when the dispatcher selects
98
+ them.
99
+
100
+ ```ts
101
+ interface ParticipantExecutor {
102
+ readonly kind: string
103
+ executeTurn(input: ParticipantExecuteInput): Promise<ParticipantExecuteOutput>
104
+ }
105
+ ```
106
+
107
+ Reference adapter: `AgentCliParticipant` — spawns a CLI binary (`claude
108
+ --print`, `hermes -p`, etc.), pipes the assembled prompt over stdin,
109
+ returns parsed stdout as the turn body.
110
+
111
+ Other implementations: in-process LLM SDK calls, server-side delegation
112
+ (e.g. MCP `run_operator`), webhook-driven external workers.
113
+
114
+ Each participant in the manifest declares an `executor` kind; the
115
+ runtime keeps one executor instance per kind, shared across
116
+ participants of that kind.
117
+
118
+ ### `StateStore`
119
+
120
+ Per-participant scratch state, persisted across turns.
121
+
122
+ ```ts
123
+ interface StateStore {
124
+ readonly kind: string
125
+ read(participantId: ParticipantId): Promise<Readonly<Record<string, unknown>>>
126
+ write(participantId: ParticipantId, state: Readonly<Record<string, unknown>>): Promise<void>
127
+ }
128
+ ```
129
+
130
+ Reference adapter: `FileStateStore` — one JSON file per participant.
131
+
132
+ The state is what the executor returns in `stateUpdate`; the kernel
133
+ writes it after the substrate append. Useful for executors that
134
+ maintain working memory between turns (counters, scratchpads,
135
+ operator-context summaries).
136
+
137
+ ### `Lifecycle` (optional)
138
+
139
+ Sparse hooks fired at the edges of a cycle. Adapters opt in by
140
+ implementing some/all callbacks.
141
+
142
+ ```ts
143
+ interface Lifecycle {
144
+ onTurnEnd?(turn: Turn): Promise<void> | void
145
+ onMention?(target: ParticipantId, byTurn: Turn): Promise<void> | void
146
+ onIdle?(): Promise<void> | void
147
+ }
148
+ ```
149
+
150
+ Useful for: domain hooks where the caller needs to react to a turn
151
+ (e.g. mirror it elsewhere). For structured observability of every
152
+ phase boundary, use the `Telemetry` port below instead.
153
+
154
+ ### `Telemetry` (optional)
155
+
156
+ A single sink for structured per-phase events:
157
+
158
+ ```ts
159
+ interface Telemetry {
160
+ emit(event: TelemetryEvent): void
161
+ }
162
+ ```
163
+
164
+ Every event carries `cycleId` + `at`; sinks group on cycleId to
165
+ rebuild OTEL-style spans. Event kinds emitted per cycle:
166
+ `cycle.started`, `substrate.read`, `dispatch.decided`,
167
+ `participant.started`/`finished`/`failed`, `substrate.appended`,
168
+ `state.written`, `cycle.idle`, `cycle.finished`.
169
+
170
+ Reference adapters in
171
+ `@agentproto/agent-runtime/adapters/telemetry`:
172
+
173
+ - `noopTelemetry` — silent default
174
+ - `stderrTelemetry({ prefix?, include?, exclude? })` — one human-readable line per event
175
+ - `arrayTelemetry()` — in-memory, for tests
176
+ - `composeTelemetry(...sinks)` — fan-out
177
+
178
+ Wire your own (OTEL exporter, JSON-lines log, metrics counter, …) by
179
+ implementing the interface. Sinks that throw are isolated — kernel
180
+ never crashes a cycle on a telemetry failure.
181
+
182
+ ## Composition
183
+
184
+ `RuntimePorts` is the tuple the kernel runs against:
185
+
186
+ ```ts
187
+ type RuntimePorts = {
188
+ substrate: Substrate
189
+ dispatcher: Dispatcher
190
+ state: StateStore
191
+ lifecycle?: Lifecycle
192
+ participants: readonly ParticipantDescriptor[]
193
+ executors: ReadonlyMap<string, ParticipantExecutor>
194
+ }
195
+ ```
196
+
197
+ Built by the caller. The reference path is `@agentproto/cli`'s
198
+ `run-swarm` verb — it reads a manifest, looks up each `kind` in the
199
+ runtime registry (`@agentproto/cli/registry/runtime`), and builds the
200
+ ports tuple. The kernel itself never matches `kind` strings; that
201
+ happens in the wiring layer.
202
+
203
+ ## Manifest
204
+
205
+ Markdown with YAML frontmatter (the same doctype convention
206
+ `@agentproto` uses elsewhere):
207
+
208
+ ```yaml
209
+ ---
210
+ schema: agentruntimes/v1
211
+ kind: MultiAgentRuntime
212
+ id: my-swarm
213
+ participants:
214
+ - id: reviewer
215
+ executor: agent-cli
216
+ displayName: Reviewer
217
+ role: ../.claude/agents/reviewer.md
218
+ substrate:
219
+ kind: file
220
+ path: ./conversation.md
221
+ dispatcher:
222
+ kind: mention
223
+ state:
224
+ kind: fs
225
+ dir: ./state
226
+ ---
227
+
228
+ Free-form documentation of what this swarm does.
229
+ ```
230
+
231
+ Each adapter block (`substrate`, `dispatcher`, `state`) carries a
232
+ `kind` plus arbitrary host-extension fields. The validating zod schema
233
+ (`manifest.ts`) uses `.loose()` on the adapter blocks — the kind's
234
+ factory reads its own typed fields off the block at build time.
235
+
236
+ ## Extension points
237
+
238
+ ### Adding a new substrate / dispatcher / executor / state store
239
+
240
+ Implement the relevant port interface. Then register through
241
+ `@agentproto/cli/registry/runtime`:
242
+
243
+ ```ts
244
+ // in your-plugin/src/index.ts
245
+ import { registerSubstrate } from "@agentproto/cli/registry/runtime"
246
+ import { MySubstrate } from "./my-substrate.js"
247
+
248
+ registerSubstrate("my-kind", (cfg, ctx) => {
249
+ return new MySubstrate({
250
+ foo: typeof cfg.foo === "string" ? cfg.foo : "default",
251
+ // …
252
+ })
253
+ })
254
+ ```
255
+
256
+ Users wire it via `--plugin <your-package>` on `agentproto run-swarm`,
257
+ or by listing it under `plugins[]` in `~/.agentproto/config.json`.
258
+
259
+ ### Adapter context
260
+
261
+ Each factory receives an `AdapterContext`:
262
+
263
+ ```ts
264
+ interface AdapterContext {
265
+ readonly baseDir: string // manifest's dir
266
+ registerCleanup(fn: () => Promise<void> | void): void
267
+ }
268
+ ```
269
+
270
+ `registerCleanup` is the place to register teardown for adapters that
271
+ hold disposable resources (MCP clients, sockets, child processes). The
272
+ CLI calls every registered callback in order when the swarm shuts down.
273
+
274
+ ## Invariants
275
+
276
+ - **The kernel never matches `kind` strings.** It dispatches off the
277
+ port interface (`substrate.append()`, `executor.executeTurn()`, etc.)
278
+ and looks executors up in the `executors` map. Kind resolution
279
+ happens in the wiring layer (the CLI), never in `runtime.ts`.
280
+ - **A single cycle is synchronous-per-step.** Substrate append, state
281
+ write, lifecycle hooks run sequentially within a cycle. Concurrency
282
+ across multiple selected participants is intentionally **not**
283
+ provided by the kernel — adapters can parallelise internally if they
284
+ want, but the contract is one-by-one.
285
+ - **Failure recovery is the caller's job.** `runTurn` propagates
286
+ exceptions. The CLI's run-loop catches them and continues; a library
287
+ user is free to do anything else.
288
+
289
+ ## What is intentionally NOT in scope
290
+
291
+ - **Tool / effector binding.** The kernel doesn't model "this
292
+ participant has these tools available." Executors handle that
293
+ themselves — the agent-cli executor passes whatever flags the CLI
294
+ needs; an in-process executor wires tools directly. If structured
295
+ binding becomes useful later, it'd go in `ParticipantDescriptor.meta`
296
+ or via a future `Effector` port.
297
+ - **Routing across substrates / federation.** One swarm = one
298
+ substrate. Federation (one operator participating in many
299
+ conversations) is the caller's composition concern.
300
+ - **Persistence schemas beyond `StateStore`.** Auditing, tracing,
301
+ evaluation runs — all live outside the kernel.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 agentproto contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,91 @@
1
+ # @agentproto/agent-runtime
2
+
3
+ MultiAgentRuntime kernel — a small port/adapter framework for running
4
+ swarms of agents over swappable conversation substrates.
5
+
6
+ **Status:** alpha.
7
+
8
+ ## What this is
9
+
10
+ A composition primitive, not a product. The kernel knows nothing
11
+ about specific transports, dispatchers, or executors — it only routes
12
+ through port interfaces. Mode (local file journal, hosted chat,
13
+ MCP-bridged thread, …) is decided entirely by the adapters the caller
14
+ wires in.
15
+
16
+ ## Ports
17
+
18
+ | Port | What it does |
19
+ | --------------------- | ------------------------------------------------------------------- |
20
+ | `Substrate` | Append-only conversation store. `append(turn)` + `read(since?)`. |
21
+ | `Dispatcher` | Decides which participants speak next given recent turns. |
22
+ | `ParticipantExecutor` | Produces a turn for a given participant on demand. |
23
+ | `StateStore` | Per-participant scratch state across turns. |
24
+ | `Lifecycle` | Optional hooks: `onTurnEnd`, `onMention`, `onIdle`. |
25
+ | `EffectorBinding` | Optional per-participant tool / MCP bindings (consumer-supplied). |
26
+
27
+ A `RuntimePorts` is a tuple of these. `runTurn(ports, options)` runs
28
+ one dispatcher cycle:
29
+
30
+ ```
31
+ read substrate → dispatch → execute selected participants → append → fire lifecycle
32
+ ```
33
+
34
+ Loop it for continuous operation.
35
+
36
+ ## Reference adapters
37
+
38
+ Shipped in this package under `@agentproto/agent-runtime/adapters/*`:
39
+
40
+ | Adapter | Kind | Notes |
41
+ | ---------------------------------------------------- | ------------ | -------------------------------------------------- |
42
+ | `substrate-file` | `file` | Append-only markdown journal at a given path. |
43
+ | `dispatcher-mention` | `mention` | Selects participants @-mentioned in the trigger. |
44
+ | `state-fs` | `fs` | One JSON file per participant. |
45
+ | `participant-agent-cli` | `agent-cli` | Spawns a CLI binary (`claude --print`, etc.). |
46
+
47
+ ## Manifest
48
+
49
+ A `MultiAgentRuntime` manifest is markdown with YAML frontmatter:
50
+
51
+ ```yaml
52
+ ---
53
+ schema: agentruntimes/v1
54
+ kind: MultiAgentRuntime
55
+ id: my-swarm
56
+ participants:
57
+ - id: reviewer
58
+ executor: agent-cli
59
+ displayName: Reviewer
60
+ role: ../.claude/agents/reviewer.md
61
+ substrate:
62
+ kind: file
63
+ path: ./conversation.md
64
+ dispatcher:
65
+ kind: mention
66
+ state:
67
+ kind: fs
68
+ dir: ./state
69
+ ---
70
+
71
+ Free-form documentation of what this swarm does.
72
+ ```
73
+
74
+ `@agentproto/cli`'s `run-swarm` verb loads such manifests, resolves
75
+ each `kind` string through its adapter registry, and runs cycles in a
76
+ loop. Third-party adapters register through `@agentproto/cli/registry/runtime`.
77
+
78
+ ## Extending
79
+
80
+ Implement any of the ports and ship it as your own package. To plug
81
+ into the CLI, export a module that calls `registerSubstrate` /
82
+ `registerDispatcher` / `registerExecutor` from
83
+ `@agentproto/cli/registry/runtime` at load time, then point users at
84
+ it with `--plugin <your-module-id>` or via `~/.agentproto/config.json`.
85
+
86
+ See [ARCHITECTURE.md](./ARCHITECTURE.md) for the port-by-port walk,
87
+ cycle diagram, and invariants.
88
+
89
+ ## License
90
+
91
+ MIT.
@@ -0,0 +1,38 @@
1
+ import { Dispatcher, DispatcherInput, ParticipantId } from '../ports.js';
2
+
3
+ /**
4
+ * Type sidecar for `mention-parser.mjs`. The implementation file is
5
+ * vanilla JavaScript so runtime profiles can stamp its source into
6
+ * Claude Code hooks at build time; this `.d.mts` keeps the TS-side
7
+ * type contract crisp.
8
+ */
9
+
10
+ declare function textContainsMention(text: string, name: string): boolean
11
+
12
+ /**
13
+ * Mention dispatcher — selects participants whose displayName is
14
+ * @-mentioned in the most recent turn.
15
+ *
16
+ * Skip rules:
17
+ * - never re-select the participant who authored the trigger turn
18
+ * (otherwise they'd reply to themselves indefinitely)
19
+ * - never dispatch on a trigger we already processed (in-memory cursor —
20
+ * prevents the loop from re-firing on the same @mention every poll
21
+ * when the kernel's own reply isn't yet visible in the next read).
22
+ * Per-dispatcher-instance state; a fresh swarm process starts with
23
+ * no cursor and naturally responds to the latest unhandled mention
24
+ * exactly once.
25
+ * - if no mentions match any known participant, return [] (idle)
26
+ *
27
+ * The parser itself lives in `../util/mention-parser.mjs` (vanilla JS)
28
+ * so runtime profiles can stamp the same implementation into Claude
29
+ * Code hooks at build time without depending on the TS build pipeline.
30
+ */
31
+
32
+ declare class MentionDispatcher implements Dispatcher {
33
+ readonly kind = "mention";
34
+ private lastProcessedTriggerId;
35
+ selectNext(input: DispatcherInput): Promise<readonly ParticipantId[]>;
36
+ }
37
+
38
+ export { MentionDispatcher, textContainsMention };
@@ -0,0 +1,45 @@
1
+ /**
2
+ * @agentproto/agent-runtime v0.1.0-alpha
3
+ * MultiAgentRuntime kernel — swappable ports + reference adapters
4
+ * (file substrate, mention dispatcher, fs state, agent-cli participant).
5
+ * Transport-specific adapters ship in separate packages.
6
+ */
7
+
8
+ // src/util/mention-parser.mjs
9
+ function textContainsMention(text, name) {
10
+ if (text.includes(`@${name}`)) return true;
11
+ if (name.includes(" ")) {
12
+ const firstName = name.split(" ")[0]?.trim();
13
+ if (!firstName) return false;
14
+ const regex = new RegExp(`@${firstName}(?!\\w)`, "i");
15
+ return regex.test(text);
16
+ }
17
+ return false;
18
+ }
19
+
20
+ // src/adapters/dispatcher-mention.ts
21
+ var MentionDispatcher = class {
22
+ kind = "mention";
23
+ lastProcessedTriggerId;
24
+ async selectNext(input) {
25
+ const trigger = input.recentTurns[input.recentTurns.length - 1];
26
+ if (!trigger) return [];
27
+ if (trigger.id === this.lastProcessedTriggerId) return [];
28
+ const text = trigger.content;
29
+ const selected = [];
30
+ for (const p of input.participants) {
31
+ if (p.id === trigger.participantId) continue;
32
+ if (textContainsMention(text, p.displayName)) {
33
+ selected.push(p.id);
34
+ }
35
+ }
36
+ if (selected.length > 0) {
37
+ this.lastProcessedTriggerId = trigger.id;
38
+ }
39
+ return selected;
40
+ }
41
+ };
42
+
43
+ export { MentionDispatcher, textContainsMention };
44
+ //# sourceMappingURL=dispatcher-mention.mjs.map
45
+ //# sourceMappingURL=dispatcher-mention.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/util/mention-parser.mjs","../../src/adapters/dispatcher-mention.ts"],"names":[],"mappings":";;;;;;;;AAuBO,SAAS,mBAAA,CAAoB,MAAM,IAAA,EAAM;AAC9C,EAAA,IAAI,KAAK,QAAA,CAAS,CAAA,CAAA,EAAI,IAAI,CAAA,CAAE,GAAG,OAAO,IAAA;AACtC,EAAA,IAAI,IAAA,CAAK,QAAA,CAAS,GAAG,CAAA,EAAG;AACtB,IAAA,MAAM,YAAY,IAAA,CAAK,KAAA,CAAM,GAAG,CAAA,CAAE,CAAC,GAAG,IAAA,EAAK;AAC3C,IAAA,IAAI,CAAC,WAAW,OAAO,KAAA;AACvB,IAAA,MAAM,QAAQ,IAAI,MAAA,CAAO,CAAA,CAAA,EAAI,SAAS,WAAW,GAAG,CAAA;AACpD,IAAA,OAAO,KAAA,CAAM,KAAK,IAAI,CAAA;AAAA,EACxB;AACA,EAAA,OAAO,KAAA;AACT;;;ACJO,IAAM,oBAAN,MAA8C;AAAA,EAC1C,IAAA,GAAO,SAAA;AAAA,EACR,sBAAA;AAAA,EAER,MAAM,WAAW,KAAA,EAA2D;AAC1E,IAAA,MAAM,UAAU,KAAA,CAAM,WAAA,CAAY,KAAA,CAAM,WAAA,CAAY,SAAS,CAAC,CAAA;AAC9D,IAAA,IAAI,CAAC,OAAA,EAAS,OAAO,EAAC;AACtB,IAAA,IAAI,OAAA,CAAQ,EAAA,KAAO,IAAA,CAAK,sBAAA,SAA+B,EAAC;AACxD,IAAA,MAAM,OAAO,OAAA,CAAQ,OAAA;AAErB,IAAA,MAAM,WAA4B,EAAC;AACnC,IAAA,KAAA,MAAW,CAAA,IAAK,MAAM,YAAA,EAAc;AAClC,MAAA,IAAI,CAAA,CAAE,EAAA,KAAO,OAAA,CAAQ,aAAA,EAAe;AACpC,MAAA,IAAI,mBAAA,CAAoB,IAAA,EAAM,CAAA,CAAE,WAAW,CAAA,EAAG;AAC5C,QAAA,QAAA,CAAS,IAAA,CAAK,EAAE,EAAE,CAAA;AAAA,MACpB;AAAA,IACF;AAEA,IAAA,IAAI,QAAA,CAAS,SAAS,CAAA,EAAG;AACvB,MAAA,IAAA,CAAK,yBAAyB,OAAA,CAAQ,EAAA;AAAA,IACxC;AACA,IAAA,OAAO,QAAA;AAAA,EACT;AACF","file":"dispatcher-mention.mjs","sourcesContent":["/**\n * Canonical @-mention parser.\n *\n * Lives as raw JavaScript (no types) because it's consumed in two\n * places that don't share a build:\n *\n * 1. The kernel imports `textContainsMention` from this file as a\n * normal ESM module (the dispatcher in `adapters/dispatcher-mention.ts`).\n *\n * 2. Runtime profiles inline this file's source into Claude Code\n * hooks at profile-build time, because `.claude/` has no\n * module-resolution at hook-execution time. The build script\n * reads this file as text and stamps it into the hook template.\n *\n * Two cases:\n * 1. Literal `@<name>` substring (case-sensitive, full name).\n * 2. For multi-word names, also `@<firstName>` with non-word\n * boundary (case-insensitive).\n *\n * Edit here only — both consumers stay in sync via build pipelines.\n */\n\n/** @param {string} text @param {string} name @returns {boolean} */\nexport function textContainsMention(text, name) {\n if (text.includes(`@${name}`)) return true\n if (name.includes(\" \")) {\n const firstName = name.split(\" \")[0]?.trim()\n if (!firstName) return false\n const regex = new RegExp(`@${firstName}(?!\\\\w)`, \"i\")\n return regex.test(text)\n }\n return false\n}\n","/**\n * Mention dispatcher — selects participants whose displayName is\n * @-mentioned in the most recent turn.\n *\n * Skip rules:\n * - never re-select the participant who authored the trigger turn\n * (otherwise they'd reply to themselves indefinitely)\n * - never dispatch on a trigger we already processed (in-memory cursor —\n * prevents the loop from re-firing on the same @mention every poll\n * when the kernel's own reply isn't yet visible in the next read).\n * Per-dispatcher-instance state; a fresh swarm process starts with\n * no cursor and naturally responds to the latest unhandled mention\n * exactly once.\n * - if no mentions match any known participant, return [] (idle)\n *\n * The parser itself lives in `../util/mention-parser.mjs` (vanilla JS)\n * so runtime profiles can stamp the same implementation into Claude\n * Code hooks at build time without depending on the TS build pipeline.\n */\n\nimport type {\n Dispatcher,\n DispatcherInput,\n ParticipantId,\n TurnId,\n} from \"../ports.js\"\nimport { textContainsMention } from \"../util/mention-parser.mjs\"\n\nexport class MentionDispatcher implements Dispatcher {\n readonly kind = \"mention\"\n private lastProcessedTriggerId: TurnId | undefined\n\n async selectNext(input: DispatcherInput): Promise<readonly ParticipantId[]> {\n const trigger = input.recentTurns[input.recentTurns.length - 1]\n if (!trigger) return []\n if (trigger.id === this.lastProcessedTriggerId) return []\n const text = trigger.content\n\n const selected: ParticipantId[] = []\n for (const p of input.participants) {\n if (p.id === trigger.participantId) continue\n if (textContainsMention(text, p.displayName)) {\n selected.push(p.id)\n }\n }\n\n if (selected.length > 0) {\n this.lastProcessedTriggerId = trigger.id\n }\n return selected\n }\n}\n\n// Re-exported so callers writing their own adapters can detect\n// mentions with identical semantics.\nexport { textContainsMention }\n"]}
@@ -0,0 +1,32 @@
1
+ import { ParticipantExecutor, ParticipantExecuteInput, ParticipantExecuteOutput } from '../ports.js';
2
+ export { parseClaudeJsonOutput } from '@agentproto/cli-exec';
3
+
4
+ /**
5
+ * Agent-CLI participant — spawns a CLI binary, pipes the assembled prompt
6
+ * over stdin, and uses captured stdout as the turn content.
7
+ *
8
+ * Works for any CLI that supports a one-shot "read prompt from stdin,
9
+ * print response to stdout" mode (claude-code's `--print`, hermes' `-p`,
10
+ * etc.).
11
+ */
12
+
13
+ type AgentCliParticipantOptions = {
14
+ /** Executable to invoke. Examples: "claude", "hermes", "goose". */
15
+ readonly command: string;
16
+ /** Static args. The prompt is fed over stdin, not as an argument. */
17
+ readonly args?: readonly string[];
18
+ /** Working directory. Defaults to process.cwd(). */
19
+ readonly cwd?: string;
20
+ /** Hard timeout in ms. Default 90000. */
21
+ readonly timeoutMs?: number;
22
+ /** Optional output parser — if returns null, content falls back to raw stdout. */
23
+ readonly parseOutput?: (stdout: string) => string | null;
24
+ };
25
+ declare class AgentCliParticipant implements ParticipantExecutor {
26
+ private readonly opts;
27
+ readonly kind = "agent-cli";
28
+ constructor(opts: AgentCliParticipantOptions);
29
+ executeTurn(input: ParticipantExecuteInput): Promise<ParticipantExecuteOutput>;
30
+ }
31
+
32
+ export { AgentCliParticipant, type AgentCliParticipantOptions };
@@ -0,0 +1,72 @@
1
+ import { readFile } from 'fs/promises';
2
+ import matter from 'gray-matter';
3
+ import { spawnWithStdin } from '@agentproto/cli-exec';
4
+ export { parseClaudeJsonOutput } from '@agentproto/cli-exec';
5
+
6
+ /**
7
+ * @agentproto/agent-runtime v0.1.0-alpha
8
+ * MultiAgentRuntime kernel — swappable ports + reference adapters
9
+ * (file substrate, mention dispatcher, fs state, agent-cli participant).
10
+ * Transport-specific adapters ship in separate packages.
11
+ */
12
+
13
+ var AgentCliParticipant = class {
14
+ constructor(opts) {
15
+ this.opts = opts;
16
+ }
17
+ opts;
18
+ kind = "agent-cli";
19
+ async executeTurn(input) {
20
+ const prompt = await assemblePrompt(input);
21
+ const stdout = await spawnWithStdin({
22
+ command: this.opts.command,
23
+ args: this.opts.args ?? [],
24
+ cwd: this.opts.cwd ?? process.cwd(),
25
+ timeoutMs: this.opts.timeoutMs ?? 9e4,
26
+ stdin: prompt,
27
+ signal: input.signal
28
+ });
29
+ const parsed = this.opts.parseOutput?.(stdout) ?? null;
30
+ const content = parsed ?? stdout.trimEnd();
31
+ return { content };
32
+ }
33
+ };
34
+ async function assemblePrompt(input) {
35
+ const roleText = input.participant.role ? await loadRole(input.participant.role) : "";
36
+ const transcript = input.recentTurns.map((t) => `[${t.participantId}] ${t.content}`).join("\n\n");
37
+ return [
38
+ roleText && `# Your role
39
+
40
+ ${roleText}`,
41
+ `# Recent conversation
42
+
43
+ ${transcript}`,
44
+ `# Your turn
45
+
46
+ You are ${input.participant.displayName}. The latest message in the conversation triggered you (most likely because it mentions you). Read the transcript above and reply in character. Keep it conversational unless the trigger asks for detailed work. Output only your reply \u2014 no preamble, no role labels, no quotes around the response.`
47
+ ].filter(Boolean).join("\n\n");
48
+ }
49
+ var ROLE_FILE_EXTENSIONS = [".md", ".markdown", ".txt"];
50
+ async function loadRole(roleField) {
51
+ if (!looksLikeRoleFile(roleField)) return roleField;
52
+ try {
53
+ const raw = await readFile(roleField, "utf8");
54
+ const parsed = matter(raw);
55
+ return parsed.content.trim();
56
+ } catch (err) {
57
+ const code = err.code;
58
+ process.stderr.write(
59
+ `agent-cli participant: role path '${roleField}' not readable (${code ?? "unknown"}); using the literal string as inline role text.
60
+ `
61
+ );
62
+ return roleField;
63
+ }
64
+ }
65
+ function looksLikeRoleFile(s) {
66
+ const lower = s.toLowerCase();
67
+ return ROLE_FILE_EXTENSIONS.some((ext) => lower.endsWith(ext));
68
+ }
69
+
70
+ export { AgentCliParticipant };
71
+ //# sourceMappingURL=participant-agent-cli.mjs.map
72
+ //# sourceMappingURL=participant-agent-cli.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/adapters/participant-agent-cli.ts"],"names":[],"mappings":";;;;;;;;;;;;AAmCO,IAAM,sBAAN,MAAyD;AAAA,EAG9D,YAA6B,IAAA,EAAkC;AAAlC,IAAA,IAAA,CAAA,IAAA,GAAA,IAAA;AAAA,EAAmC;AAAA,EAAnC,IAAA;AAAA,EAFpB,IAAA,GAAO,WAAA;AAAA,EAIhB,MAAM,YACJ,KAAA,EACmC;AACnC,IAAA,MAAM,MAAA,GAAS,MAAM,cAAA,CAAe,KAAK,CAAA;AACzC,IAAA,MAAM,MAAA,GAAS,MAAM,cAAA,CAAe;AAAA,MAClC,OAAA,EAAS,KAAK,IAAA,CAAK,OAAA;AAAA,MACnB,IAAA,EAAM,IAAA,CAAK,IAAA,CAAK,IAAA,IAAQ,EAAC;AAAA,MACzB,GAAA,EAAK,IAAA,CAAK,IAAA,CAAK,GAAA,IAAO,QAAQ,GAAA,EAAI;AAAA,MAClC,SAAA,EAAW,IAAA,CAAK,IAAA,CAAK,SAAA,IAAa,GAAA;AAAA,MAClC,KAAA,EAAO,MAAA;AAAA,MACP,QAAQ,KAAA,CAAM;AAAA,KACf,CAAA;AACD,IAAA,MAAM,MAAA,GAAS,IAAA,CAAK,IAAA,CAAK,WAAA,GAAc,MAAM,CAAA,IAAK,IAAA;AAClD,IAAA,MAAM,OAAA,GAAU,MAAA,IAAU,MAAA,CAAO,OAAA,EAAQ;AACzC,IAAA,OAAO,EAAE,OAAA,EAAQ;AAAA,EACnB;AACF;AAEA,eAAe,eACb,KAAA,EACiB;AACjB,EAAA,MAAM,QAAA,GAAW,MAAM,WAAA,CAAY,IAAA,GAC/B,MAAM,QAAA,CAAS,KAAA,CAAM,WAAA,CAAY,IAAI,CAAA,GACrC,EAAA;AAEJ,EAAA,MAAM,UAAA,GAAa,KAAA,CAAM,WAAA,CACtB,GAAA,CAAI,CAAC,CAAA,KAAM,CAAA,CAAA,EAAI,CAAA,CAAE,aAAa,KAAK,CAAA,CAAE,OAAO,CAAA,CAAE,CAAA,CAC9C,KAAK,MAAM,CAAA;AAEd,EAAA,OAAO;AAAA,IACL,QAAA,IAAY,CAAA;;AAAA,EAAkB,QAAQ,CAAA,CAAA;AAAA,IACtC,CAAA;;AAAA,EAA4B,UAAU,CAAA,CAAA;AAAA,IACtC,CAAA;;AAAA,QAAA,EAA0B,KAAA,CAAM,YAAY,WAAW,CAAA,2SAAA;AAAA,GACzD,CACG,MAAA,CAAO,OAAO,CAAA,CACd,KAAK,MAAM,CAAA;AAChB;AAKA,IAAM,oBAAA,GAAuB,CAAC,KAAA,EAAO,WAAA,EAAa,MAAM,CAAA;AAExD,eAAe,SAAS,SAAA,EAAoC;AAC1D,EAAA,IAAI,CAAC,iBAAA,CAAkB,SAAS,CAAA,EAAG,OAAO,SAAA;AAC1C,EAAA,IAAI;AACF,IAAA,MAAM,GAAA,GAAM,MAAM,QAAA,CAAS,SAAA,EAAW,MAAM,CAAA;AAI5C,IAAA,MAAM,MAAA,GAAS,OAAO,GAAG,CAAA;AACzB,IAAA,OAAO,MAAA,CAAO,QAAQ,IAAA,EAAK;AAAA,EAC7B,SAAS,GAAA,EAAK;AAKZ,IAAA,MAAM,OAAQ,GAAA,CAA8B,IAAA;AAC5C,IAAA,OAAA,CAAQ,MAAA,CAAO,KAAA;AAAA,MACb,CAAA,kCAAA,EAAqC,SAAS,CAAA,gBAAA,EAAmB,IAAA,IAAQ,SAAS,CAAA;AAAA;AAAA,KACpF;AACA,IAAA,OAAO,SAAA;AAAA,EACT;AACF;AAEA,SAAS,kBAAkB,CAAA,EAAoB;AAC7C,EAAA,MAAM,KAAA,GAAQ,EAAE,WAAA,EAAY;AAC5B,EAAA,OAAO,qBAAqB,IAAA,CAAK,CAAC,QAAQ,KAAA,CAAM,QAAA,CAAS,GAAG,CAAC,CAAA;AAC/D","file":"participant-agent-cli.mjs","sourcesContent":["/**\n * Agent-CLI participant — spawns a CLI binary, pipes the assembled prompt\n * over stdin, and uses captured stdout as the turn content.\n *\n * Works for any CLI that supports a one-shot \"read prompt from stdin,\n * print response to stdout\" mode (claude-code's `--print`, hermes' `-p`,\n * etc.).\n */\n\nimport { readFile } from \"node:fs/promises\"\nimport matter from \"gray-matter\"\nimport { spawnWithStdin } from \"@agentproto/cli-exec\"\nimport type {\n ParticipantExecuteInput,\n ParticipantExecuteOutput,\n ParticipantExecutor,\n} from \"../ports.js\"\n\n// Re-exported for back-compat: the JSON-envelope parser now lives in the shared\n// @agentproto/cli-exec package alongside the spawn helper.\nexport { parseClaudeJsonOutput } from \"@agentproto/cli-exec\"\n\nexport type AgentCliParticipantOptions = {\n /** Executable to invoke. Examples: \"claude\", \"hermes\", \"goose\". */\n readonly command: string\n /** Static args. The prompt is fed over stdin, not as an argument. */\n readonly args?: readonly string[]\n /** Working directory. Defaults to process.cwd(). */\n readonly cwd?: string\n /** Hard timeout in ms. Default 90000. */\n readonly timeoutMs?: number\n /** Optional output parser — if returns null, content falls back to raw stdout. */\n readonly parseOutput?: (stdout: string) => string | null\n}\n\nexport class AgentCliParticipant implements ParticipantExecutor {\n readonly kind = \"agent-cli\"\n\n constructor(private readonly opts: AgentCliParticipantOptions) {}\n\n async executeTurn(\n input: ParticipantExecuteInput\n ): Promise<ParticipantExecuteOutput> {\n const prompt = await assemblePrompt(input)\n const stdout = await spawnWithStdin({\n command: this.opts.command,\n args: this.opts.args ?? [],\n cwd: this.opts.cwd ?? process.cwd(),\n timeoutMs: this.opts.timeoutMs ?? 90000,\n stdin: prompt,\n signal: input.signal,\n })\n const parsed = this.opts.parseOutput?.(stdout) ?? null\n const content = parsed ?? stdout.trimEnd()\n return { content }\n }\n}\n\nasync function assemblePrompt(\n input: ParticipantExecuteInput\n): Promise<string> {\n const roleText = input.participant.role\n ? await loadRole(input.participant.role)\n : \"\"\n\n const transcript = input.recentTurns\n .map((t) => `[${t.participantId}] ${t.content}`)\n .join(\"\\n\\n\")\n\n return [\n roleText && `# Your role\\n\\n${roleText}`,\n `# Recent conversation\\n\\n${transcript}`,\n `# Your turn\\n\\nYou are ${input.participant.displayName}. The latest message in the conversation triggered you (most likely because it mentions you). Read the transcript above and reply in character. Keep it conversational unless the trigger asks for detailed work. Output only your reply — no preamble, no role labels, no quotes around the response.`,\n ]\n .filter(Boolean)\n .join(\"\\n\\n\")\n}\n\n// File extensions we treat as a path-to-role-file. Anything else is\n// inline role text — even strings that contain `/`, which are common\n// in normal sentences (\"I am an AI/ML reviewer\").\nconst ROLE_FILE_EXTENSIONS = [\".md\", \".markdown\", \".txt\"]\n\nasync function loadRole(roleField: string): Promise<string> {\n if (!looksLikeRoleFile(roleField)) return roleField\n try {\n const raw = await readFile(roleField, \"utf8\")\n // Strip optional YAML frontmatter — lets a Claude Code agent\n // definition file (.claude/agents/*.md) double as a swarm role\n // without ferrying the agent's metadata into the prompt.\n const parsed = matter(raw)\n return parsed.content.trim()\n } catch (err) {\n // The string had a role-file extension but the file isn't readable\n // — surface a hint on stderr so authors don't silently get the\n // literal path as a prompt. Still fall back to inline so the swarm\n // doesn't crash on a typo.\n const code = (err as NodeJS.ErrnoException).code\n process.stderr.write(\n `agent-cli participant: role path '${roleField}' not readable (${code ?? \"unknown\"}); using the literal string as inline role text.\\n`\n )\n return roleField\n }\n}\n\nfunction looksLikeRoleFile(s: string): boolean {\n const lower = s.toLowerCase()\n return ROLE_FILE_EXTENSIONS.some((ext) => lower.endsWith(ext))\n}\n"]}
@@ -0,0 +1,25 @@
1
+ import { StateStore, ParticipantId } from '../ports.js';
2
+
3
+ /**
4
+ * Filesystem state store — one JSON file per participant.
5
+ *
6
+ * <stateDir>/<participantId>.json
7
+ *
8
+ * Missing files read as {}. Writes are full overwrites; callers
9
+ * pass the merged state, the adapter doesn't merge on its own.
10
+ */
11
+
12
+ type FileStateStoreOptions = {
13
+ /** Directory to hold the per-participant JSON files. */
14
+ readonly dir: string;
15
+ };
16
+ declare class FileStateStore implements StateStore {
17
+ private readonly opts;
18
+ readonly kind = "fs";
19
+ constructor(opts: FileStateStoreOptions);
20
+ read(participantId: ParticipantId): Promise<Readonly<Record<string, unknown>>>;
21
+ write(participantId: ParticipantId, state: Readonly<Record<string, unknown>>): Promise<void>;
22
+ private pathFor;
23
+ }
24
+
25
+ export { FileStateStore, type FileStateStoreOptions };