@controlflow-ai/daemon 0.1.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.
Files changed (67) hide show
  1. package/README.md +360 -0
  2. package/bin/console.js +2 -0
  3. package/bin/daemon.js +2 -0
  4. package/bin/pal.js +2 -0
  5. package/bin/server.js +2 -0
  6. package/package.json +31 -0
  7. package/src/agent-runtime.ts +285 -0
  8. package/src/app.ts +745 -0
  9. package/src/args.ts +54 -0
  10. package/src/artifacts.ts +85 -0
  11. package/src/cli.ts +284 -0
  12. package/src/client.ts +310 -0
  13. package/src/coco.ts +52 -0
  14. package/src/codex.ts +41 -0
  15. package/src/coding-agent-runtime.ts +20 -0
  16. package/src/config.ts +106 -0
  17. package/src/console.ts +349 -0
  18. package/src/daemon-client.ts +91 -0
  19. package/src/daemon.ts +580 -0
  20. package/src/db.ts +2830 -0
  21. package/src/failure-message.ts +17 -0
  22. package/src/format.ts +13 -0
  23. package/src/http.ts +55 -0
  24. package/src/lark/agent-runtime.ts +142 -0
  25. package/src/lark/cli.ts +549 -0
  26. package/src/lark/credentials.ts +105 -0
  27. package/src/lark/daemon-integration.ts +108 -0
  28. package/src/lark/dispatcher.ts +374 -0
  29. package/src/lark/event-router.ts +329 -0
  30. package/src/lark/inbound-events.ts +131 -0
  31. package/src/lark/server-integration.ts +445 -0
  32. package/src/lark/setup.ts +326 -0
  33. package/src/lark/ws-daemon.ts +224 -0
  34. package/src/lark-fixture-diagnostics.ts +56 -0
  35. package/src/lark-fixture.ts +277 -0
  36. package/src/local-api.ts +155 -0
  37. package/src/local-auth.ts +45 -0
  38. package/src/migrations/001_initial.ts +61 -0
  39. package/src/migrations/002_daemon_deliveries.ts +52 -0
  40. package/src/migrations/003_sessions_runs.ts +49 -0
  41. package/src/migrations/004_message_idempotency.ts +21 -0
  42. package/src/migrations/005_artifacts.ts +24 -0
  43. package/src/migrations/006_lark_channel_foundation.ts +119 -0
  44. package/src/migrations/007_agents_a0.ts +17 -0
  45. package/src/migrations/008_b0_chat_history.ts +31 -0
  46. package/src/migrations/009_b0_transcript_ingest_seq.ts +35 -0
  47. package/src/migrations/010_b0_transcript_shadow_external_ids.ts +32 -0
  48. package/src/migrations/011_b0_channel_conversation_audit_only.ts +27 -0
  49. package/src/migrations/012_b0_cross_conversation_invariant.ts +45 -0
  50. package/src/migrations/013_b1_0_eng_inbound_raw_events.ts +56 -0
  51. package/src/migrations/014_agents_runtime.ts +10 -0
  52. package/src/migrations/015_agent_runtime_sessions.ts +15 -0
  53. package/src/migrations/016_room_participants.ts +27 -0
  54. package/src/migrations/017_unified_room_delivery.ts +203 -0
  55. package/src/migrations/018_room_display_names.ts +36 -0
  56. package/src/migrations/019_computer_connections.ts +63 -0
  57. package/src/migrations/020_computer_agent_assignments.ts +20 -0
  58. package/src/migrations/021_provider_identity_bindings.ts +32 -0
  59. package/src/migrations.ts +85 -0
  60. package/src/neeko.ts +23 -0
  61. package/src/provider-identity.ts +40 -0
  62. package/src/runtime-registry.ts +41 -0
  63. package/src/server-auth.ts +13 -0
  64. package/src/server.ts +63 -0
  65. package/src/token-file.ts +57 -0
  66. package/src/types.ts +408 -0
  67. package/src/web.ts +565 -0
package/README.md ADDED
@@ -0,0 +1,360 @@
1
+ # pal
2
+
3
+ Small Bun demo for the Lock idea: a local message server, a daemon that connects a computer, and CLI surfaces for humans, agents, and Lark bots.
4
+
5
+ ## Architecture
6
+
7
+ | Component | Script | Role | Default endpoint |
8
+ |-----------|--------|------|------------------|
9
+ | **Server** | `bun run server` | Message platform, control API, web workbench, Lark websocket host | `http://127.0.0.1:4127` |
10
+ | **Daemon** | `bun run daemon` | Connects one computer, claims deliveries for assigned agents, runs coding-agent CLIs | local API `http://127.0.0.1:4137` |
11
+ | **Console** | `bun run console -- ...` | Admin CLI that talks directly to the Server | Server API |
12
+ | **CLI** | `bun run cli -- ...` | Agent/human CLI that talks through the local Daemon | Daemon local API |
13
+
14
+ The current preferred model is computer-first: provision a computer, start one daemon with the provisioned API key, then assign agents to that computer. The daemon can run with zero agents and periodically reconciles assignments from the server, so newly assigned agents start without a daemon restart and removed agents stop being registered for new work.
15
+
16
+ ## Quick Start
17
+
18
+ ### 1. Install Dependencies
19
+
20
+ ```bash
21
+ bun install
22
+ ```
23
+
24
+ For disposable local smoke tests, keep state isolated:
25
+
26
+ ```bash
27
+ export PAL_HOME=/tmp/pal-demo
28
+ ```
29
+
30
+ ### 2. Start Server
31
+
32
+ ```bash
33
+ bun run server
34
+ ```
35
+
36
+ Server listens on `http://127.0.0.1:4127/` by default. Change the bind address with `PAL_HOST` and the port with `PAL_PORT`:
37
+
38
+ ```bash
39
+ PAL_HOST=0.0.0.0 PAL_PORT=4127 bun run server
40
+ ```
41
+
42
+ When clients or daemons connect through a non-default address, pass `--server <url>` or set `PAL_SERVER` / `LOCK_SERVER_URL` to the reachable URL.
43
+
44
+ ### 3. Provision a Computer
45
+
46
+ ```bash
47
+ curl -s -X POST http://127.0.0.1:4127/api/computers/provision \
48
+ -H 'content-type: application/json' \
49
+ -d '{"name":"Local Demo","server_url":"http://127.0.0.1:4127"}'
50
+ ```
51
+
52
+ The response includes a `computer.id`, an `api_key`, and a packaged daemon command. For a source checkout, start the local Bun daemon with the returned key:
53
+
54
+ ```bash
55
+ bun run daemon -- --server http://127.0.0.1:4127 --api-key sk_machine_...
56
+ ```
57
+
58
+ If no agents are assigned yet, the daemon stays connected, heartbeats the computer, and waits for assignments.
59
+
60
+ ### 4. Create and Assign an Agent
61
+
62
+ Use `agents onboard` to create/update an agent and optionally assign it to the provisioned computer:
63
+
64
+ ```bash
65
+ bun run console -- agents onboard \
66
+ --key codex \
67
+ --name "Codex Agent" \
68
+ --runtime codex \
69
+ --computer-id machine_...
70
+ ```
71
+
72
+ If the daemon is already running with the same API key, it will discover the `codex` assignment on a later heartbeat and begin claiming deliveries for it. Restarting is not required.
73
+
74
+ You can also do the two steps separately:
75
+
76
+ ```bash
77
+ bun run console -- agents create --key codex --name "Codex Agent" --runtime codex --desc "Main coding agent"
78
+ curl -s -X POST http://127.0.0.1:4127/api/agents/codex/assignment \
79
+ -H 'content-type: application/json' \
80
+ -d '{"computer_id":"machine_..."}'
81
+ ```
82
+
83
+ ### 5. Send a Message
84
+
85
+ ```bash
86
+ # Through the local daemon
87
+ bun run cli -- send --room general --from alice --to codex "hello"
88
+
89
+ # Directly to the server
90
+ bun run console -- send --chat general --from alice --to codex "hello"
91
+ ```
92
+
93
+ ## Console Commands
94
+
95
+ Console connects to the server directly and is intended for administration.
96
+
97
+ ### Agents
98
+
99
+ ```bash
100
+ # List agents
101
+ bun run console -- agents list [--json]
102
+
103
+ # Create or update an agent record
104
+ bun run console -- agents create --key <agent-key> --name <display-name> [--runtime neeko|coco|codex] [--desc <description>]
105
+
106
+ # Create/update an agent and optionally assign it to a computer
107
+ bun run console -- agents onboard --key <agent-key> --name <display-name> [--runtime codex] [--desc <description>] [--computer-id <machine-id>]
108
+
109
+ # Update only the runtime
110
+ bun run console -- agents update --key <agent-key> --runtime neeko|coco|codex
111
+ ```
112
+
113
+ `codex` is the validated runtime for the current demo environment. Do not use `neeko` or `coco` until their binaries and adapters are available on the runtime host.
114
+
115
+ ### Lark Bots
116
+
117
+ ```bash
118
+ # Configure a bot, resolve its bot open_id, bind it to an agent, and ask the running server to reload Lark
119
+ bun run console -- lark setup --app-id <app-id> --app-secret <app-secret> --label <name> --agent <agent-key>
120
+
121
+ # Configure a bot and create/update the bound agent in one command
122
+ bun run console -- lark setup \
123
+ --app-id <app-id> \
124
+ --app-secret <app-secret> \
125
+ --label <name> \
126
+ --agent codex \
127
+ --create-agent \
128
+ --agent-name "Codex" \
129
+ --runtime codex \
130
+ --computer-id <machine-id>
131
+
132
+ # List configured bots; secrets are redacted
133
+ bun run console -- lark list
134
+
135
+ # View recent raw Lark events
136
+ bun run console -- lark events --limit 20
137
+
138
+ # Send a message through a configured bot
139
+ bun run console -- lark send --app-id <app-id> --to <receive_id> [--to-type chat_id|open_id|union_id|email|user_id] "Hello"
140
+ ```
141
+
142
+ `lark setup` writes `~/.pal/lark.json` or `PAL_LARK_CONFIG` with mode `0600`. By default it calls `POST /api/lark/reload` on the running server after a successful write. Use `--no-reload` when you want to edit or validate the file first, then trigger reload manually:
143
+
144
+ ```bash
145
+ curl -s -X POST http://127.0.0.1:4127/api/lark/reload
146
+ ```
147
+
148
+ Reload scans the config explicitly. If the file is invalid, the server keeps existing Lark websocket listeners running and returns an error instead of dropping them.
149
+
150
+ The older `bun run console -- lark daemon ...` command still exists for standalone Lark testing, but normal server operation now hosts Lark websocket clients inside `bun run server`.
151
+
152
+ ### Messages and Runs
153
+
154
+ ```bash
155
+ # Health check
156
+ bun run console -- health
157
+
158
+ # List rooms/chats
159
+ bun run console -- chats [--json]
160
+
161
+ # View messages
162
+ bun run console -- messages [--chat general] [--parent 1] [--after 0] [--limit 50] [--q text] [--json]
163
+
164
+ # View an agent inbox
165
+ bun run console -- inbox --agent codex [--after 0] [--limit 50] [--json]
166
+
167
+ # List runs
168
+ bun run console -- runs [--json]
169
+
170
+ # Control a run
171
+ bun run console -- run-action <run-id> kill
172
+ bun run console -- run-action <run-id> restart
173
+ ```
174
+
175
+ ## CLI Commands
176
+
177
+ CLI connects through the local daemon. It is the surface used by coding agents and local operators once the daemon is running.
178
+
179
+ ### Rooms, Topics, and Messages
180
+
181
+ ```bash
182
+ # Send a message
183
+ bun run cli -- send --room general --from alice [--to codex] "Task completed"
184
+
185
+ # Reply to an existing message
186
+ bun run cli -- send --room general --from codex --parent 1 "Reply content"
187
+
188
+ # Send file content as a message
189
+ bun run cli -- send --room general --from codex --file /path/to/message.txt
190
+
191
+ # List rooms and members
192
+ bun run cli -- rooms list [--json]
193
+ bun run cli -- rooms members --room general [--json]
194
+
195
+ # Invite an agent to a room with a delivery mode
196
+ bun run cli -- rooms invite --room general --agent codex [--mode mentions|all|periodic|muted|off]
197
+
198
+ # Create a topic inside a room
199
+ bun run cli -- topics create --room general "Investigation"
200
+
201
+ # List or show messages
202
+ bun run cli -- messages list [--room general] [--topic 1] [--after 0] [--limit 50] [--q text] [--json]
203
+ bun run cli -- messages show <message-id> [--json]
204
+
205
+ # Start a simple agent-to-agent debate workflow
206
+ bun run cli -- debate start --chat general --a codex --b reviewer [--turns 6] "Topic"
207
+ ```
208
+
209
+ ### Artifacts
210
+
211
+ ```bash
212
+ # Upload an HTML file through the daemon to the server
213
+ bun run cli -- http file /path/to/report.html --mime text/html --title "Report"
214
+
215
+ # Upload with a TTL in seconds
216
+ bun run cli -- http file /path/to/data.json --mime application/json --ttl 3600
217
+ ```
218
+
219
+ ### Query and Control
220
+
221
+ ```bash
222
+ bun run cli -- health
223
+ bun run cli -- chats [--json]
224
+ bun run cli -- runs [--json]
225
+ bun run cli -- run-action <run-id> kill
226
+ bun run cli -- run-action <run-id> restart
227
+ ```
228
+
229
+ ## Daemon Commands
230
+
231
+ ```bash
232
+ # Preferred computer-first startup
233
+ bun run daemon -- --server http://127.0.0.1:4127 --api-key sk_machine_...
234
+
235
+ # Equivalent env-var startup
236
+ PAL_API_KEY=sk_machine_... bun run daemon -- --server http://127.0.0.1:4127
237
+
238
+ # Custom polling interval and daemon local API port
239
+ bun run daemon -- --server http://127.0.0.1:4127 --api-key sk_machine_... --interval 3000 --local-port 4137
240
+
241
+ # Dry run: claim and record runs without executing the agent runtime
242
+ bun run daemon -- --server http://127.0.0.1:4127 --api-key sk_machine_... --dry-run
243
+
244
+ # Legacy explicit-agent startup using a computer id/secret instead of an API key
245
+ bun run daemon -- --agent codex --computer-id hm-media-demo --computer-secret pal-demo-computer-secret --cwd /path/to/workspace
246
+
247
+ # One-shot processing for tests or scripts
248
+ bun run daemon -- --server http://127.0.0.1:4127 --api-key sk_machine_... --once
249
+ ```
250
+
251
+ The daemon creates one local API for CLI calls, connects the computer to the server, heartbeats the connection, reconciles assigned agents, claims pending deliveries for currently assigned agents, and starts one runtime process per claimed delivery. It can stay online with no assigned agents. Runs have no default timeout; use run actions to kill or restart them.
252
+
253
+ ## Environment Variables
254
+
255
+ | Variable | Default | Description |
256
+ |----------|---------|-------------|
257
+ | `PAL_HOME` | `~/.pal` | Runtime home for DB, daemon state, Lark config, and agent homes |
258
+ | `PAL_DB` | `$PAL_HOME/lock.sqlite` | SQLite database path |
259
+ | `PAL_HOST` | `127.0.0.1` | Server bind host used by `bun run server`; set `0.0.0.0` to listen on all interfaces |
260
+ | `PAL_PORT` | `4127` | Server bind port used by `bun run server` |
261
+ | `PAL_SERVER` | `http://127.0.0.1:4127` | Reachable server URL for console/daemon clients; update this when `PAL_HOST`/`PAL_PORT` change client access |
262
+ | `LOCK_SERVER_URL` | - | Alternate server URL env read before `PAL_SERVER` |
263
+ | `PAL_API_KEY` | - | Provisioned computer API key for daemon startup |
264
+ | `PAL_AGENT` | - | Optional legacy explicit agent for daemon startup |
265
+ | `PAL_COMPUTER_ID` | - | Legacy computer id when not using `PAL_API_KEY` |
266
+ | `PAL_COMPUTER_SECRET` | - | Legacy computer secret when not using `PAL_API_KEY` |
267
+ | `LOCK_DAEMON_URL` | `http://127.0.0.1:4137` | Local daemon API URL used by `bun run cli` |
268
+ | `LOCK_DAEMON_TOKEN` | token file fallback | Local daemon API bearer token |
269
+ | `PAL_LARK_CONFIG` | `$PAL_HOME/lark.json` | Lark bot credential file |
270
+ | `PAL_OWNER_LARK_UNION_ID` | - | Optional Lark sender allowlist for business ingest |
271
+ | `PAL_LARK_ACTION_REACTION_EMOJI` | `Typing` | Reaction added when Lark delivery is created |
272
+
273
+ ## Runtime Semantics
274
+
275
+ - Rooms are the primary conversation container; `chat` remains as a compatibility alias in several APIs.
276
+ - Agents receive deliveries through room subscriptions, direct recipients, or Lark bot bindings.
277
+ - A daemon starts one agent run for each claimed delivery.
278
+ - Runs do not have a default timeout. Agents can work as long as needed.
279
+ - A visible chat reply is just a message, not run completion.
280
+ - A run completes only when the agent exits normally, fails when it exits non-zero, or is explicitly killed/restarted.
281
+ - Restart kills the current process and starts a fresh run for the same message.
282
+
283
+ ## HTTP API
284
+
285
+ Common read and room APIs:
286
+
287
+ - `GET /`
288
+ - `GET /health`
289
+ - `GET /api/chats`
290
+ - `GET /api/rooms`
291
+ - `POST /api/rooms`
292
+ - `GET /api/rooms/<room-id-or-name>/members`
293
+ - `GET /api/rooms/<room-id-or-name>/mentionables`
294
+ - `POST /api/rooms/<room-id-or-name>/agents`
295
+ - `POST /api/rooms/<room-id-or-name>/topics`
296
+ - `GET /api/messages?chat=general&after=0&limit=50`
297
+ - `GET /api/messages/<id>`
298
+ - `POST /api/messages`
299
+ - `GET /api/inbox?agent=codex&after=0&limit=50`
300
+
301
+ Computer, daemon, and delivery APIs:
302
+
303
+ - `POST /api/computers/provision`
304
+ - `POST /api/computers/connect`
305
+ - `POST /api/computers/<computer-id>/heartbeat`
306
+ - `POST /api/daemons`
307
+ - `GET /api/deliveries?agent=codex&status=pending&limit=50`
308
+ - `POST /api/deliveries`
309
+ - `POST /api/deliveries/<delivery-id>/claim`
310
+ - `POST /api/deliveries/<delivery-id>/ack`
311
+ - `POST /api/deliveries/<delivery-id>/fail`
312
+
313
+ Agent, session, run, artifact, and Lark APIs:
314
+
315
+ - `GET /api/agents`
316
+ - `POST /api/agents`
317
+ - `POST /api/agents/onboard`
318
+ - `PATCH /api/agents/<key>`
319
+ - `POST /api/agents/<key>/assignment`
320
+ - `GET /api/sessions`
321
+ - `POST /api/sessions`
322
+ - `GET /api/sessions/<session-id>/runs`
323
+ - `POST /api/sessions/<session-id>/runtime-session`
324
+ - `GET /api/runs`
325
+ - `POST /api/runs`
326
+ - `GET /api/runs/<id>`
327
+ - `POST /api/runs/<id>/pid`
328
+ - `POST /api/runs/<id>/finish`
329
+ - `POST /api/runs/<id>/kill`
330
+ - `POST /api/runs/<id>/restart`
331
+ - `GET /api/artifacts`
332
+ - `POST /api/artifacts`
333
+ - `POST /api/artifacts/cleanup`
334
+ - `POST /api/artifacts/<artifact-id>/revoke`
335
+ - `GET /api/workbench/artifacts`
336
+ - `POST /api/lark/reload`
337
+
338
+ Example message body:
339
+
340
+ ```json
341
+ {
342
+ "room": "general",
343
+ "sender": "alice",
344
+ "recipient": "codex",
345
+ "content": "hello"
346
+ }
347
+ ```
348
+
349
+ Example agent onboarding body:
350
+
351
+ ```json
352
+ {
353
+ "agent_key": "codex",
354
+ "display_name": "Codex Agent",
355
+ "runtime": "codex",
356
+ "computer_id": "machine_..."
357
+ }
358
+ ```
359
+
360
+ This is intentionally not a production architecture. It is a single-node demo to validate the workflow before adding richer routing, auth, WebSocket delivery, UI, or multi-agent adapters.
package/bin/console.js ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env bun
2
+ import '../src/console.ts';
package/bin/daemon.js ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env bun
2
+ import '../src/daemon.ts';
package/bin/pal.js ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env bun
2
+ import '../src/cli.ts';
package/bin/server.js ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env bun
2
+ import '../src/server.ts';
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "@controlflow-ai/daemon",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "scripts": {
6
+ "server": "bun run src/server.ts",
7
+ "daemon": "bun run src/daemon.ts",
8
+ "cli": "bun run src/cli.ts",
9
+ "console": "bun run src/console.ts",
10
+ "typecheck": "tsc --noEmit",
11
+ "test": "bun test",
12
+ "check": "bun run typecheck && bun test"
13
+ },
14
+ "devDependencies": {
15
+ "@types/bun": "latest",
16
+ "typescript": "^5.9.3"
17
+ },
18
+ "dependencies": {
19
+ "@larksuiteoapi/node-sdk": "^1.59.0"
20
+ },
21
+ "bin": {
22
+ "daemon": "bin/daemon.js",
23
+ "pal-daemon": "bin/daemon.js",
24
+ "pal": "bin/pal.js",
25
+ "pal-server": "bin/server.js",
26
+ "pal-console": "bin/console.js"
27
+ },
28
+ "publishConfig": {
29
+ "access": "public"
30
+ }
31
+ }
@@ -0,0 +1,285 @@
1
+ import type { Message, RoomParticipant, RunAction } from './types.js';
2
+ import { join } from 'node:path';
3
+ import { sanitizeProviderIds } from './provider-identity.js';
4
+
5
+ export interface AgentRuntimeRunInput {
6
+ agent: string;
7
+ serverUrl: string;
8
+ message: Message;
9
+ cwd: string;
10
+ agentHome?: string;
11
+ projectCwd?: string;
12
+ extraArgs: string[];
13
+ localDaemonUrl?: string;
14
+ localDaemonToken?: string;
15
+ runtimeSessionId?: string | null;
16
+ roomParticipants?: RoomParticipant[];
17
+ recentMessages?: Message[];
18
+ dryRun?: boolean;
19
+ privateCliBinDir?: string;
20
+ palCliCommand?: string;
21
+ signal?: AbortSignal;
22
+ onStart?: (pid: number | null) => Promise<void>;
23
+ getAction?: () => Promise<RunAction | null>;
24
+ }
25
+
26
+ export function formatRoomParticipantContext(participants: RoomParticipant[] | undefined): string {
27
+ if (!participants || participants.length === 0) {
28
+ return '\nRoom participants: no local participant snapshot is available yet.';
29
+ }
30
+ const lines = participants.slice(0, 50).map((p) => {
31
+ const participant = sanitizeProviderIds(p.participant_id);
32
+ const name = p.display_name ? ` (${sanitizeProviderIds(p.display_name)})` : '';
33
+ return `- ${p.kind}: ${participant}${name}, source=${p.source}`;
34
+ });
35
+ const suffix = participants.length > 50 ? `\n- ... ${participants.length - 50} more participants omitted` : '';
36
+ return `\nRoom participants:\n${lines.join('\n')}${suffix}`;
37
+ }
38
+
39
+ export function formatRecentMessageContext(messages: Message[] | undefined, currentMessageId: number): string {
40
+ if (!messages || messages.length === 0) {
41
+ return '\nRecent room history: no local message history is available yet.';
42
+ }
43
+ const lines = messages.slice(-12).map((message) => {
44
+ const marker = message.id === currentMessageId ? ' current' : '';
45
+ const parent = message.parent_id === null ? '' : ` parent=${message.parent_id}`;
46
+ const recipient = message.recipient ? ` to=@${sanitizeProviderIds(message.recipient)}` : '';
47
+ const sender = sanitizeProviderIds(message.sender);
48
+ const content = sanitizeProviderIds(message.content).replace(/\s+/g, ' ').trim().slice(0, 500);
49
+ return `- [${message.id}${marker}] @${sender}${recipient}${parent}: ${content}`;
50
+ });
51
+ const omitted = messages.length > 12 ? `\n- ... ${messages.length - 12} older messages omitted` : '';
52
+ return `\nRecent room history:\n${lines.join('\n')}${omitted}`;
53
+ }
54
+
55
+ export function runtimeCwd(input: AgentRuntimeRunInput): string {
56
+ return input.agentHome ?? input.cwd;
57
+ }
58
+
59
+ export function runtimeProjectCwd(input: AgentRuntimeRunInput): string {
60
+ return input.projectCwd ?? input.cwd;
61
+ }
62
+
63
+ export function buildPalPrompt(input: AgentRuntimeRunInput): string {
64
+ const palCli = input.palCliCommand ?? 'pal';
65
+ const thread = input.message.parent_id === null ? '' : `\nTopic parent message id: ${input.message.parent_id}`;
66
+ const recipient = input.message.recipient ? `\nRecipient: @${sanitizeProviderIds(input.message.recipient)}` : '';
67
+ const agentHome = runtimeCwd(input);
68
+ const projectCwd = runtimeProjectCwd(input);
69
+ const chatName = sanitizeProviderIds(input.message.chat_name);
70
+ const sender = sanitizeProviderIds(input.message.sender);
71
+ const content = sanitizeProviderIds(input.message.content);
72
+
73
+ return `You are ${input.agent}, a long-running PAL coding agent connected to the pal chat server.
74
+
75
+ PAL is a multi-agent chat platform where humans and agents collaborate through rooms and topics.
76
+
77
+ Workspace contract:
78
+ - Your current working directory is your persistent PAL agent home: ${agentHome}
79
+ - Your cwd is for identity, memory, recovery state, scratch files, and durable local notes.
80
+ - Your cwd is not the project repository.
81
+ - The project workspace for source inspection, code changes, tests, and Git work is: ${projectCwd}
82
+
83
+ Startup recovery:
84
+ - Read MEMORY.md in your cwd first when it exists.
85
+ - Follow its recovery order and read only the referenced state/kb files you need.
86
+ - Keep durable agent memory in your agent home, not in the project repository.
87
+
88
+ A new chat message arrived.
89
+
90
+ Message id: ${input.message.id}
91
+ Chat: #${chatName}${thread}
92
+ Sender: @${sender}${recipient}
93
+ ${formatRoomParticipantContext(input.roomParticipants)}
94
+ ${formatRecentMessageContext(input.recentMessages, input.message.id)}
95
+ Content:
96
+ ${content}
97
+
98
+ Communication rules:
99
+ - Use the PAL CLI for any reply that should be visible in chat.
100
+ - Reply to the same chat/topic unless the user explicitly asks otherwise.
101
+ - Avoid acknowledgment-only replies; send a visible message only when it provides a result, blocker, decision request, ownership signal, or useful progress.
102
+ - Report only work you actually did.
103
+ - Store PAL handles such as user:pal_user_ab12cd34 in durable memory. Never store provider-native IDs such as Feishu/Lark open_id, chat_id, message_id, or app_id values.
104
+
105
+ Commands:
106
+ - Send a reply to the same chat: ${palCli} send --chat ${chatName} --from ${input.agent} <message>
107
+ - Send a message to another agent and trigger that agent's runtime: ${palCli} send --chat ${chatName} --from ${input.agent} --to <agent-key> <message>
108
+ - Reply in this topic: ${palCli} send --chat ${chatName} --parent ${input.message.parent_id ?? input.message.id} --from ${input.agent} <message>
109
+ - List rooms: ${palCli} rooms list
110
+ - View room members: ${palCli} rooms members --room ${chatName}
111
+ - Read recent chat messages: ${palCli} messages list --room ${chatName}
112
+ - Show one message: ${palCli} messages show <message-id>
113
+
114
+ Finish naturally when the task is done. There is no fixed timeout for this run.`;
115
+ }
116
+
117
+ export interface AgentRuntimeRunResult {
118
+ output: string;
119
+ exitCode: number | null;
120
+ stoppedByAction: RunAction | null;
121
+ runtimeSessionId?: string | null;
122
+ }
123
+
124
+ export type AgentRuntimeProtocol = 'json-stream' | 'acp';
125
+
126
+ export interface AgentRuntimeCapabilities {
127
+ protocol: AgentRuntimeProtocol;
128
+ resume: 'runtime-session-id' | 'adapter-session-id' | 'none';
129
+ busyDeliveryMode: 'queue' | 'direct' | 'notification' | 'none';
130
+ supportsMcp: boolean;
131
+ }
132
+
133
+ export interface AgentRuntime {
134
+ readonly name: string;
135
+ readonly capabilities: AgentRuntimeCapabilities;
136
+ /** Build the prompt text fed to the agent CLI. */
137
+ buildPrompt(input: AgentRuntimeRunInput): string;
138
+ /** Build the CLI argument array (excluding the command name itself). */
139
+ buildArgs(input: AgentRuntimeRunInput): string[];
140
+ /** The executable command name (e.g. "neeko", "coco", "codex"). */
141
+ readonly command: string;
142
+ /** Return the working directory for the agent process. Defaults to input.agentHome ?? input.cwd. */
143
+ buildCwd?(input: AgentRuntimeRunInput): string;
144
+ /** Parse stdout/stderr from the agent process. Defaults to trimmed stdout + stderr. */
145
+ parseOutput?(input: { stdout: string; stderr: string; input: AgentRuntimeRunInput }): { output: string; runtimeSessionId?: string | null };
146
+ }
147
+
148
+ function sleep(ms: number): Promise<void> {
149
+ return new Promise((resolve) => setTimeout(resolve, ms));
150
+ }
151
+
152
+ function processGroupSignal(pid: number, signal: NodeJS.Signals): void {
153
+ try {
154
+ process.kill(-pid, signal);
155
+ } catch {
156
+ try { process.kill(pid, signal); } catch { /* already exited */ }
157
+ }
158
+ }
159
+
160
+ async function stopProcess(proc: ReturnType<typeof Bun.spawn>): Promise<number | null> {
161
+ processGroupSignal(proc.pid, 'SIGTERM');
162
+ const graceful = await Promise.race([
163
+ proc.exited.then((exitCode) => ({ exited: true as const, exitCode })),
164
+ sleep(2000).then(() => ({ exited: false as const })),
165
+ ]);
166
+
167
+ if (graceful.exited) return graceful.exitCode;
168
+
169
+ processGroupSignal(proc.pid, 'SIGKILL');
170
+ proc.kill('SIGKILL');
171
+ return await Promise.race([
172
+ proc.exited,
173
+ sleep(500).then(() => null),
174
+ ]);
175
+ }
176
+
177
+ async function waitForExitOrAction(
178
+ proc: ReturnType<typeof Bun.spawn>,
179
+ input: AgentRuntimeRunInput,
180
+ ): Promise<Pick<AgentRuntimeRunResult, 'exitCode' | 'stoppedByAction'>> {
181
+ const pollMs = 1000;
182
+
183
+ while (true) {
184
+ const waiters: Array<Promise<{ type: 'exit'; exitCode: number } | { type: 'abort' } | { type: 'tick' }>> = [
185
+ proc.exited.then((exitCode) => ({ type: 'exit' as const, exitCode })),
186
+ sleep(pollMs).then(() => ({ type: 'tick' as const })),
187
+ ];
188
+ if (input.signal) {
189
+ waiters.push(new Promise<{ type: 'abort' }>((resolve) => {
190
+ if (input.signal!.aborted) {
191
+ resolve({ type: 'abort' });
192
+ return;
193
+ }
194
+ input.signal!.addEventListener('abort', () => resolve({ type: 'abort' }), { once: true });
195
+ }));
196
+ }
197
+ const result = await Promise.race(waiters);
198
+
199
+ if (result.type === 'exit') {
200
+ return { exitCode: result.exitCode, stoppedByAction: null };
201
+ }
202
+ if (result.type === 'abort') {
203
+ return { exitCode: await stopProcess(proc), stoppedByAction: 'kill' };
204
+ }
205
+
206
+ const action = await input.getAction?.();
207
+ if (action) {
208
+ return { exitCode: await stopProcess(proc), stoppedByAction: action };
209
+ }
210
+ }
211
+ }
212
+
213
+ /** Spawn an agent runtime process and manage its lifecycle. */
214
+ export async function runAgentRuntime(
215
+ runtime: AgentRuntime,
216
+ input: AgentRuntimeRunInput,
217
+ ): Promise<AgentRuntimeRunResult> {
218
+ const prompt = runtime.buildPrompt(input);
219
+ const args = runtime.buildArgs(input);
220
+ const procCwd = runtime.buildCwd?.(input) ?? runtimeCwd(input);
221
+ console.log(`[${runtime.name}] run agent=${input.agent} chat=${input.message.chat_name} message=${input.message.id} cwd=${procCwd} projectCwd=${runtimeProjectCwd(input)} dryRun=${input.dryRun} extraArgs=[${input.extraArgs.join(', ')}]`);
222
+
223
+ if (input.dryRun) {
224
+ console.log(`[${runtime.name}] dry-run mode, skipping spawn`);
225
+ const [subcommand, ...restArgs] = args;
226
+ const renderedArgs = [
227
+ subcommand,
228
+ ...restArgs.map((arg) => JSON.stringify(arg)),
229
+ ].filter(Boolean).join(' ');
230
+ await input.onStart?.(null);
231
+ return {
232
+ output: `[dry-run] ${runtime.command}${renderedArgs ? ` ${renderedArgs}` : ''}`,
233
+ exitCode: 0,
234
+ stoppedByAction: null,
235
+ runtimeSessionId: input.runtimeSessionId ?? null,
236
+ };
237
+ }
238
+
239
+ const env = {
240
+ ...process.env,
241
+ PAL_SERVER: input.serverUrl,
242
+ PAL_AGENT: input.agent,
243
+ ...(input.localDaemonUrl ? { LOCK_DAEMON_URL: input.localDaemonUrl } : {}),
244
+ ...(input.localDaemonToken ? { LOCK_DAEMON_TOKEN: input.localDaemonToken } : {}),
245
+ ...(input.privateCliBinDir ? { PAL_CLI: join(input.privateCliBinDir, 'pal'), PATH: `${input.privateCliBinDir}:${process.env.PATH ?? ''}` } : {}),
246
+ };
247
+ console.log(`[${runtime.name}] spawning: ${runtime.command} ${args.map((a) => JSON.stringify(a)).join(' ')}`);
248
+ const proc = Bun.spawn([runtime.command, ...args], {
249
+ cwd: procCwd,
250
+ stdout: 'pipe',
251
+ stderr: 'pipe',
252
+ detached: true,
253
+ env,
254
+ });
255
+
256
+ console.log(`[${runtime.name}] spawned pid=${proc.pid}`);
257
+ await input.onStart?.(proc.pid);
258
+ const status = await waitForExitOrAction(proc, input);
259
+ console.log(`[${runtime.name}] process exited exitCode=${status.exitCode} stoppedByAction=${status.stoppedByAction ?? '-'}`);
260
+
261
+ const [stdout, stderr] = await Promise.all([
262
+ new Response(proc.stdout).text(),
263
+ new Response(proc.stderr).text(),
264
+ ]);
265
+
266
+ const parsed = runtime.parseOutput?.({ stdout, stderr, input }) ?? {
267
+ output: [stdout.trim(), stderr.trim()].filter(Boolean).join('\n'),
268
+ runtimeSessionId: input.runtimeSessionId ?? null,
269
+ };
270
+ const output = parsed.output;
271
+ console.log(`[${runtime.name}] output length=${output.length}`);
272
+ return { output, runtimeSessionId: parsed.runtimeSessionId ?? input.runtimeSessionId ?? null, ...status };
273
+ }
274
+
275
+ export type RuntimeDriver = AgentRuntime;
276
+
277
+ /** @deprecated Use AgentRuntimeRunInput instead. */
278
+ export type CodingAgentRunInput = AgentRuntimeRunInput;
279
+ /** @deprecated Use AgentRuntimeRunResult instead. */
280
+ export type CodingAgentRunResult = AgentRuntimeRunResult;
281
+ /** @deprecated Use AgentRuntime instead. */
282
+ export type CodingAgentRuntime = AgentRuntime;
283
+
284
+ /** @deprecated Use runAgentRuntime instead. */
285
+ export const runCodingAgent = runAgentRuntime;