@controlflow-ai/daemon 0.1.1 → 0.1.3

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 (65) hide show
  1. package/README.md +66 -24
  2. package/package.json +16 -3
  3. package/src/agent-avatar.ts +30 -0
  4. package/src/agent-key.ts +28 -0
  5. package/src/agent-permissions.ts +359 -0
  6. package/src/agent-runtime.ts +810 -28
  7. package/src/agent-workspace.ts +183 -0
  8. package/src/app.ts +2183 -79
  9. package/src/args.ts +54 -7
  10. package/src/cli.ts +873 -14
  11. package/src/client.ts +482 -12
  12. package/src/coco.ts +9 -40
  13. package/src/codex.ts +33 -5
  14. package/src/config.ts +28 -4
  15. package/src/console.ts +460 -26
  16. package/src/daemon-client.ts +116 -3
  17. package/src/daemon.ts +958 -101
  18. package/src/db.ts +3216 -113
  19. package/src/delivery-ws.ts +269 -0
  20. package/src/format.ts +4 -1
  21. package/src/lark/app-registration.ts +141 -0
  22. package/src/lark/cli.ts +7 -137
  23. package/src/lark/credentials.ts +36 -3
  24. package/src/lark/event-router.ts +61 -5
  25. package/src/lark/inbound-events.ts +156 -3
  26. package/src/lark/server-integration.ts +659 -111
  27. package/src/lark/setup.ts +74 -5
  28. package/src/lark/ws-daemon.ts +136 -10
  29. package/src/local-api.ts +611 -14
  30. package/src/local-auth.ts +36 -3
  31. package/src/message-attachments.ts +71 -0
  32. package/src/messaging-cli.ts +741 -0
  33. package/src/messaging-status.ts +669 -0
  34. package/src/migrations/023_projects.ts +65 -0
  35. package/src/migrations/024_agents_model.ts +10 -0
  36. package/src/migrations/025_room_archive.ts +44 -0
  37. package/src/migrations/026_project_archive.ts +44 -0
  38. package/src/migrations/027_agent_permission_profiles.ts +16 -0
  39. package/src/migrations/028_lark_websocket_restart_state.ts +16 -0
  40. package/src/migrations/029_held_message_drafts.ts +32 -0
  41. package/src/migrations/030_agent_room_read_state.ts +25 -0
  42. package/src/migrations/031_room_tasks.ts +29 -0
  43. package/src/migrations/032_room_reminders.ts +29 -0
  44. package/src/migrations/033_room_saved_messages.ts +25 -0
  45. package/src/migrations/034_agent_activity_events.ts +27 -0
  46. package/src/migrations/035_agent_avatars.ts +17 -0
  47. package/src/migrations/036_project_agent_defaults.ts +21 -0
  48. package/src/migrations/037_message_attachments.ts +36 -0
  49. package/src/migrations/038_agent_activity_room_scope.ts +64 -0
  50. package/src/migrations/039_message_attachments_path.ts +34 -0
  51. package/src/migrations/040_message_attachments_file_schema.ts +80 -0
  52. package/src/migrations/041_room_system_events.ts +30 -0
  53. package/src/migrations/042_message_attachment_file_kind.ts +52 -0
  54. package/src/migrations/043_room_mode_skill_registry.ts +92 -0
  55. package/src/migrations/044_workflow_runtime.ts +69 -0
  56. package/src/migrations/045_skill_repository_ownership.ts +64 -0
  57. package/src/migrations.ts +70 -1
  58. package/src/neeko.ts +40 -4
  59. package/src/runtime-env.ts +179 -0
  60. package/src/runtime-registry.ts +83 -13
  61. package/src/server.ts +244 -4
  62. package/src/token-file.ts +13 -6
  63. package/src/types.ts +394 -0
  64. package/src/workflow-runtime.ts +275 -0
  65. package/src/web.ts +0 -904
package/README.md CHANGED
@@ -15,6 +15,10 @@ The current preferred model is computer-first: provision a computer, start one d
15
15
 
16
16
  ## Quick Start
17
17
 
18
+ The commands below work on Linux/macOS shells. For native Windows daemon setup,
19
+ PowerShell examples, and Windows path notes, see
20
+ [`docs/windows-daemon.md`](docs/windows-daemon.md).
21
+
18
22
  ### 1. Install Dependencies
19
23
 
20
24
  ```bash
@@ -27,6 +31,12 @@ For disposable local smoke tests, keep state isolated:
27
31
  export PAL_HOME=/tmp/pal-demo
28
32
  ```
29
33
 
34
+ PowerShell equivalent:
35
+
36
+ ```powershell
37
+ $env:PAL_HOME = "$env:TEMP\pal-demo"
38
+ ```
39
+
30
40
  ### 2. Start Server
31
41
 
32
42
  ```bash
@@ -39,6 +49,14 @@ Server listens on `http://127.0.0.1:4127/` by default. Change the bind address w
39
49
  PAL_HOST=0.0.0.0 PAL_PORT=4127 bun run server
40
50
  ```
41
51
 
52
+ PowerShell equivalent:
53
+
54
+ ```powershell
55
+ $env:PAL_HOST = "0.0.0.0"
56
+ $env:PAL_PORT = "4127"
57
+ bun run server
58
+ ```
59
+
42
60
  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
61
 
44
62
  ### 3. Provision a Computer
@@ -49,6 +67,13 @@ curl -s -X POST http://127.0.0.1:4127/api/computers/provision \
49
67
  -d '{"name":"Local Demo","server_url":"http://127.0.0.1:4127"}'
50
68
  ```
51
69
 
70
+ PowerShell equivalent:
71
+
72
+ ```powershell
73
+ $body = @{ name = "Local Demo"; server_url = "http://127.0.0.1:4127" } | ConvertTo-Json
74
+ Invoke-RestMethod -Method Post -Uri "http://127.0.0.1:4127/api/computers/provision" -ContentType "application/json" -Body $body
75
+ ```
76
+
52
77
  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
78
 
54
79
  ```bash
@@ -84,10 +109,10 @@ curl -s -X POST http://127.0.0.1:4127/api/agents/codex/assignment \
84
109
 
85
110
  ```bash
86
111
  # Through the local daemon
87
- bun run cli -- send --room general --from alice --to codex "hello"
112
+ bun run cli -- send --room general --from alice --mention codex "hello"
88
113
 
89
114
  # Directly to the server
90
- bun run console -- send --chat general --from alice --to codex "hello"
115
+ bun run console -- send --chat general --from alice --mention codex "hello"
91
116
  ```
92
117
 
93
118
  ## Console Commands
@@ -103,32 +128,27 @@ bun run console -- agents list [--json]
103
128
  # Create or update an agent record
104
129
  bun run console -- agents create --key <agent-key> --name <display-name> [--runtime neeko|coco|codex] [--desc <description>]
105
130
 
106
- # Create/update an agent and optionally assign it to a computer
131
+ # Create/update an agent, optionally assign it to a computer, and optionally set up Lark at the end
107
132
  bun run console -- agents onboard --key <agent-key> --name <display-name> [--runtime codex] [--desc <description>] [--computer-id <machine-id>]
133
+ bun run console -- agents onboard --interactive
134
+
135
+ # Update runtime and/or manage the Lark bot bound to the agent
136
+ bun run console -- agents update --key <agent-key> [--runtime neeko|coco|codex]
137
+ bun run console -- agents update --key <agent-key> --lark-app-id <app-id> --lark-app-secret <app-secret> [--lark-label <name>] [--rebind-lark]
138
+ bun run console -- agents update --key <agent-key> --unbind-lark
139
+ bun run console -- agents update --interactive
108
140
 
109
- # Update only the runtime
110
- bun run console -- agents update --key <agent-key> --runtime neeko|coco|codex
141
+ # Delete an agent and clean up its assignment, room memberships, subscriptions, Lark binding, and active deliveries
142
+ bun run console -- agents delete --key <agent-key> --yes
111
143
  ```
112
144
 
113
145
  `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
146
 
147
+ `agents onboard --interactive` asks at the end whether to set up a Lark bot. Lark setup can create a Feishu app by QR scan/open-link, or accept a pasted App ID/App Secret. `agents update --interactive` manages Lark binding, rebind, and unbind for existing agents.
148
+
115
149
  ### Lark Bots
116
150
 
117
151
  ```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
152
  # List configured bots; secrets are redacted
133
153
  bun run console -- lark list
134
154
 
@@ -139,7 +159,9 @@ bun run console -- lark events --limit 20
139
159
  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
160
  ```
141
161
 
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:
162
+ Lark bot binding is managed as part of agent settings. `agents update --lark-app-id ... --lark-app-secret ...` validates the credential, resolves `bot open_id`, writes `~/.pal/lark.json` or `PAL_LARK_CONFIG`, and asks the running server to reload Lark integration. An agent may only be bound to one Lark bot at a time; use `--rebind-lark` to move that agent from its previous bot to the new bot, or `--unbind-lark` to clear the binding while keeping the credential.
163
+
164
+ The Lark config file is written with mode `0600`. Use `--no-reload` when you want to edit or validate the file first, then trigger reload manually:
143
165
 
144
166
  ```bash
145
167
  curl -s -X POST http://127.0.0.1:4127/api/lark/reload
@@ -180,7 +202,7 @@ CLI connects through the local daemon. It is the surface used by coding agents a
180
202
 
181
203
  ```bash
182
204
  # Send a message
183
- bun run cli -- send --room general --from alice [--to codex] "Task completed"
205
+ bun run cli -- send --room general --from alice [--mention codex ...] "Task completed"
184
206
 
185
207
  # Reply to an existing message
186
208
  bun run cli -- send --room general --from codex --parent 1 "Reply content"
@@ -195,6 +217,9 @@ bun run cli -- rooms members --room general [--json]
195
217
  # Invite an agent to a room with a delivery mode
196
218
  bun run cli -- rooms invite --room general --agent codex [--mode mentions|all|periodic|muted|off]
197
219
 
220
+ # Restart the active runtime for an agent in a room
221
+ bun run cli -- rooms restart-agent --room general --agent codex [--json]
222
+
198
223
  # Create a topic inside a room
199
224
  bun run cli -- topics create --room general "Investigation"
200
225
 
@@ -224,6 +249,7 @@ bun run cli -- chats [--json]
224
249
  bun run cli -- runs [--json]
225
250
  bun run cli -- run-action <run-id> kill
226
251
  bun run cli -- run-action <run-id> restart
252
+ bun run cli -- rooms restart-agent --room general --agent codex
227
253
  ```
228
254
 
229
255
  ## Daemon Commands
@@ -248,7 +274,21 @@ bun run daemon -- --agent codex --computer-id hm-media-demo --computer-secret pa
248
274
  bun run daemon -- --server http://127.0.0.1:4127 --api-key sk_machine_... --once
249
275
  ```
250
276
 
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.
277
+ PowerShell equivalents:
278
+
279
+ ```powershell
280
+ # Preferred computer-first startup
281
+ bun run daemon -- --server http://127.0.0.1:4127 --api-key sk_machine_...
282
+
283
+ # Equivalent env-var startup
284
+ $env:PAL_API_KEY = "sk_machine_..."
285
+ bun run daemon -- --server http://127.0.0.1:4127
286
+
287
+ # Windows project checkout as runtime cwd
288
+ bun run daemon -- --server http://127.0.0.1:4127 --api-key sk_machine_... --cwd C:\Projects\pal
289
+ ```
290
+
291
+ The daemon creates one local API for CLI calls, connects the computer to the server, opens a delivery WebSocket at `/api/daemon/ws`, heartbeats the connection, reconciles assigned agents, claims pending deliveries for currently assigned agents, and starts one runtime process per claimed delivery. The WebSocket is a wakeup path, not the reliability boundary: deliveries are still durably recorded as `pending`, so messages can be sent while the daemon is offline or the network is unhealthy, and the daemon drains pending work when the socket reconnects. If the WebSocket is unavailable, the daemon falls back to processing pending deliveries during its heartbeat loop. It can stay online with no assigned agents. Runs have no default timeout; use run actions to kill or restart them.
252
292
 
253
293
  ## Environment Variables
254
294
 
@@ -281,7 +321,7 @@ bun run console -- lark-users delete --user-id <union-id>
281
321
  ## Runtime Semantics
282
322
 
283
323
  - Rooms are the primary conversation container; `chat` remains as a compatibility alias in several APIs.
284
- - Agents receive deliveries through room subscriptions, direct recipients, or Lark bot bindings.
324
+ - Agents receive deliveries through room subscriptions, direct mentions, or Lark bot bindings.
285
325
  - A daemon starts one agent run for each claimed delivery.
286
326
  - Runs do not have a default timeout. Agents can work as long as needed.
287
327
  - A visible chat reply is just a message, not run completion.
@@ -300,6 +340,7 @@ Common read and room APIs:
300
340
  - `GET /api/rooms/<room-id-or-name>/members`
301
341
  - `GET /api/rooms/<room-id-or-name>/mentionables`
302
342
  - `POST /api/rooms/<room-id-or-name>/agents`
343
+ - `POST /api/rooms/<room-id-or-name>/agents/<agent-key>/restart`
303
344
  - `POST /api/rooms/<room-id-or-name>/topics`
304
345
  - `GET /api/messages?chat=general&after=0&limit=50`
305
346
  - `GET /api/messages/<id>`
@@ -311,6 +352,7 @@ Computer, daemon, and delivery APIs:
311
352
  - `POST /api/computers/provision`
312
353
  - `POST /api/computers/connect`
313
354
  - `POST /api/computers/<computer-id>/heartbeat`
355
+ - `GET /api/daemon/ws`
314
356
  - `POST /api/daemons`
315
357
  - `GET /api/deliveries?agent=codex&status=pending&limit=50`
316
358
  - `POST /api/deliveries`
@@ -349,7 +391,7 @@ Example message body:
349
391
  {
350
392
  "room": "general",
351
393
  "sender": "alice",
352
- "recipient": "codex",
394
+ "mentions": ["codex"],
353
395
  "content": "hello"
354
396
  }
355
397
  ```
package/package.json CHANGED
@@ -1,22 +1,35 @@
1
1
  {
2
2
  "name": "@controlflow-ai/daemon",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "server": "bun run src/server.ts",
7
+ "server:dev": "bun --watch src/server.ts",
7
8
  "daemon": "bun run src/daemon.ts",
9
+ "daemon:dev": "bun --watch src/daemon.ts",
8
10
  "cli": "bun run src/cli.ts",
9
11
  "console": "bun run src/console.ts",
12
+ "web:dev": "vite --config web/app/vite.config.ts --host 127.0.0.1",
13
+ "web:build": "vite build --config web/app/vite.config.ts",
14
+ "web:preview": "vite preview --config web/app/vite.config.ts --host 127.0.0.1",
10
15
  "typecheck": "tsc --noEmit",
11
16
  "test": "bun test",
12
17
  "check": "bun run typecheck && bun test"
13
18
  },
14
19
  "devDependencies": {
15
20
  "@types/bun": "latest",
16
- "typescript": "^5.9.3"
21
+ "@types/qrcode": "^1.5.6",
22
+ "@types/react": "^19.2.15",
23
+ "@types/react-dom": "^19.2.3",
24
+ "@vitejs/plugin-react": "^6.0.2",
25
+ "typescript": "^5.9.3",
26
+ "vite": "^8.0.14"
17
27
  },
18
28
  "dependencies": {
19
- "@larksuiteoapi/node-sdk": "^1.59.0"
29
+ "@larksuiteoapi/node-sdk": "^1.59.0",
30
+ "qrcode": "^1.5.4",
31
+ "react": "^19.2.6",
32
+ "react-dom": "^19.2.6"
20
33
  },
21
34
  "bin": {
22
35
  "daemon": "bin/daemon.js",
@@ -0,0 +1,30 @@
1
+ const avatarKeys = [
2
+ 'sage',
3
+ 'teal',
4
+ 'indigo',
5
+ 'amber',
6
+ 'rose',
7
+ 'violet',
8
+ 'slate',
9
+ 'moss',
10
+ ] as const;
11
+
12
+ export type AgentAvatar = typeof avatarKeys[number];
13
+
14
+ function hashSeed(seed: string): number {
15
+ let hash = 2166136261;
16
+ for (const char of seed) {
17
+ hash ^= char.charCodeAt(0);
18
+ hash = Math.imul(hash, 16777619);
19
+ }
20
+ return hash >>> 0;
21
+ }
22
+
23
+ export function generateAgentAvatar(agentKey: string, displayName = ''): AgentAvatar {
24
+ const seed = `${agentKey.trim().toLowerCase()}:${displayName.trim().toLowerCase()}`;
25
+ return avatarKeys[hashSeed(seed) % avatarKeys.length]!;
26
+ }
27
+
28
+ export function normalizeAgentAvatar(value: unknown, agentKey: string, displayName = ''): AgentAvatar {
29
+ return avatarKeys.includes(value as AgentAvatar) ? value as AgentAvatar : generateAgentAvatar(agentKey, displayName);
30
+ }
@@ -0,0 +1,28 @@
1
+ import type { MessageStore } from './db.js';
2
+
3
+ export function agentKeyFromDisplayName(displayName: string): string {
4
+ const slug = displayName
5
+ .normalize('NFKD')
6
+ .replace(/[\u0300-\u036f]/g, '')
7
+ .toLowerCase()
8
+ .replace(/['’]/g, '')
9
+ .replace(/[^\p{Letter}\p{Number}]+/gu, '-')
10
+ .replace(/^-+|-+$/g, '')
11
+ .replace(/-{2,}/g, '-')
12
+ .split('')
13
+ .slice(0, 64)
14
+ .join('')
15
+ .replace(/-+$/g, '');
16
+ return slug || 'agent';
17
+ }
18
+
19
+ export function uniqueAgentKeyFromDisplayName(displayName: string, store: Pick<MessageStore, 'getAgent'>): string {
20
+ const base = agentKeyFromDisplayName(displayName);
21
+ let candidate = base;
22
+ let suffix = 2;
23
+ while (store.getAgent(candidate)) {
24
+ candidate = `${base}-${suffix}`;
25
+ suffix += 1;
26
+ }
27
+ return candidate;
28
+ }
@@ -0,0 +1,359 @@
1
+ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
2
+ import { isAbsolute, join, normalize, relative, win32 } from 'node:path';
3
+ import type { AgentRuntimeRunInput } from './agent-runtime.js';
4
+
5
+ export type FilesystemPermissionMode = 'project-write' | 'scoped-write' | 'full-access';
6
+
7
+ export interface RuntimeLaunchContext {
8
+ runtimeRoot: string;
9
+ agentHome: string;
10
+ projectRoot: string | null;
11
+ }
12
+
13
+ export interface AgentPermissionProfile {
14
+ filesystemMode: FilesystemPermissionMode;
15
+ extraWritableRoots: string[];
16
+ }
17
+
18
+ export interface PermissionValidationDiagnostic {
19
+ level: 'error' | 'warning';
20
+ code: string;
21
+ message: string;
22
+ path?: string;
23
+ }
24
+
25
+ export interface PermissionValidationResult {
26
+ ok: boolean;
27
+ profile: AgentPermissionProfile;
28
+ diagnostics: PermissionValidationDiagnostic[];
29
+ warnings: PermissionValidationDiagnostic[];
30
+ }
31
+
32
+ export const defaultAgentPermissionProfile: AgentPermissionProfile = {
33
+ filesystemMode: 'project-write',
34
+ extraWritableRoots: [],
35
+ };
36
+
37
+ function isWindowsAbsolutePath(path: string): boolean {
38
+ return /^[a-zA-Z]:[\\/]/.test(path) || /^\\\\[^\\]+\\[^\\]+/.test(path);
39
+ }
40
+
41
+ function isPortableAbsolutePath(path: string): boolean {
42
+ return isAbsolute(path) || isWindowsAbsolutePath(path);
43
+ }
44
+
45
+ function normalizeWindowsWritableRoot(path: string): string {
46
+ const normalized = win32.normalize(path.trim());
47
+ const root = win32.parse(normalized).root;
48
+ return normalized === root ? normalized : normalized.replace(/[\\/]+$/, '');
49
+ }
50
+
51
+ export function isFilesystemPermissionMode(value: unknown): value is FilesystemPermissionMode {
52
+ return value === 'project-write' || value === 'scoped-write' || value === 'full-access';
53
+ }
54
+
55
+ export function normalizeWritableRoot(path: string): string {
56
+ if (isWindowsAbsolutePath(path.trim())) return normalizeWindowsWritableRoot(path);
57
+ const normalized = normalize(path.trim());
58
+ return normalized === '/' ? normalized : normalized.replace(/\/+$/, '');
59
+ }
60
+
61
+ export function normalizeExtraWritableRoots(paths: unknown): string[] {
62
+ if (!Array.isArray(paths)) return [];
63
+ const seen = new Set<string>();
64
+ const roots: string[] = [];
65
+ for (const item of paths) {
66
+ if (typeof item !== 'string') continue;
67
+ const normalized = normalizeWritableRoot(item);
68
+ if (!normalized || seen.has(normalized)) continue;
69
+ seen.add(normalized);
70
+ roots.push(normalized);
71
+ }
72
+ return roots;
73
+ }
74
+
75
+ export function coerceAgentPermissionProfile(input: unknown): AgentPermissionProfile {
76
+ if (!input || typeof input !== 'object') return { ...defaultAgentPermissionProfile };
77
+ const record = input as Record<string, unknown>;
78
+ return {
79
+ filesystemMode: isFilesystemPermissionMode(record.filesystemMode) ? record.filesystemMode : defaultAgentPermissionProfile.filesystemMode,
80
+ extraWritableRoots: normalizeExtraWritableRoots(record.extraWritableRoots),
81
+ };
82
+ }
83
+
84
+ export function validateAgentPermissionProfile(input: unknown, options: { checkFilesystem?: boolean } = {}): PermissionValidationResult {
85
+ const raw = input && typeof input === 'object' ? input as Record<string, unknown> : {};
86
+ const diagnostics: PermissionValidationDiagnostic[] = [];
87
+ const filesystemMode = raw.filesystemMode;
88
+ if (filesystemMode !== undefined && !isFilesystemPermissionMode(filesystemMode)) {
89
+ diagnostics.push({ level: 'error', code: 'BAD_FILESYSTEM_MODE', message: 'filesystemMode must be project-write, scoped-write, or full-access' });
90
+ }
91
+
92
+ if ('extraWritableRoots' in raw && !Array.isArray(raw.extraWritableRoots)) {
93
+ diagnostics.push({ level: 'error', code: 'BAD_EXTRA_WRITABLE_ROOTS', message: 'extraWritableRoots must be an array of absolute paths' });
94
+ }
95
+
96
+ const seen = new Set<string>();
97
+ const roots: string[] = [];
98
+ if (Array.isArray(raw.extraWritableRoots)) {
99
+ for (const value of raw.extraWritableRoots) {
100
+ if (typeof value !== 'string') {
101
+ diagnostics.push({ level: 'error', code: 'BAD_EXTRA_WRITABLE_ROOT', message: 'extra writable root must be a string' });
102
+ continue;
103
+ }
104
+ const trimmed = value.trim();
105
+ if (!trimmed) {
106
+ diagnostics.push({ level: 'error', code: 'EMPTY_EXTRA_WRITABLE_ROOT', message: 'extra writable root cannot be empty' });
107
+ continue;
108
+ }
109
+ const normalized = normalizeWritableRoot(trimmed);
110
+ if (!isPortableAbsolutePath(normalized)) {
111
+ diagnostics.push({ level: 'error', code: 'EXTRA_WRITABLE_ROOT_NOT_ABSOLUTE', message: 'extra writable root must be an absolute path', path: value });
112
+ continue;
113
+ }
114
+ if (seen.has(normalized)) {
115
+ diagnostics.push({ level: 'error', code: 'DUPLICATE_EXTRA_WRITABLE_ROOT', message: 'extra writable roots cannot contain duplicates', path: normalized });
116
+ continue;
117
+ }
118
+ seen.add(normalized);
119
+ if (options.checkFilesystem) {
120
+ try {
121
+ const stat = statSync(normalized);
122
+ if (!stat.isDirectory()) diagnostics.push({ level: 'error', code: 'EXTRA_WRITABLE_ROOT_NOT_DIRECTORY', message: 'extra writable root must be a directory', path: normalized });
123
+ } catch {
124
+ diagnostics.push({ level: 'error', code: 'EXTRA_WRITABLE_ROOT_NOT_FOUND', message: 'extra writable root does not exist', path: normalized });
125
+ }
126
+ }
127
+ roots.push(normalized);
128
+ }
129
+ }
130
+
131
+ const profile: AgentPermissionProfile = {
132
+ filesystemMode: isFilesystemPermissionMode(filesystemMode) ? filesystemMode : defaultAgentPermissionProfile.filesystemMode,
133
+ extraWritableRoots: roots,
134
+ };
135
+
136
+ if (profile.filesystemMode === 'full-access') {
137
+ diagnostics.push({ level: 'warning', code: 'FULL_ACCESS_DANGEROUS', message: 'full-access disables filesystem sandboxing and should only be used for trusted agents' });
138
+ }
139
+
140
+ const warnings = diagnostics.filter((diagnostic) => diagnostic.level === 'warning');
141
+ return { ok: diagnostics.every((diagnostic) => diagnostic.level !== 'error'), profile, diagnostics, warnings };
142
+ }
143
+
144
+ export function runtimeStateHome(input: AgentRuntimeRunInput): string {
145
+ return input.launchContext?.agentHome ?? input.agentHome ?? input.cwd;
146
+ }
147
+
148
+ export function runtimeLaunchRoot(input: AgentRuntimeRunInput): string {
149
+ return input.launchContext?.runtimeRoot ?? input.agentHome ?? input.cwd;
150
+ }
151
+
152
+ export function buildRuntimeLaunchContext(input: AgentRuntimeRunInput): RuntimeLaunchContext {
153
+ const agentHome = input.agentHome ?? input.cwd;
154
+ const projectRoot = input.projectCwd ?? null;
155
+ return {
156
+ runtimeRoot: agentHome,
157
+ agentHome,
158
+ projectRoot,
159
+ };
160
+ }
161
+
162
+ export function effectivePermissionProfile(input: AgentRuntimeRunInput): AgentPermissionProfile {
163
+ return input.permissionProfile ?? defaultAgentPermissionProfile;
164
+ }
165
+
166
+ export function buildCodexPermissionArgs(profile: AgentPermissionProfile, context: RuntimeLaunchContext): string[] {
167
+ if (profile.filesystemMode === 'full-access') return ['--sandbox', 'danger-full-access'];
168
+ const args = ['--sandbox', 'workspace-write', '-c', 'sandbox_workspace_write.network_access=true'];
169
+ for (const root of writableRootsForProfile(profile, context)) {
170
+ if (normalizeWritableRoot(root) === normalizeWritableRoot(context.runtimeRoot)) continue;
171
+ args.push('--add-dir', root);
172
+ }
173
+ return args;
174
+ }
175
+
176
+ export function writableRootsForProfile(profile: AgentPermissionProfile, context: RuntimeLaunchContext): string[] {
177
+ if (profile.filesystemMode === 'full-access') return ['/'];
178
+ const roots = [context.runtimeRoot];
179
+ if (context.projectRoot) roots.push(context.projectRoot);
180
+ if (profile.filesystemMode === 'scoped-write') roots.push(...profile.extraWritableRoots);
181
+ return Array.from(new Set(roots.map(normalizeWritableRoot)));
182
+ }
183
+
184
+ export function buildCocoPermissionArgs(profile: AgentPermissionProfile, context: RuntimeLaunchContext): string[] {
185
+ if (profile.filesystemMode === 'full-access') return ['-y'];
186
+ return writableRootsForProfile(profile, context).flatMap((root) => ['--add-dir', root]);
187
+ }
188
+
189
+ function pathWithinRoot(path: string, root: string): boolean {
190
+ const normalizedPath = normalizeWritableRoot(path);
191
+ const normalizedRoot = normalizeWritableRoot(root);
192
+ if (normalizedRoot === '/') return true;
193
+ if (normalizedPath === normalizedRoot) return true;
194
+ if (isWindowsAbsolutePath(normalizedPath) || isWindowsAbsolutePath(normalizedRoot)) {
195
+ if (!isWindowsAbsolutePath(normalizedPath) || !isWindowsAbsolutePath(normalizedRoot)) return false;
196
+ const rel = win32.relative(normalizedRoot, normalizedPath);
197
+ return Boolean(rel) && !rel.startsWith('..') && !win32.isAbsolute(rel);
198
+ }
199
+ const rel = relative(normalizedRoot, normalizedPath);
200
+ return Boolean(rel) && !rel.startsWith('..') && !isAbsolute(rel);
201
+ }
202
+
203
+ function collectAbsolutePaths(value: unknown, paths: string[] = []): string[] {
204
+ if (typeof value === 'string') {
205
+ const trimmed = value.trim();
206
+ if (isPortableAbsolutePath(trimmed)) paths.push(normalizeWritableRoot(trimmed));
207
+ return paths;
208
+ }
209
+ if (Array.isArray(value)) {
210
+ for (const item of value) collectAbsolutePaths(item, paths);
211
+ return paths;
212
+ }
213
+ if (value && typeof value === 'object') {
214
+ for (const item of Object.values(value)) collectAbsolutePaths(item, paths);
215
+ }
216
+ return paths;
217
+ }
218
+
219
+ function permissionOptionMatches(option: Record<string, unknown>, pattern: RegExp): boolean {
220
+ return pattern.test(String(option.name ?? option.optionId ?? option.description ?? option.kind ?? ''));
221
+ }
222
+
223
+ export function selectAcpPermissionOption(input: {
224
+ params?: Record<string, unknown>;
225
+ profile: AgentPermissionProfile;
226
+ context: RuntimeLaunchContext;
227
+ }): string {
228
+ const options = Array.isArray(input.params?.options) ? input.params.options as Array<Record<string, unknown>> : [];
229
+ const fallback = String(options[0]?.optionId ?? 'allow');
230
+ const allowOption = options.find((option) => permissionOptionMatches(option, /allow|approve|yes/i));
231
+ const denyOption = options.find((option) => permissionOptionMatches(option, /deny|reject|no/i));
232
+
233
+ if (input.profile.filesystemMode === 'full-access') {
234
+ return String((allowOption ?? options[0])?.optionId ?? 'allow');
235
+ }
236
+
237
+ const paths = Array.from(new Set(collectAbsolutePaths(input.params)));
238
+ const roots = writableRootsForProfile(input.profile, input.context);
239
+ const hasOutsidePath = paths.some((path) => !roots.some((root) => pathWithinRoot(path, root)));
240
+ if (hasOutsidePath && denyOption) return String(denyOption.optionId ?? denyOption.name ?? fallback);
241
+ return String((allowOption ?? options[0])?.optionId ?? 'allow');
242
+ }
243
+
244
+ function directoryPattern(path: string): string {
245
+ const withoutTrailingSlash = path.replace(/\/+$/, '');
246
+ return `${withoutTrailingSlash}/**`;
247
+ }
248
+
249
+ const openCodeBuiltinAgentNames = ['build', 'plan', 'general', 'explore', 'scout'];
250
+
251
+ function nonInteractiveOpenCodePermissions(): Record<string, unknown> {
252
+ return {
253
+ read: { '*': 'allow' },
254
+ list: 'allow',
255
+ glob: 'allow',
256
+ grep: 'allow',
257
+ edit: 'allow',
258
+ bash: 'allow',
259
+ task: 'allow',
260
+ skill: 'allow',
261
+ lsp: 'allow',
262
+ todoread: 'allow',
263
+ todowrite: 'allow',
264
+ webfetch: 'allow',
265
+ websearch: 'allow',
266
+ codesearch: 'allow',
267
+ ask: 'deny',
268
+ question: 'deny',
269
+ doom_loop: 'deny',
270
+ };
271
+ }
272
+
273
+ function nonInteractiveOpenCodeTools(): Record<string, boolean> {
274
+ return {
275
+ ask: false,
276
+ question: false,
277
+ };
278
+ }
279
+
280
+ function withPalOwnedOpenCodePermissions(baseConfig: Record<string, unknown>, permission: Record<string, unknown>): Record<string, unknown> {
281
+ const { agent: baseAgent, permission: _basePermission, tools: _baseTools, ...rest } = baseConfig;
282
+ const tools = nonInteractiveOpenCodeTools();
283
+ const config: Record<string, unknown> = {
284
+ ...rest,
285
+ '$schema': typeof baseConfig.$schema === 'string' ? baseConfig.$schema : 'https://opencode.ai/config.json',
286
+ permission,
287
+ tools,
288
+ };
289
+
290
+ const agentNames = new Set(openCodeBuiltinAgentNames);
291
+ if (baseAgent && typeof baseAgent === 'object' && !Array.isArray(baseAgent)) {
292
+ for (const name of Object.keys(baseAgent as Record<string, unknown>)) agentNames.add(name);
293
+ }
294
+
295
+ const agent: Record<string, unknown> = {};
296
+ for (const name of agentNames) {
297
+ const rawValue = baseAgent && typeof baseAgent === 'object' && !Array.isArray(baseAgent)
298
+ ? (baseAgent as Record<string, unknown>)[name]
299
+ : undefined;
300
+ if (rawValue !== undefined) {
301
+ if (!rawValue || typeof rawValue !== 'object' || Array.isArray(rawValue)) {
302
+ agent[name] = rawValue;
303
+ continue;
304
+ }
305
+ const { permission: _agentPermission, tools: _agentTools, ...agentRest } = rawValue as Record<string, unknown>;
306
+ agent[name] = { ...agentRest, permission, tools };
307
+ } else {
308
+ agent[name] = { permission, tools };
309
+ }
310
+ }
311
+ config.agent = agent;
312
+
313
+ return config;
314
+ }
315
+
316
+ export function buildOpenCodePermissionConfig(profile: AgentPermissionProfile, context: RuntimeLaunchContext, baseConfig: Record<string, unknown> = {}): Record<string, unknown> {
317
+ if (profile.filesystemMode === 'full-access') {
318
+ return withPalOwnedOpenCodePermissions(baseConfig, {
319
+ ...nonInteractiveOpenCodePermissions(),
320
+ external_directory: 'allow',
321
+ });
322
+ }
323
+
324
+ const writableRoots = writableRootsForProfile(profile, context);
325
+ const externalDirectory: Record<string, 'allow' | 'deny'> = { '*': 'deny' };
326
+ for (const root of writableRoots) {
327
+ if (normalizeWritableRoot(root) !== normalizeWritableRoot(context.runtimeRoot)) {
328
+ externalDirectory[directoryPattern(root)] = 'allow';
329
+ }
330
+ }
331
+
332
+ return withPalOwnedOpenCodePermissions(baseConfig, {
333
+ ...nonInteractiveOpenCodePermissions(),
334
+ external_directory: externalDirectory,
335
+ });
336
+ }
337
+
338
+ function readProjectOpenCodeConfig(runtimeRoot: string): Record<string, unknown> {
339
+ const path = join(runtimeRoot, 'opencode.json');
340
+ if (!existsSync(path)) return {};
341
+ try {
342
+ const parsed = JSON.parse(readFileSync(path, 'utf8')) as unknown;
343
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as Record<string, unknown> : {};
344
+ } catch {
345
+ return {};
346
+ }
347
+ }
348
+
349
+ export function writeGeneratedOpenCodeConfig(input: AgentRuntimeRunInput): string {
350
+ const context = input.launchContext ?? buildRuntimeLaunchContext(input);
351
+ const profile = effectivePermissionProfile(input);
352
+ const baseConfig = readProjectOpenCodeConfig(context.projectRoot ?? context.runtimeRoot);
353
+ const config = buildOpenCodePermissionConfig(profile, context, baseConfig);
354
+ const dir = join(context.agentHome, 'generated');
355
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
356
+ const path = join(dir, 'opencode-pal-permissions.json');
357
+ writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
358
+ return path;
359
+ }