@controlflow-ai/daemon 0.1.2 → 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 (61) hide show
  1. package/README.md +54 -6
  2. package/package.json +3 -1
  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 +795 -28
  7. package/src/agent-workspace.ts +183 -0
  8. package/src/app.ts +1970 -79
  9. package/src/args.ts +54 -7
  10. package/src/cli.ts +873 -14
  11. package/src/client.ts +472 -10
  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 +230 -20
  16. package/src/daemon-client.ts +116 -3
  17. package/src/daemon.ts +936 -98
  18. package/src/db.ts +3128 -122
  19. package/src/delivery-ws.ts +269 -0
  20. package/src/format.ts +4 -1
  21. package/src/lark/cli.ts +3 -3
  22. package/src/lark/event-router.ts +60 -4
  23. package/src/lark/inbound-events.ts +156 -3
  24. package/src/lark/server-integration.ts +659 -111
  25. package/src/lark/ws-daemon.ts +136 -10
  26. package/src/local-api.ts +545 -15
  27. package/src/local-auth.ts +33 -1
  28. package/src/message-attachments.ts +71 -0
  29. package/src/messaging-cli.ts +741 -0
  30. package/src/messaging-status.ts +669 -0
  31. package/src/migrations/024_agents_model.ts +10 -0
  32. package/src/migrations/025_room_archive.ts +44 -0
  33. package/src/migrations/026_project_archive.ts +44 -0
  34. package/src/migrations/027_agent_permission_profiles.ts +16 -0
  35. package/src/migrations/028_lark_websocket_restart_state.ts +16 -0
  36. package/src/migrations/029_held_message_drafts.ts +32 -0
  37. package/src/migrations/030_agent_room_read_state.ts +25 -0
  38. package/src/migrations/031_room_tasks.ts +29 -0
  39. package/src/migrations/032_room_reminders.ts +29 -0
  40. package/src/migrations/033_room_saved_messages.ts +25 -0
  41. package/src/migrations/034_agent_activity_events.ts +27 -0
  42. package/src/migrations/035_agent_avatars.ts +17 -0
  43. package/src/migrations/036_project_agent_defaults.ts +21 -0
  44. package/src/migrations/037_message_attachments.ts +36 -0
  45. package/src/migrations/038_agent_activity_room_scope.ts +64 -0
  46. package/src/migrations/039_message_attachments_path.ts +34 -0
  47. package/src/migrations/040_message_attachments_file_schema.ts +80 -0
  48. package/src/migrations/041_room_system_events.ts +30 -0
  49. package/src/migrations/042_message_attachment_file_kind.ts +52 -0
  50. package/src/migrations/043_room_mode_skill_registry.ts +92 -0
  51. package/src/migrations/044_workflow_runtime.ts +69 -0
  52. package/src/migrations/045_skill_repository_ownership.ts +64 -0
  53. package/src/migrations.ts +69 -1
  54. package/src/neeko.ts +40 -4
  55. package/src/runtime-env.ts +179 -0
  56. package/src/runtime-registry.ts +83 -13
  57. package/src/server.ts +244 -4
  58. package/src/token-file.ts +13 -6
  59. package/src/types.ts +362 -0
  60. package/src/workflow-runtime.ts +275 -0
  61. package/src/web.ts +0 -904
@@ -1,6 +1,10 @@
1
- import type { Message, RoomParticipant, RunAction } from './types.js';
2
- import { join } from 'node:path';
1
+ import type { AgentActivityKind, AgentRoomSubscription, DeliveryContext, EnabledSkill, Message, RoomMode, RoomParticipant, RoomSavedMessage, RunAction } from './types.js';
2
+ import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
3
+ import { createWriteStream, existsSync, mkdirSync, type WriteStream } from 'node:fs';
4
+ import { delimiter, join, win32 as pathWin32 } from 'node:path';
5
+ import { homeDir, privatePalCliExecutableName } from './config.js';
3
6
  import { sanitizeProviderIds } from './provider-identity.js';
7
+ import { buildRuntimeLaunchContext, runtimeLaunchRoot as permissionRuntimeLaunchRoot, runtimeStateHome as permissionRuntimeStateHome, selectAcpPermissionOption, effectivePermissionProfile, type AgentPermissionProfile, type RuntimeLaunchContext } from './agent-permissions.js';
4
8
 
5
9
  export interface AgentRuntimeRunInput {
6
10
  agent: string;
@@ -9,6 +13,8 @@ export interface AgentRuntimeRunInput {
9
13
  cwd: string;
10
14
  agentHome?: string;
11
15
  projectCwd?: string;
16
+ permissionProfile?: AgentPermissionProfile;
17
+ launchContext?: RuntimeLaunchContext;
12
18
  projectContext?: {
13
19
  id: string;
14
20
  name: string;
@@ -23,15 +29,38 @@ export interface AgentRuntimeRunInput {
23
29
  localDaemonToken?: string;
24
30
  runtimeSessionId?: string | null;
25
31
  roomParticipants?: RoomParticipant[];
32
+ roomAgentSubscriptions?: AgentRoomSubscription[];
33
+ roomMode?: RoomMode;
34
+ enabledSkills?: EnabledSkill[];
26
35
  recentMessages?: Message[];
36
+ deliveryContext?: DeliveryContext;
37
+ runtimeAttachments?: RuntimeAttachment[];
27
38
  dryRun?: boolean;
28
39
  privateCliBinDir?: string;
29
40
  palCliCommand?: string;
41
+ runtimeEnv?: NodeJS.ProcessEnv;
30
42
  signal?: AbortSignal;
31
43
  onStart?: (pid: number | null) => Promise<void>;
44
+ onActivity?: (event: AgentRuntimeActivityEvent) => Promise<void>;
32
45
  getAction?: () => Promise<RunAction | null>;
33
46
  }
34
47
 
48
+ export interface RuntimeAttachment {
49
+ id: string;
50
+ messageId: number;
51
+ kind: 'image' | 'file';
52
+ mimeType: string;
53
+ filename: string;
54
+ path: string;
55
+ }
56
+
57
+ export interface AgentRuntimeActivityEvent {
58
+ kind: AgentActivityKind;
59
+ title: string;
60
+ detail?: string;
61
+ metadata?: Record<string, unknown>;
62
+ }
63
+
35
64
  export function formatRoomParticipantContext(participants: RoomParticipant[] | undefined): string {
36
65
  if (!participants || participants.length === 0) {
37
66
  return '\nRoom participants: no local participant snapshot is available yet.';
@@ -39,12 +68,58 @@ export function formatRoomParticipantContext(participants: RoomParticipant[] | u
39
68
  const lines = participants.slice(0, 50).map((p) => {
40
69
  const participant = sanitizeProviderIds(p.participant_id);
41
70
  const name = p.display_name ? ` (${sanitizeProviderIds(p.display_name)})` : '';
42
- return `- ${p.kind}: ${participant}${name}, source=${p.source}`;
71
+ const mentionKey = p.kind === 'agent' ? `, mention_key=${participant}` : '';
72
+ return `- ${p.kind}: ${participant}${name}${mentionKey}, source=${p.source}`;
43
73
  });
44
74
  const suffix = participants.length > 50 ? `\n- ... ${participants.length - 50} more participants omitted` : '';
45
75
  return `\nRoom participants:\n${lines.join('\n')}${suffix}`;
46
76
  }
47
77
 
78
+ export function formatRoomDeliveryPolicyContext(input: {
79
+ agent: string;
80
+ subscriptions?: AgentRoomSubscription[];
81
+ }): string {
82
+ const modes = new Map((input.subscriptions ?? [])
83
+ .filter((subscription) => subscription.channel_id === null)
84
+ .map((subscription) => [subscription.agent, subscription.mode]));
85
+ const currentMode = modes.get(input.agent) ?? 'mentions';
86
+ const strategyLines = [...modes.entries()]
87
+ .sort(([left], [right]) => left.localeCompare(right))
88
+ .slice(0, 50)
89
+ .map(([agent, mode]) => {
90
+ const current = agent === input.agent ? ' (you)' : '';
91
+ return `- @${sanitizeProviderIds(agent)}${current}: ${mode}`;
92
+ });
93
+ const strategyBlock = strategyLines.length > 0
94
+ ? `\nCurrent agent receive strategies in this room:\n${strategyLines.join('\n')}`
95
+ : '\nCurrent agent receive strategies in this room: no subscription snapshot is available; assume agents default to mentions mode unless a direct mention succeeds.';
96
+ return `\nRoom delivery and mention semantics:
97
+ - A direct mention (\`--mention <mention_key>\` or a message addressed to @agent) wakes that agent even when ordinary room messages would not.
98
+ - For agent participants, use the exact \`mention_key\` shown in Room participants.
99
+ - An explicit @all/@_all/所有人 mention is a room-wide handoff: every active room agent that can participate may receive it. Use it only when all agents should inspect the request.
100
+ - A plain room message without a direct recipient is shared context. Agents in \`all\` mode receive it immediately; agents in \`mentions\` mode usually do not wake unless directly mentioned or included by @all.
101
+ - \`periodic\` means pull/batched attention: do not rely on an immediate wakeup from ordinary chatter. Directly mention the agent when you need timely action.
102
+ - \`muted\` or \`off\` means the agent should not be expected to react unless the system explicitly delivers a direct task.
103
+ - Your current receive strategy here is \`${currentMode}\`. When coordinating, decide whether the other agent will naturally see the room message or needs a direct \`--mention <mention_key>\` send.${strategyBlock}`;
104
+ }
105
+
106
+ export function formatRoomModeAndSkillContext(input: {
107
+ roomMode?: RoomMode;
108
+ enabledSkills?: EnabledSkill[];
109
+ }): string {
110
+ const mode = input.roomMode ?? 'standard';
111
+ const modeDescription = mode === 'idea_development'
112
+ ? 'This room is for developing ideas through conversation and .pal docs before implementation handoff.'
113
+ : 'This is an ordinary PAL room. Do not apply special Idea Development workflow unless the user explicitly asks for it.';
114
+ const skills = input.enabledSkills ?? [];
115
+ const skillBlock = skills.length === 0
116
+ ? 'Enabled skills: none.'
117
+ : `Enabled skills:\n${skills.map((skill) => (
118
+ `- ${sanitizeProviderIds(skill.key)} (${sanitizeProviderIds(skill.binding_scope)} priority ${skill.binding_priority}): ${sanitizeProviderIds(skill.name)}\n${sanitizeProviderIds(skill.instruction_content)}`
119
+ )).join('\n')}`;
120
+ return `\nRoom mode and skills:\n- Current room mode: ${mode}\n- ${modeDescription}\n${skillBlock}`;
121
+ }
122
+
48
123
  export function formatRecentMessageContext(messages: Message[] | undefined, currentMessageId: number): string {
49
124
  if (!messages || messages.length === 0) {
50
125
  return '\nRecent room history: no local message history is available yet.';
@@ -55,35 +130,119 @@ export function formatRecentMessageContext(messages: Message[] | undefined, curr
55
130
  const recipient = message.recipient ? ` to=@${sanitizeProviderIds(message.recipient)}` : '';
56
131
  const sender = sanitizeProviderIds(message.sender);
57
132
  const content = sanitizeProviderIds(message.content).replace(/\s+/g, ' ').trim().slice(0, 500);
58
- return `- [${message.id}${marker}] @${sender}${recipient}${parent}: ${content}`;
133
+ const attachments = formatMessageAttachmentSummary(message);
134
+ return `- [${message.id}${marker}] @${sender}${recipient}${parent}: ${content}${attachments}`;
59
135
  });
60
136
  const omitted = messages.length > 12 ? `\n- ... ${messages.length - 12} older messages omitted` : '';
61
137
  return `\nRecent room history:\n${lines.join('\n')}${omitted}`;
62
138
  }
63
139
 
140
+ function formatMessageLines(messages: Message[], currentMessageId: number): string {
141
+ return messages.map((message) => {
142
+ const marker = message.id === currentMessageId ? ' current' : '';
143
+ const parent = message.parent_id === null ? '' : ` parent=${message.parent_id}`;
144
+ const recipient = message.recipient ? ` to=@${sanitizeProviderIds(message.recipient)}` : '';
145
+ const sender = sanitizeProviderIds(message.sender);
146
+ const content = sanitizeProviderIds(message.content).replace(/\s+/g, ' ').trim().slice(0, 500);
147
+ const attachments = formatMessageAttachmentSummary(message);
148
+ return `- [${message.id}${marker}] @${sender}${recipient}${parent}: ${content}${attachments}`;
149
+ }).join('\n');
150
+ }
151
+
152
+ function formatMessageAttachmentSummary(message: Message): string {
153
+ if (!message.attachments || message.attachments.length === 0) return '';
154
+ const items = message.attachments.map((attachment) => (
155
+ `${attachment.kind} ${sanitizeProviderIds(attachment.filename || attachment.id)} (${attachment.mime_type}, ${attachment.size_bytes} bytes, path=${attachment.path})`
156
+ ));
157
+ return ` [attachments: ${items.join('; ')}]`;
158
+ }
159
+
160
+ function formatSavedRoomContext(savedMessages: RoomSavedMessage[] | undefined): string {
161
+ if (!savedMessages || savedMessages.length === 0) return '';
162
+ const lines = savedMessages.slice(0, 10).map((saved) => {
163
+ const id = sanitizeProviderIds(saved.id);
164
+ const sender = sanitizeProviderIds(saved.message_sender);
165
+ const savedBy = sanitizeProviderIds(saved.saved_by);
166
+ const note = saved.note?.trim()
167
+ ? ` note="${sanitizeProviderIds(saved.note).replace(/\s+/g, ' ').trim().slice(0, 240)}"`
168
+ : '';
169
+ const content = sanitizeProviderIds(saved.message_content).replace(/\s+/g, ' ').trim().slice(0, 500);
170
+ return `- [saved ${id} msg #${saved.message_id}] saved by @${savedBy}${note}; original @${sender}: ${content}`;
171
+ });
172
+ const omitted = savedMessages.length > 10 ? `\n- ... ${savedMessages.length - 10} older saved messages omitted` : '';
173
+ return `\nSaved room context:\nThese are room messages and notes humans or agents saved as durable collaboration context.\n${lines.join('\n')}${omitted}`;
174
+ }
175
+
176
+ function formatRuntimeAttachments(attachments: RuntimeAttachment[] | undefined): string {
177
+ if (!attachments || attachments.length === 0) return '';
178
+ const imageCount = attachments.filter((attachment) => attachment.kind === 'image').length;
179
+ const fileCount = attachments.length - imageCount;
180
+ const lines = attachments.map((attachment) => (
181
+ `- ${attachment.kind} #${sanitizeProviderIds(attachment.id)} from message ${attachment.messageId}: ${sanitizeProviderIds(attachment.filename)}, ${attachment.mimeType}, local path: ${attachment.path}`
182
+ ));
183
+ const summary = imageCount === attachments.length
184
+ ? `The triggering message includes ${imageCount} image attachment${imageCount === 1 ? '' : 's'}.`
185
+ : `The triggering message includes ${attachments.length} attachment${attachments.length === 1 ? '' : 's'}, including ${imageCount} image attachment${imageCount === 1 ? '' : 's'} and ${fileCount} file attachment${fileCount === 1 ? '' : 's'}.`;
186
+ return `\nMessage attachments:\n${summary}\nThese files are stored under the PAL room directory and are visible to agents participating in this room. Use the listed local paths if you need to inspect them.\n${lines.join('\n')}`;
187
+ }
188
+
189
+ export function formatDeliveredRoomContext(context: DeliveryContext | undefined, currentMessageId: number, chatId: string): string {
190
+ if (!context) return formatRecentMessageContext(undefined, currentMessageId);
191
+ const header = '\nDelivered room context:\nThis context contains the newest 50 messages since your previous delivery or room-join cursor.';
192
+ const omitted = context.omittedCount > 0
193
+ ? `\nThere are ${context.omittedCount} earlier unread/context messages not shown. Use \`pal messages list --chat-id ${chatId} --after ${context.startMessageId} --limit 50\` to inspect them.`
194
+ : '';
195
+ const lines = context.messages.length > 0
196
+ ? `\n${formatMessageLines(context.messages, currentMessageId)}`
197
+ : '\nNo delivered room context messages are available.';
198
+ return `${header}${omitted}${lines}`;
199
+ }
200
+
201
+ function formatAllMentionOnlyHandoff(content: string, contextLabel: string): string {
202
+ if (!/^\s*@(?:_all|all|所有人)\s*$/iu.test(content)) return '';
203
+ return `\nAll-agents handoff:
204
+ - The current message contains only an @all mention. Treat it as a broadcast trigger for the most recent prior message in ${contextLabel} from the same sender in this chat/topic.
205
+ - If that prior message contains a concrete request, act on it and send a useful reply; do not dismiss this as acknowledgment-only just because the trigger content is only @all.
206
+ - If no prior concrete request is visible, explain that you need the task details.`;
207
+ }
208
+
64
209
  export function runtimeCwd(input: AgentRuntimeRunInput): string {
65
- return input.agentHome ?? input.cwd;
210
+ return permissionRuntimeStateHome(input);
66
211
  }
67
212
 
68
213
  export function runtimeProjectCwd(input: AgentRuntimeRunInput): string {
69
214
  return input.projectCwd ?? input.cwd;
70
215
  }
71
216
 
217
+ export function runtimeLaunchRoot(input: AgentRuntimeRunInput): string {
218
+ return permissionRuntimeLaunchRoot(input);
219
+ }
220
+
72
221
  export function buildPalPrompt(input: AgentRuntimeRunInput): string {
73
222
  const palCli = input.palCliCommand ?? 'pal';
74
223
  const thread = input.message.parent_id === null ? '' : `\nTopic parent message id: ${input.message.parent_id}`;
75
224
  const recipient = input.message.recipient ? `\nRecipient: @${sanitizeProviderIds(input.message.recipient)}` : '';
76
225
  const agentHome = runtimeCwd(input);
77
226
  const projectCwd = runtimeProjectCwd(input);
227
+ const launchContext = input.launchContext ?? buildRuntimeLaunchContext(input);
78
228
  const chatName = sanitizeProviderIds(input.message.chat_name);
229
+ const chatRef = input.message.chat_id;
79
230
  const sender = sanitizeProviderIds(input.message.sender);
80
231
  const content = sanitizeProviderIds(input.message.content);
232
+ const freshnessFlags = `--base-message-id ${input.message.id} --hold-if-stale`;
81
233
  const projectContext = input.projectContext
82
234
  ? input.projectContext.accessible
83
235
  ? `\nProject context:\n- You are working in project: ${sanitizeProviderIds(input.projectContext.name)}\n- Project computer: ${sanitizeProviderIds(input.projectContext.computerName || input.projectContext.computerId)}\n- Project path: ${input.projectContext.rootPath}\n- This agent is running on the project computer and can use the project workspace path above.`
84
236
  : `\nProject context:\n- This room belongs to project: ${sanitizeProviderIds(input.projectContext.name)}\n- Project computer: ${sanitizeProviderIds(input.projectContext.computerName || input.projectContext.computerId)}\n- Project path: ${input.projectContext.rootPath}\n- This agent is currently running on computer: ${sanitizeProviderIds(input.projectContext.currentComputerId || 'unknown')}\n- The computers differ, so the project path is temporarily not accessible from this agent run. Do not claim to inspect or change that path unless access is restored.`
85
237
  : '';
86
238
 
239
+ const messageContext = input.deliveryContext
240
+ ? formatDeliveredRoomContext(input.deliveryContext, input.message.id, chatRef)
241
+ : formatRecentMessageContext(input.recentMessages, input.message.id);
242
+ const savedRoomContext = input.deliveryContext ? formatSavedRoomContext(input.deliveryContext.savedMessages) : '';
243
+ const runtimeAttachments = formatRuntimeAttachments(input.runtimeAttachments);
244
+ const messageContextLabel = input.deliveryContext ? 'Delivered room context' : 'Recent room history';
245
+
87
246
  return `You are ${input.agent}, a long-running PAL coding agent connected to the pal chat server.
88
247
 
89
248
  PAL is a multi-agent chat platform where humans and agents collaborate through rooms and topics.
@@ -91,14 +250,14 @@ PAL is a multi-agent chat platform where humans and agents collaborate through r
91
250
  Workspace contract:
92
251
  - Your current working directory is your persistent PAL agent home: ${agentHome}
93
252
  - Your cwd is for identity, memory, recovery state, scratch files, and durable local notes.
94
- - Your cwd is not the project repository.
253
+ - The runtime process starts from your persistent PAL agent home: ${launchContext.runtimeRoot}
95
254
  - The project workspace for source inspection, code changes, tests, and Git work is: ${projectCwd}
255
+ - Keep durable agent memory in your agent home, not in the project repository.
96
256
  ${projectContext}
97
257
 
98
258
  Startup recovery:
99
- - Read MEMORY.md in your cwd first when it exists.
259
+ - Read ${agentHome}/MEMORY.md first when it exists.
100
260
  - Follow its recovery order and read only the referenced state/kb files you need.
101
- - Keep durable agent memory in your agent home, not in the project repository.
102
261
 
103
262
  A new chat message arrived.
104
263
 
@@ -106,7 +265,12 @@ Message id: ${input.message.id}
106
265
  Chat: #${chatName}${thread}
107
266
  Sender: @${sender}${recipient}
108
267
  ${formatRoomParticipantContext(input.roomParticipants)}
109
- ${formatRecentMessageContext(input.recentMessages, input.message.id)}
268
+ ${formatRoomDeliveryPolicyContext({ agent: input.agent, subscriptions: input.roomAgentSubscriptions })}
269
+ ${formatRoomModeAndSkillContext({ roomMode: input.roomMode, enabledSkills: input.enabledSkills })}
270
+ ${messageContext}
271
+ ${savedRoomContext}
272
+ ${runtimeAttachments}
273
+ ${formatAllMentionOnlyHandoff(content, messageContextLabel)}
110
274
  Content:
111
275
  ${content}
112
276
 
@@ -118,13 +282,37 @@ Communication rules:
118
282
  - 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.
119
283
 
120
284
  Commands:
121
- - Send a reply to the same chat: ${palCli} send --chat ${chatName} --from ${input.agent} <message>
122
- - Send a message to another agent and trigger that agent's runtime: ${palCli} send --chat ${chatName} --from ${input.agent} --to <agent-key> <message>
123
- - Reply in this topic: ${palCli} send --chat ${chatName} --parent ${input.message.parent_id ?? input.message.id} --from ${input.agent} <message>
285
+ - Send a reply to the same chat: ${palCli} send --chat-id ${chatRef} ${freshnessFlags} --from ${input.agent} <message>
286
+ - Send a message to one or more agents and trigger their runtimes: ${palCli} send --chat-id ${chatRef} ${freshnessFlags} --from ${input.agent} --mention <mention_key> [--mention <mention_key> ...] <message>
287
+ Use the exact mention_key shown for agent participants above, for example \`--mention 逻辑尖子生\`.
288
+ - Reply in this topic: ${palCli} send --chat-id ${chatRef} --parent ${input.message.parent_id ?? input.message.id} ${freshnessFlags} --from ${input.agent} <message>
124
289
  - List rooms: ${palCli} rooms list
125
- - View room members: ${palCli} rooms members --room ${chatName}
126
- - Read recent chat messages: ${palCli} messages list --room ${chatName}
290
+ - View room members: ${palCli} rooms members --room ${chatRef}
291
+ - Read recent chat messages: ${palCli} messages list --chat-id ${chatRef}
127
292
  - Show one message: ${palCli} messages show <message-id>
293
+ - List room tasks: ${palCli} tasks list --chat-id ${chatRef}
294
+ - Create room tasks: ${palCli} tasks create --chat-id ${chatRef} --from ${input.agent} <title> [title...]
295
+ - Claim room tasks: ${palCli} tasks claim --chat-id ${chatRef} --tasks <task-number>[,<task-number>]
296
+ - Update a task: ${palCli} tasks status --chat-id ${chatRef} --task <task-number> --status in_review|done|closed
297
+ - Schedule a reminder from this message: ${palCli} reminders schedule --msg-id ${input.message.id} --delay-seconds <seconds> --title <title> [--repeat daily|weekly|every:2h]
298
+ - List scheduled reminders: ${palCli} reminders list --status scheduled
299
+ - Save this message for later: ${palCli} saved save --msg-id ${input.message.id} --note <note>
300
+ - List saved messages: ${palCli} saved list --chat-id ${chatRef}
301
+ - List held drafts: ${palCli} drafts list
302
+ - Revise a held draft after reading intervening messages: ${palCli} drafts revise <draft-id> <revised message>
303
+ - Stay silent on an obsolete held draft: ${palCli} drafts stay-silent <draft-id>
304
+ - Send an unchanged held draft anyway: ${palCli} drafts send-anyway <draft-id>
305
+
306
+ Markdown-safe sending:
307
+ - For Markdown or any message with special shell characters such as quotes, backticks, dollar signs, command substitutions, or multiple lines, pipe a single-quoted heredoc into \`${palCli} send --stdin\`.
308
+ - Use this pattern for ordinary replies: \`cat << 'PAL_MESSAGE' | ${palCli} send --chat-id ${chatRef} ${freshnessFlags} --from ${input.agent} --stdin\`, then the raw Markdown body, then \`PAL_MESSAGE\`.
309
+ - For topic replies, keep the same pattern and include the parent flag: \`cat << 'PAL_MESSAGE' | ${palCli} send --chat-id ${chatRef} --parent ${input.message.parent_id ?? input.message.id} ${freshnessFlags} --from ${input.agent} --stdin\`.
310
+ - This avoids shared temp files entirely; each send process reads its own stdin stream.
311
+
312
+ Freshness rule:
313
+ - Keep the freshness flags on visible replies. If \`${palCli} send\` reports a held draft, the room changed after the context you saw. Inspect the returned intervening messages before doing anything else.
314
+ - Use \`${palCli} drafts revise <draft-id> <revised message>\` if a reply is still useful, \`${palCli} drafts stay-silent <draft-id>\` if the draft is obsolete, and \`${palCli} drafts send-anyway <draft-id>\` only when the unchanged content is still correct.
315
+ - Do not leave held drafts unresolved after deciding.
128
316
 
129
317
  Finish naturally when the task is done. There is no fixed timeout for this run.`;
130
318
  }
@@ -136,6 +324,23 @@ export interface AgentRuntimeRunResult {
136
324
  runtimeSessionId?: string | null;
137
325
  }
138
326
 
327
+ export interface AgentRuntimeSteerInput {
328
+ agent: string;
329
+ serverUrl: string;
330
+ runId: string;
331
+ sessionId: string;
332
+ runtimeSessionId?: string | null;
333
+ message: Message;
334
+ cwd: string;
335
+ agentHome?: string;
336
+ projectCwd?: string;
337
+ localDaemonUrl?: string;
338
+ localDaemonToken?: string;
339
+ privateCliBinDir?: string;
340
+ palCliCommand?: string;
341
+ runtimeEnv?: NodeJS.ProcessEnv;
342
+ }
343
+
139
344
  export type AgentRuntimeProtocol = 'json-stream' | 'acp';
140
345
 
141
346
  export interface AgentRuntimeCapabilities {
@@ -143,6 +348,7 @@ export interface AgentRuntimeCapabilities {
143
348
  resume: 'runtime-session-id' | 'adapter-session-id' | 'none';
144
349
  busyDeliveryMode: 'queue' | 'direct' | 'notification' | 'none';
145
350
  supportsMcp: boolean;
351
+ supportsSteer: boolean;
146
352
  }
147
353
 
148
354
  export interface AgentRuntime {
@@ -156,24 +362,166 @@ export interface AgentRuntime {
156
362
  readonly command: string;
157
363
  /** Return the working directory for the agent process. Defaults to input.agentHome ?? input.cwd. */
158
364
  buildCwd?(input: AgentRuntimeRunInput): string;
365
+ /** Return additional environment variables for the agent process. */
366
+ buildEnv?(input: AgentRuntimeRunInput): Record<string, string>;
159
367
  /** Parse stdout/stderr from the agent process. Defaults to trimmed stdout + stderr. */
160
368
  parseOutput?(input: { stdout: string; stderr: string; input: AgentRuntimeRunInput }): { output: string; runtimeSessionId?: string | null };
369
+ /** Inject a message into an already running runtime session when supported. */
370
+ steerActiveRun?(input: AgentRuntimeSteerInput): Promise<void>;
371
+ }
372
+
373
+ export interface PrivateCliEnvOptions {
374
+ platform?: NodeJS.Platform;
375
+ pathDelimiter?: string;
376
+ }
377
+
378
+ export function buildPrivateCliEnv(
379
+ privateCliBinDir: string,
380
+ basePath = '',
381
+ options: PrivateCliEnvOptions = {},
382
+ ): { PAL_CLI: string; PATH: string } {
383
+ const platform = options.platform ?? process.platform;
384
+ const cliName = privatePalCliExecutableName(platform);
385
+ const palCli = platform === 'win32' ? pathWin32.join(privateCliBinDir, cliName) : join(privateCliBinDir, cliName);
386
+ return {
387
+ PAL_CLI: palCli,
388
+ PATH: `${privateCliBinDir}${options.pathDelimiter ?? delimiter}${basePath}`,
389
+ };
390
+ }
391
+
392
+ function runtimeDebugEnabled(): boolean {
393
+ return /^(1|true|yes|on)$/i.test(process.env.PAL_RUNTIME_DEBUG ?? '');
394
+ }
395
+
396
+ function runtimeDebugLogDir(): string {
397
+ return process.env.PAL_RUNTIME_LOG_DIR?.trim() || join(homeDir(), 'runtime-logs');
398
+ }
399
+
400
+ function safeFileSegment(value: string): string {
401
+ return value.replace(/[^a-zA-Z0-9._-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 80) || 'unknown';
402
+ }
403
+
404
+ function jsonSafe(value: unknown): unknown {
405
+ if (value instanceof Error) return { name: value.name, message: value.message, stack: value.stack };
406
+ return value;
407
+ }
408
+
409
+ class RuntimeDebugLogger {
410
+ readonly path: string;
411
+ private stream: WriteStream;
412
+ private closed = false;
413
+
414
+ private constructor(path: string, stream: WriteStream) {
415
+ this.path = path;
416
+ this.stream = stream;
417
+ }
418
+
419
+ static create(runtime: AgentRuntime, input: AgentRuntimeRunInput, prompt: string, args: string[], procCwd: string): RuntimeDebugLogger | null {
420
+ if (!runtimeDebugEnabled()) return null;
421
+ const dir = runtimeDebugLogDir();
422
+ mkdirSync(dir, { recursive: true });
423
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
424
+ const file = [
425
+ timestamp,
426
+ safeFileSegment(runtime.name),
427
+ safeFileSegment(input.agent),
428
+ `message-${input.message.id}`,
429
+ ].join('_');
430
+ const logger = new RuntimeDebugLogger(join(dir, `${file}.jsonl`), createWriteStream(join(dir, `${file}.jsonl`), { flags: 'a', mode: 0o600 }));
431
+ logger.write('runtime.start', {
432
+ runtime: runtime.name,
433
+ command: runtime.command,
434
+ args,
435
+ cwd: procCwd,
436
+ projectCwd: runtimeProjectCwd(input),
437
+ agent: input.agent,
438
+ chat: input.message.chat_name,
439
+ messageId: input.message.id,
440
+ runtimeSessionId: input.runtimeSessionId ?? null,
441
+ prompt,
442
+ });
443
+ console.log(`[${runtime.name}] debug log=${logger.path}`);
444
+ return logger;
445
+ }
446
+
447
+ write(event: string, data: Record<string, unknown> = {}): void {
448
+ if (this.closed) return;
449
+ const safeData = jsonSafe(data);
450
+ const record = safeData && typeof safeData === 'object' ? safeData as Record<string, unknown> : { value: safeData };
451
+ this.stream.write(`${JSON.stringify({ ts: new Date().toISOString(), event, ...record })}\n`);
452
+ }
453
+
454
+ close(): void {
455
+ if (this.closed) return;
456
+ this.closed = true;
457
+ this.stream.end();
458
+ }
161
459
  }
162
460
 
163
461
  function sleep(ms: number): Promise<void> {
164
462
  return new Promise((resolve) => setTimeout(resolve, ms));
165
463
  }
166
464
 
167
- function processGroupSignal(pid: number, signal: NodeJS.Signals): void {
465
+ export interface ProcessTreeSignalOptions {
466
+ platform?: NodeJS.Platform;
467
+ killPid?: (pid: number, signal?: NodeJS.Signals | number) => void;
468
+ taskkill?: (pid: number, force: boolean) => Promise<void>;
469
+ }
470
+
471
+ function runTaskkill(pid: number, force: boolean): Promise<void> {
472
+ return new Promise((resolve, reject) => {
473
+ const args = ['/pid', String(pid), '/t'];
474
+ if (force) args.push('/f');
475
+ const taskkill = spawn('taskkill.exe', args, { windowsHide: true });
476
+ let stderr = '';
477
+ taskkill.stderr.on('data', (chunk) => {
478
+ stderr += String(chunk);
479
+ });
480
+ taskkill.once('error', reject);
481
+ taskkill.once('exit', (code) => {
482
+ if (code === 0) {
483
+ resolve();
484
+ return;
485
+ }
486
+ reject(new Error(`taskkill exited ${code}${stderr.trim() ? `: ${stderr.trim()}` : ''}`));
487
+ });
488
+ });
489
+ }
490
+
491
+ export async function signalProcessTree(
492
+ pid: number,
493
+ signal: NodeJS.Signals,
494
+ options: ProcessTreeSignalOptions = {},
495
+ ): Promise<void> {
496
+ const platform = options.platform ?? process.platform;
497
+ const killPid = options.killPid ?? process.kill;
498
+ if (platform === 'win32') {
499
+ try {
500
+ await (options.taskkill ?? runTaskkill)(pid, signal === 'SIGKILL');
501
+ } catch {
502
+ try { killPid(pid, signal); } catch { /* already exited */ }
503
+ }
504
+ return;
505
+ }
168
506
  try {
169
- process.kill(-pid, signal);
507
+ killPid(-pid, signal);
170
508
  } catch {
171
- try { process.kill(pid, signal); } catch { /* already exited */ }
509
+ try { killPid(pid, signal); } catch { /* already exited */ }
172
510
  }
173
511
  }
174
512
 
513
+ function stopNodeProcess(proc: ChildProcessWithoutNullStreams): void {
514
+ if (!proc.pid) return;
515
+ void signalProcessTree(proc.pid, 'SIGTERM');
516
+ setTimeout(() => {
517
+ if (proc.exitCode === null && proc.signalCode === null && proc.pid) {
518
+ void signalProcessTree(proc.pid, 'SIGKILL');
519
+ }
520
+ }, 2000).unref();
521
+ }
522
+
175
523
  async function stopProcess(proc: ReturnType<typeof Bun.spawn>): Promise<number | null> {
176
- processGroupSignal(proc.pid, 'SIGTERM');
524
+ await signalProcessTree(proc.pid, 'SIGTERM');
177
525
  const graceful = await Promise.race([
178
526
  proc.exited.then((exitCode) => ({ exited: true as const, exitCode })),
179
527
  sleep(2000).then(() => ({ exited: false as const })),
@@ -181,14 +529,376 @@ async function stopProcess(proc: ReturnType<typeof Bun.spawn>): Promise<number |
181
529
 
182
530
  if (graceful.exited) return graceful.exitCode;
183
531
 
184
- processGroupSignal(proc.pid, 'SIGKILL');
185
- proc.kill('SIGKILL');
532
+ await signalProcessTree(proc.pid, 'SIGKILL');
533
+ try { proc.kill('SIGKILL'); } catch { /* already exited */ }
186
534
  return await Promise.race([
187
535
  proc.exited,
188
536
  sleep(500).then(() => null),
189
537
  ]);
190
538
  }
191
539
 
540
+ interface JsonRpcMessage {
541
+ jsonrpc?: string;
542
+ id?: number | string | null;
543
+ method?: string;
544
+ params?: Record<string, unknown>;
545
+ result?: unknown;
546
+ error?: unknown;
547
+ }
548
+
549
+ function textFromAcpContent(content: unknown): string {
550
+ if (!content || typeof content !== 'object') return '';
551
+ const record = content as Record<string, unknown>;
552
+ if (record.type === 'text' && typeof record.text === 'string') return record.text;
553
+ if (record.type === 'content') return textFromAcpContent(record.content);
554
+ return '';
555
+ }
556
+
557
+ function redactRuntimeLog(value: string): string {
558
+ return value
559
+ .replace(/(api[-_]?key|token|secret|authorization|password)(["'\s:=]+)([^\s"']+)/gi, '$1$2<redacted>')
560
+ .replace(/Bearer\s+[^\s"']+/gi, 'Bearer <redacted>')
561
+ .replace(/sk-[A-Za-z0-9_-]+/g, 'sk-<redacted>');
562
+ }
563
+
564
+ function tailLines(lines: string[], count = 8): string[] {
565
+ return lines.slice(Math.max(0, lines.length - count)).map((line) => redactRuntimeLog(line));
566
+ }
567
+
568
+ function truncateActivityText(value: string, max = 1200): string {
569
+ const compact = redactRuntimeLog(value).replace(/\s+/g, ' ').trim();
570
+ return compact.length > max ? `${compact.slice(0, max - 1)}...` : compact;
571
+ }
572
+
573
+ function stringField(record: Record<string, unknown>, key: string): string {
574
+ const value = record[key];
575
+ return typeof value === 'string' ? value : '';
576
+ }
577
+
578
+ function activityFromRuntimeJson(line: string): AgentRuntimeActivityEvent | null {
579
+ let event: Record<string, unknown>;
580
+ try {
581
+ const parsed = JSON.parse(line) as unknown;
582
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
583
+ event = parsed as Record<string, unknown>;
584
+ } catch {
585
+ return null;
586
+ }
587
+
588
+ const type = stringField(event, 'type') || stringField(event, 'method') || 'runtime.event';
589
+ const item = event.item && typeof event.item === 'object' && !Array.isArray(event.item) ? event.item as Record<string, unknown> : {};
590
+ const itemType = stringField(item, 'type');
591
+ const text = stringField(event, 'text') || stringField(item, 'text') || stringField(event, 'message');
592
+ const command = stringField(event, 'command') || stringField(item, 'command');
593
+ const detail = truncateActivityText(text || command || line);
594
+ const metadata = { runtimeEventType: type, itemType: itemType || undefined };
595
+
596
+ if (type === 'thread.started') return { kind: 'lifecycle', title: 'Runtime thread started', detail: stringField(event, 'thread_id'), metadata };
597
+ if (type.includes('error') || stringField(event, 'level') === 'error') return { kind: 'error', title: 'Runtime error', detail, metadata };
598
+ if (itemType.includes('command') || itemType.includes('tool') || type.includes('exec') || type.includes('tool')) return { kind: 'tool', title: command ? 'Tool call' : 'Using tool', detail, metadata };
599
+ if (itemType === 'agent_message' || type.includes('message') || text) return { kind: 'output', title: 'Output', detail, metadata };
600
+ if (type.includes('completed')) return { kind: 'lifecycle', title: 'Step completed', detail, metadata };
601
+ if (type.includes('started')) return { kind: 'working', title: 'Working', detail, metadata };
602
+ return { kind: 'thinking', title: 'Thinking', detail, metadata };
603
+ }
604
+
605
+ async function emitActivity(input: AgentRuntimeRunInput, event: AgentRuntimeActivityEvent): Promise<void> {
606
+ try {
607
+ await input.onActivity?.(event);
608
+ } catch (error) {
609
+ console.warn(`[runtime] activity write failed: ${error instanceof Error ? error.message : String(error)}`);
610
+ }
611
+ }
612
+
613
+ function commandPath(command: string, env: Record<string, string | undefined>): string | null {
614
+ if (command.includes('/')) return existsSync(command) ? command : null;
615
+ for (const dir of (env.PATH ?? '').split(delimiter)) {
616
+ if (!dir) continue;
617
+ const candidate = join(dir, command);
618
+ if (existsSync(candidate)) return candidate;
619
+ }
620
+ return null;
621
+ }
622
+
623
+ function runtimeEnvSummary(command: string, env: Record<string, string | undefined>): Record<string, unknown> {
624
+ const krb5 = env.KRB5CCNAME;
625
+ const krb5Path = krb5?.replace(/^FILE:/, '');
626
+ const pathParts = (env.PATH ?? '').split(delimiter).filter(Boolean);
627
+ return {
628
+ commandPath: commandPath(command, env) ?? '<not found in PATH>',
629
+ home: env.HOME ? 'set' : 'missing',
630
+ user: env.USER ? 'set' : 'missing',
631
+ krb5ccname: krb5 ? `${krb5}${krb5Path && existsSync(krb5Path) ? ' exists' : ' missing-file'}` : 'missing',
632
+ lang: env.LANG ?? null,
633
+ lcAll: env.LC_ALL ?? null,
634
+ term: env.TERM ?? null,
635
+ httpsProxy: env.HTTPS_PROXY || env.https_proxy ? 'set' : 'missing',
636
+ httpProxy: env.HTTP_PROXY || env.http_proxy ? 'set' : 'missing',
637
+ noProxy: env.NO_PROXY || env.no_proxy ? 'set' : 'missing',
638
+ palCli: env.PAL_CLI ? 'set' : 'missing',
639
+ pathEntries: pathParts.length,
640
+ pathHead: pathParts.slice(0, 5),
641
+ };
642
+ }
643
+
644
+ async function runAcpAgentRuntime(
645
+ runtime: AgentRuntime,
646
+ input: AgentRuntimeRunInput,
647
+ prompt: string,
648
+ args: string[],
649
+ procCwd: string,
650
+ env: Record<string, string | undefined>,
651
+ debugLog: RuntimeDebugLogger | null,
652
+ ): Promise<AgentRuntimeRunResult> {
653
+ const envSummary = runtimeEnvSummary(runtime.command, env);
654
+ console.log(`[${runtime.name}] acp env ${JSON.stringify(envSummary)}`);
655
+ await emitActivity(input, { kind: 'working', title: 'Starting ACP runtime', detail: `${runtime.command} ${args[0] ?? ''}`.trim() });
656
+ debugLog?.write('process.env_summary', envSummary);
657
+ const proc = spawn(runtime.command, args, {
658
+ cwd: procCwd,
659
+ detached: true,
660
+ env: env as NodeJS.ProcessEnv,
661
+ stdio: ['pipe', 'pipe', 'pipe'],
662
+ });
663
+ console.log(`[${runtime.name}] spawned pid=${proc.pid ?? '-'}`);
664
+ debugLog?.write('process.spawned', { pid: proc.pid ?? null });
665
+ await input.onStart?.(proc.pid ?? null);
666
+ await emitActivity(input, { kind: 'working', title: 'Runtime process started', detail: proc.pid ? `pid ${proc.pid}` : 'pid unavailable' });
667
+
668
+ let nextId = 1;
669
+ const pending = new Map<number, {
670
+ method: string;
671
+ resolve: (value: unknown) => void;
672
+ reject: (error: Error) => void;
673
+ }>();
674
+ const outputChunks: string[] = [];
675
+ const stderrChunks: string[] = [];
676
+ let sessionId = input.runtimeSessionId ?? null;
677
+ let stoppedByAction: RunAction | null = null;
678
+ let completed = false;
679
+ let childExit: { code: number | null; signal: NodeJS.Signals | null } | null = null;
680
+ let childError: string | null = null;
681
+ const rejectPendingRequests = (reason: string): void => {
682
+ if (pending.size === 0) return;
683
+ const diagnostics = {
684
+ reason,
685
+ pending: Array.from(pending.values()).map((request) => request.method),
686
+ childExit,
687
+ childError,
688
+ stdinDestroyed: proc.stdin.destroyed,
689
+ stdoutTail: tailLines(outputChunks),
690
+ stderrTail: tailLines(stderrChunks),
691
+ };
692
+ console.warn(`[${runtime.name}] acp pending requests failed ${JSON.stringify(diagnostics)}`);
693
+ debugLog?.write('acp.pending_failed', diagnostics);
694
+ for (const [id, request] of Array.from(pending.entries())) {
695
+ pending.delete(id);
696
+ request.reject(new Error(`${reason} before ${request.method} response; childExit=${JSON.stringify(childExit)} stderrTail=${JSON.stringify(diagnostics.stderrTail)}`));
697
+ }
698
+ };
699
+ proc.once('exit', (code, signal) => {
700
+ childExit = { code, signal };
701
+ console.log(`[${runtime.name}] process exit observed code=${code ?? '-'} signal=${signal ?? '-'}`);
702
+ debugLog?.write('process.exit_observed', childExit);
703
+ rejectPendingRequests('ACP child process exited');
704
+ });
705
+ proc.once('error', (error) => {
706
+ childError = error.message;
707
+ console.warn(`[${runtime.name}] process error ${redactRuntimeLog(error.message)}`);
708
+ debugLog?.write('process.error', { message: redactRuntimeLog(error.message) });
709
+ rejectPendingRequests('ACP child process error');
710
+ });
711
+
712
+ const send = (method: string, params: Record<string, unknown>): Promise<unknown> => {
713
+ const id = nextId;
714
+ nextId += 1;
715
+ const request = { jsonrpc: '2.0', id, method, params };
716
+ const startedAt = Date.now();
717
+ console.log(`[${runtime.name}] acp request id=${id} method=${method}`);
718
+ debugLog?.write('acp.send', request);
719
+ proc.stdin.write(`${JSON.stringify(request)}\n`);
720
+ return new Promise((resolve, reject) => {
721
+ pending.set(id, {
722
+ method,
723
+ resolve: (value) => {
724
+ const elapsedMs = Date.now() - startedAt;
725
+ console.log(`[${runtime.name}] acp response id=${id} method=${method} elapsedMs=${elapsedMs}`);
726
+ resolve(value);
727
+ },
728
+ reject: (error) => {
729
+ const elapsedMs = Date.now() - startedAt;
730
+ console.warn(`[${runtime.name}] acp error id=${id} method=${method} elapsedMs=${elapsedMs} error=${redactRuntimeLog(error.message)}`);
731
+ reject(error);
732
+ },
733
+ });
734
+ });
735
+ };
736
+
737
+ const reply = (id: number | string, result: unknown): void => {
738
+ const response = { jsonrpc: '2.0', id, result };
739
+ debugLog?.write('acp.client_response', response);
740
+ proc.stdin.write(`${JSON.stringify(response)}\n`);
741
+ };
742
+
743
+ const rejectClientRequest = (id: number | string, method: string): void => {
744
+ const response = { jsonrpc: '2.0', id, error: { code: -32601, message: `Unsupported ACP client method: ${method}` } };
745
+ debugLog?.write('acp.client_response', response);
746
+ proc.stdin.write(`${JSON.stringify(response)}\n`);
747
+ };
748
+
749
+ const handleMessage = (message: JsonRpcMessage): void => {
750
+ if (message.id !== undefined && message.id !== null && !message.method) {
751
+ const pendingRequest = pending.get(Number(message.id));
752
+ if (!pendingRequest) {
753
+ console.warn(`[${runtime.name}] acp response id=${String(message.id)} has no pending request`);
754
+ return;
755
+ }
756
+ pending.delete(Number(message.id));
757
+ if (message.error) {
758
+ pendingRequest.reject(new Error(`${pendingRequest.method} failed: ${JSON.stringify(message.error)}`));
759
+ } else {
760
+ pendingRequest.resolve(message.result);
761
+ }
762
+ return;
763
+ }
764
+
765
+ if (message.method === 'session/update') {
766
+ const update = (message.params?.update ?? {}) as Record<string, unknown>;
767
+ if (typeof message.params?.sessionId === 'string') sessionId = message.params.sessionId;
768
+ if (update.sessionUpdate === 'agent_message_chunk') {
769
+ const text = textFromAcpContent(update.content);
770
+ if (text) {
771
+ outputChunks.push(text);
772
+ void emitActivity(input, { kind: 'output', title: 'Output', detail: truncateActivityText(text), metadata: { protocol: 'acp', sessionId } });
773
+ }
774
+ } else if (typeof update.sessionUpdate === 'string') {
775
+ void emitActivity(input, { kind: 'thinking', title: 'Thinking', detail: update.sessionUpdate, metadata: { protocol: 'acp', sessionId } });
776
+ }
777
+ return;
778
+ }
779
+
780
+ if (message.id !== undefined && message.id !== null && message.method) {
781
+ if (message.method === 'session/request_permission') {
782
+ reply(message.id, {
783
+ outcome: {
784
+ outcome: 'selected',
785
+ optionId: selectAcpPermissionOption({
786
+ params: message.params,
787
+ profile: effectivePermissionProfile(input),
788
+ context: input.launchContext ?? buildRuntimeLaunchContext(input),
789
+ }),
790
+ },
791
+ });
792
+ } else {
793
+ rejectClientRequest(message.id, message.method);
794
+ }
795
+ }
796
+ };
797
+
798
+ const readLines = async (stream: NodeJS.ReadableStream, onLine: (line: string) => void): Promise<void> => {
799
+ let buffer = '';
800
+ for await (const chunk of stream) {
801
+ buffer += String(chunk);
802
+ let newline = buffer.indexOf('\n');
803
+ while (newline >= 0) {
804
+ const line = buffer.slice(0, newline).trim();
805
+ buffer = buffer.slice(newline + 1);
806
+ if (line) onLine(line);
807
+ newline = buffer.indexOf('\n');
808
+ }
809
+ }
810
+ const rest = buffer.trim();
811
+ if (rest) onLine(rest);
812
+ };
813
+
814
+ const stdoutTask = readLines(proc.stdout, (line) => {
815
+ debugLog?.write('process.stdout', { line });
816
+ try {
817
+ const message = JSON.parse(line) as JsonRpcMessage;
818
+ debugLog?.write('acp.receive', { message });
819
+ handleMessage(message);
820
+ } catch {
821
+ const redacted = redactRuntimeLog(line);
822
+ console.warn(`[${runtime.name}] acp non-json stdout: ${redacted}`);
823
+ outputChunks.push(line);
824
+ }
825
+ });
826
+ const stderrTask = readLines(proc.stderr, (line) => {
827
+ debugLog?.write('process.stderr', { line });
828
+ stderrChunks.push(line);
829
+ console.warn(`[${runtime.name}] stderr: ${redactRuntimeLog(line)}`);
830
+ });
831
+
832
+ const actionTimer = setInterval(() => {
833
+ void input.getAction?.().then((action) => {
834
+ if (!action || completed || stoppedByAction) return;
835
+ stoppedByAction = action;
836
+ if (sessionId) {
837
+ proc.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', method: 'session/cancel', params: { sessionId } })}\n`);
838
+ }
839
+ stopNodeProcess(proc);
840
+ }).catch(() => {});
841
+ }, 1000);
842
+
843
+ try {
844
+ console.log(`[${runtime.name}] acp initialize start`);
845
+ await emitActivity(input, { kind: 'working', title: 'Initializing runtime', detail: 'ACP initialize' });
846
+ const initialized = await send('initialize', {
847
+ protocolVersion: 1,
848
+ clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false },
849
+ clientInfo: { name: 'pal', version: '0.1.0' },
850
+ }) as Record<string, unknown>;
851
+ const capabilities = (initialized.agentCapabilities ?? {}) as Record<string, unknown>;
852
+ const sessionCapabilities = (capabilities.sessionCapabilities ?? {}) as Record<string, unknown>;
853
+ console.log(`[${runtime.name}] acp initialize ok capabilities=${JSON.stringify({ resume: Boolean(sessionCapabilities.resume), loadSession: Boolean(capabilities.loadSession) })}`);
854
+ const sessionParams = { cwd: procCwd, mcpServers: [] };
855
+ let session: Record<string, unknown>;
856
+ if (input.runtimeSessionId && sessionCapabilities.resume) {
857
+ console.log(`[${runtime.name}] acp session/resume start sessionId=${input.runtimeSessionId}`);
858
+ session = await send('session/resume', { sessionId: input.runtimeSessionId, ...sessionParams }) as Record<string, unknown>;
859
+ sessionId = input.runtimeSessionId;
860
+ } else if (input.runtimeSessionId && capabilities.loadSession) {
861
+ console.log(`[${runtime.name}] acp session/load start sessionId=${input.runtimeSessionId}`);
862
+ session = await send('session/load', { sessionId: input.runtimeSessionId, ...sessionParams }) as Record<string, unknown>;
863
+ sessionId = input.runtimeSessionId;
864
+ } else {
865
+ console.log(`[${runtime.name}] acp session/new start cwd=${procCwd}`);
866
+ session = await send('session/new', sessionParams) as Record<string, unknown>;
867
+ if (typeof session.sessionId === 'string') sessionId = session.sessionId;
868
+ }
869
+ if (!sessionId) throw new Error('ACP session did not provide a sessionId');
870
+ console.log(`[${runtime.name}] acp session ready sessionId=${sessionId}`);
871
+ await emitActivity(input, { kind: 'lifecycle', title: 'Runtime session ready', detail: sessionId, metadata: { protocol: 'acp' } });
872
+
873
+ console.log(`[${runtime.name}] acp session/prompt start promptChars=${prompt.length}`);
874
+ await emitActivity(input, { kind: 'working', title: 'Prompt sent', detail: `${prompt.length} chars` });
875
+ await send('session/prompt', {
876
+ sessionId,
877
+ prompt: [{ type: 'text', text: prompt }],
878
+ });
879
+ completed = true;
880
+ console.log(`[${runtime.name}] acp session/prompt completed outputChunks=${outputChunks.length} stderrLines=${stderrChunks.length}`);
881
+ await emitActivity(input, { kind: 'lifecycle', title: 'Runtime completed', detail: `${outputChunks.length} output chunks` });
882
+ } finally {
883
+ clearInterval(actionTimer);
884
+ proc.stdin.end();
885
+ stopNodeProcess(proc);
886
+ }
887
+
888
+ const exitCode = await Promise.race([
889
+ new Promise<number | null>((resolve) => proc.once('exit', (code) => resolve(code))),
890
+ sleep(3000).then(() => null),
891
+ ]);
892
+ debugLog?.write('process.exit', { exitCode, stoppedByAction });
893
+ await Promise.allSettled([stdoutTask, stderrTask]);
894
+ const output = [outputChunks.join(''), stderrChunks.join('\n')].filter(Boolean).join('\n');
895
+ console.log(`[${runtime.name}] output length=${output.length}`);
896
+ const result = { output, runtimeSessionId: sessionId, exitCode: stoppedByAction ? exitCode : 0, stoppedByAction };
897
+ debugLog?.write('runtime.result', result);
898
+ debugLog?.close();
899
+ return result;
900
+ }
901
+
192
902
  async function waitForExitOrAction(
193
903
  proc: ReturnType<typeof Bun.spawn>,
194
904
  input: AgentRuntimeRunInput,
@@ -233,33 +943,47 @@ export async function runAgentRuntime(
233
943
  const prompt = runtime.buildPrompt(input);
234
944
  const args = runtime.buildArgs(input);
235
945
  const procCwd = runtime.buildCwd?.(input) ?? runtimeCwd(input);
946
+ const debugLog = RuntimeDebugLogger.create(runtime, input, prompt, args, procCwd);
236
947
  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(', ')}]`);
948
+ await emitActivity(input, { kind: 'working', title: 'Run accepted', detail: `${runtime.name} runtime starting in ${procCwd}` });
237
949
 
238
950
  if (input.dryRun) {
239
951
  console.log(`[${runtime.name}] dry-run mode, skipping spawn`);
952
+ await emitActivity(input, { kind: 'lifecycle', title: 'Dry run', detail: 'Runtime spawn skipped' });
240
953
  const [subcommand, ...restArgs] = args;
241
954
  const renderedArgs = [
242
955
  subcommand,
243
956
  ...restArgs.map((arg) => JSON.stringify(arg)),
244
957
  ].filter(Boolean).join(' ');
245
958
  await input.onStart?.(null);
246
- return {
959
+ const result = {
247
960
  output: `[dry-run] ${runtime.command}${renderedArgs ? ` ${renderedArgs}` : ''}`,
248
961
  exitCode: 0,
249
962
  stoppedByAction: null,
250
963
  runtimeSessionId: input.runtimeSessionId ?? null,
251
964
  };
965
+ debugLog?.write('runtime.result', result);
966
+ debugLog?.close();
967
+ return result;
252
968
  }
253
969
 
970
+ const baseEnv = input.runtimeEnv ?? process.env;
254
971
  const env = {
255
- ...process.env,
972
+ ...baseEnv,
256
973
  PAL_SERVER: input.serverUrl,
257
974
  PAL_AGENT: input.agent,
258
975
  ...(input.localDaemonUrl ? { LOCK_DAEMON_URL: input.localDaemonUrl } : {}),
259
976
  ...(input.localDaemonToken ? { LOCK_DAEMON_TOKEN: input.localDaemonToken } : {}),
260
- ...(input.privateCliBinDir ? { PAL_CLI: join(input.privateCliBinDir, 'pal'), PATH: `${input.privateCliBinDir}:${process.env.PATH ?? ''}` } : {}),
977
+ ...(input.privateCliBinDir ? buildPrivateCliEnv(input.privateCliBinDir, baseEnv.PATH ?? '') : {}),
978
+ ...(runtime.buildEnv?.(input) ?? {}),
261
979
  };
262
980
  console.log(`[${runtime.name}] spawning: ${runtime.command} ${args.map((a) => JSON.stringify(a)).join(' ')}`);
981
+ if (runtime.capabilities.protocol === 'acp') {
982
+ return runAcpAgentRuntime(runtime, input, prompt, args, procCwd, env, debugLog);
983
+ }
984
+ const envSummary = runtimeEnvSummary(runtime.command, env);
985
+ console.log(`[${runtime.name}] env ${JSON.stringify(envSummary)}`);
986
+ debugLog?.write('process.env_summary', envSummary);
263
987
  const proc = Bun.spawn([runtime.command, ...args], {
264
988
  cwd: procCwd,
265
989
  stdout: 'pipe',
@@ -269,14 +993,49 @@ export async function runAgentRuntime(
269
993
  });
270
994
 
271
995
  console.log(`[${runtime.name}] spawned pid=${proc.pid}`);
996
+ debugLog?.write('process.spawned', { pid: proc.pid });
272
997
  await input.onStart?.(proc.pid);
998
+ await emitActivity(input, { kind: 'working', title: 'Runtime process started', detail: `pid ${proc.pid}` });
999
+
1000
+ const stdoutLines: string[] = [];
1001
+ const stderrLines: string[] = [];
1002
+ const collectLines = async (stream: ReadableStream<Uint8Array>, onLine: (line: string) => void): Promise<string> => {
1003
+ const decoder = new TextDecoder();
1004
+ let buffer = '';
1005
+ let text = '';
1006
+ for await (const chunk of stream) {
1007
+ const part = decoder.decode(chunk, { stream: true });
1008
+ text += part;
1009
+ buffer += part;
1010
+ let newline = buffer.indexOf('\n');
1011
+ while (newline >= 0) {
1012
+ const line = buffer.slice(0, newline).trim();
1013
+ buffer = buffer.slice(newline + 1);
1014
+ if (line) onLine(line);
1015
+ newline = buffer.indexOf('\n');
1016
+ }
1017
+ }
1018
+ const rest = `${buffer}${decoder.decode()}`.trim();
1019
+ if (rest) onLine(rest);
1020
+ return text;
1021
+ };
1022
+ const stdoutTask = collectLines(proc.stdout, (line) => {
1023
+ stdoutLines.push(line);
1024
+ const activity = activityFromRuntimeJson(line);
1025
+ if (activity) void emitActivity(input, activity);
1026
+ });
1027
+ const stderrTask = collectLines(proc.stderr, (line) => {
1028
+ stderrLines.push(line);
1029
+ void emitActivity(input, { kind: 'error', title: 'Runtime stderr', detail: truncateActivityText(line) });
1030
+ });
1031
+
273
1032
  const status = await waitForExitOrAction(proc, input);
274
1033
  console.log(`[${runtime.name}] process exited exitCode=${status.exitCode} stoppedByAction=${status.stoppedByAction ?? '-'}`);
1034
+ debugLog?.write('process.exit', status);
275
1035
 
276
- const [stdout, stderr] = await Promise.all([
277
- new Response(proc.stdout).text(),
278
- new Response(proc.stderr).text(),
279
- ]);
1036
+ const [stdout, stderr] = await Promise.all([stdoutTask, stderrTask]);
1037
+ debugLog?.write('process.stdout', { text: stdout });
1038
+ debugLog?.write('process.stderr', { text: stderr });
280
1039
 
281
1040
  const parsed = runtime.parseOutput?.({ stdout, stderr, input }) ?? {
282
1041
  output: [stdout.trim(), stderr.trim()].filter(Boolean).join('\n'),
@@ -284,7 +1043,15 @@ export async function runAgentRuntime(
284
1043
  };
285
1044
  const output = parsed.output;
286
1045
  console.log(`[${runtime.name}] output length=${output.length}`);
287
- return { output, runtimeSessionId: parsed.runtimeSessionId ?? input.runtimeSessionId ?? null, ...status };
1046
+ const result = { output, runtimeSessionId: parsed.runtimeSessionId ?? input.runtimeSessionId ?? null, ...status };
1047
+ await emitActivity(input, {
1048
+ kind: status.stoppedByAction ? 'lifecycle' : status.exitCode === 0 ? 'lifecycle' : 'error',
1049
+ title: status.stoppedByAction ? `Run ${status.stoppedByAction}` : status.exitCode === 0 ? 'Run completed' : 'Run failed',
1050
+ detail: `${stdoutLines.length} stdout events · ${stderrLines.length} stderr lines`,
1051
+ });
1052
+ debugLog?.write('runtime.result', result);
1053
+ debugLog?.close();
1054
+ return result;
288
1055
  }
289
1056
 
290
1057
  export type RuntimeDriver = AgentRuntime;