@borgee/agents-host 0.1.8 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -26,6 +26,7 @@ Borgee channel message
26
26
  - local-config multi-agent startup
27
27
  - one isolated `AgentsHost` per effective agent key
28
28
  - per-channel conversation memory via each provider's native session mechanism
29
+ - one draft progress message per turn when a provider emits public progress
29
30
  - hot reload for host config and agent file add / update / remove
30
31
 
31
32
  **Out of scope:**
@@ -23,8 +23,12 @@ export declare class AgentsHost {
23
23
  private readonly config;
24
24
  private readonly provider;
25
25
  private readonly borgee;
26
+ private readonly activeTurns;
27
+ private readonly progressChannelQueues;
28
+ private readonly progressDrafts;
26
29
  private selfAgentId;
27
30
  private started;
31
+ private controlPlaneClosed;
28
32
  constructor(config: AgentsHostConfig, deps?: {
29
33
  borgee?: ChatControlPlane;
30
34
  provider?: ProviderAdapter;
@@ -32,4 +36,7 @@ export declare class AgentsHost {
32
36
  start(): Promise<void>;
33
37
  stop(): Promise<void>;
34
38
  private handleMessage;
39
+ private trackActiveTurn;
40
+ private enqueueProgressChannelTurn;
41
+ private ensureProgressDraft;
35
42
  }
@@ -1,5 +1,107 @@
1
1
  import { SdkChatControlPlane } from './chat/sdk-chat-control-plane.js';
2
2
  import { createProvider } from './providers/create-provider.js';
3
+ const STREAM_PROGRESS_EDIT_THROTTLE_MS = 150;
4
+ function hasVisibleText(value) {
5
+ return value.trim().length > 0;
6
+ }
7
+ function providerUsesDraftProgress(provider) {
8
+ return provider === 'claude' || provider === 'copilot';
9
+ }
10
+ class DraftMessageController {
11
+ borgee;
12
+ channelId;
13
+ canWrite;
14
+ messageId = null;
15
+ currentBody = '';
16
+ pendingBody = null;
17
+ timer = null;
18
+ sealed = false;
19
+ writeChain = Promise.resolve();
20
+ constructor(borgee, channelId, canWrite) {
21
+ this.borgee = borgee;
22
+ this.channelId = channelId;
23
+ this.canWrite = canWrite;
24
+ }
25
+ update(text) {
26
+ if (this.sealed || !hasVisibleText(text)) {
27
+ return;
28
+ }
29
+ this.pendingBody = text;
30
+ if (this.timer) {
31
+ return;
32
+ }
33
+ this.timer = setTimeout(() => {
34
+ this.timer = null;
35
+ void this.flushPending().catch((error) => {
36
+ console.error('[agents-host] failed to flush draft progress:', error);
37
+ });
38
+ }, STREAM_PROGRESS_EDIT_THROTTLE_MS);
39
+ }
40
+ cancelProgress() {
41
+ if (this.timer) {
42
+ clearTimeout(this.timer);
43
+ this.timer = null;
44
+ }
45
+ this.sealed = true;
46
+ }
47
+ async finalize(finalText) {
48
+ this.sealed = true;
49
+ if (this.timer) {
50
+ clearTimeout(this.timer);
51
+ this.timer = null;
52
+ }
53
+ if (hasVisibleText(finalText)) {
54
+ this.pendingBody = finalText;
55
+ await this.flushPending();
56
+ return;
57
+ }
58
+ await this.enqueueWrite(async () => {
59
+ if (!this.canWrite() || !this.messageId) {
60
+ return;
61
+ }
62
+ await this.borgee.deleteMessage(this.messageId);
63
+ this.messageId = null;
64
+ this.currentBody = '';
65
+ this.pendingBody = null;
66
+ });
67
+ }
68
+ async discard() {
69
+ this.sealed = true;
70
+ if (this.timer) {
71
+ clearTimeout(this.timer);
72
+ this.timer = null;
73
+ }
74
+ this.pendingBody = null;
75
+ await this.enqueueWrite(async () => {
76
+ if (!this.canWrite() || !this.messageId) {
77
+ return;
78
+ }
79
+ await this.borgee.deleteMessage(this.messageId);
80
+ this.messageId = null;
81
+ this.currentBody = '';
82
+ });
83
+ }
84
+ async flushPending() {
85
+ await this.enqueueWrite(async () => {
86
+ const body = this.pendingBody;
87
+ if (!this.canWrite() || !body || body === this.currentBody) {
88
+ return;
89
+ }
90
+ if (this.messageId) {
91
+ await this.borgee.editMessage(this.messageId, body);
92
+ }
93
+ else {
94
+ const posted = await this.borgee.postMessage(this.channelId, body);
95
+ this.messageId = posted.messageId;
96
+ }
97
+ this.currentBody = body;
98
+ });
99
+ }
100
+ enqueueWrite(operation) {
101
+ this.writeChain = this.writeChain.then(operation, operation);
102
+ return this.writeChain;
103
+ }
104
+ }
3
105
  /**
4
106
  * Minimal single-agent agents host: connects one local Claude/Copilot CLI
5
107
  * to exactly one Borgee agent over `@borgee/plugin-sdk` (BPP / `/ws/plugin`).
@@ -22,8 +124,12 @@ export class AgentsHost {
22
124
  config;
23
125
  provider;
24
126
  borgee;
127
+ activeTurns = new Set();
128
+ progressChannelQueues = new Map();
129
+ progressDrafts = new Map();
25
130
  selfAgentId = null;
26
131
  started = false;
132
+ controlPlaneClosed = false;
27
133
  constructor(config, deps) {
28
134
  this.config = config;
29
135
  this.provider = deps?.provider ?? createProvider({
@@ -44,8 +150,15 @@ export class AgentsHost {
44
150
  if (this.started)
45
151
  return;
46
152
  this.started = true;
153
+ this.controlPlaneClosed = false;
47
154
  await this.borgee.connect((message) => {
48
- void this.handleMessage(message);
155
+ if (!this.started) {
156
+ return;
157
+ }
158
+ const task = providerUsesDraftProgress(this.config.agent.provider)
159
+ ? this.enqueueProgressChannelTurn(message.channel_id, () => this.handleMessage(message))
160
+ : this.handleMessage(message);
161
+ this.trackActiveTurn(task);
49
162
  });
50
163
  const me = await this.borgee.getMe();
51
164
  this.selfAgentId = me.id;
@@ -59,8 +172,13 @@ export class AgentsHost {
59
172
  if (!this.started)
60
173
  return;
61
174
  this.started = false;
175
+ for (const draft of this.progressDrafts.values()) {
176
+ draft.cancelProgress();
177
+ }
178
+ this.controlPlaneClosed = true;
62
179
  await this.borgee.close();
63
180
  await this.provider.dispose?.();
181
+ await Promise.allSettled([...this.activeTurns]);
64
182
  }
65
183
  async handleMessage(msg) {
66
184
  console.log('[agents-host] received message', {
@@ -80,6 +198,8 @@ export class AgentsHost {
80
198
  // messages an agent receives (per-agent + per-channel require_mention is
81
199
  // enforced at BPP fan-out via AgentReceivesChannelMessage). If a channel
82
200
  // message reaches this host at all, the agent is meant to handle it.
201
+ const stopTyping = this.borgee.startTyping(msg.channel_id);
202
+ const useDraftProgress = providerUsesDraftProgress(this.config.agent.provider);
83
203
  try {
84
204
  const reply = await this.provider.generateReply({
85
205
  agentName: this.config.agent.agentName,
@@ -87,15 +207,73 @@ export class AgentsHost {
87
207
  channelId: msg.channel_id,
88
208
  incomingAuthorId: authorId,
89
209
  incomingContent: content,
90
- });
91
- await this.borgee.postMessage(msg.channel_id, reply.text);
210
+ }, useDraftProgress
211
+ ? {
212
+ onProgress: (update) => {
213
+ if (!this.started || this.controlPlaneClosed) {
214
+ return;
215
+ }
216
+ this.ensureProgressDraft(msg.channel_id).update(update.text);
217
+ },
218
+ }
219
+ : undefined);
220
+ if (useDraftProgress) {
221
+ const draft = this.progressDrafts.get(msg.channel_id);
222
+ if (!this.started) {
223
+ await draft?.discard().catch(() => { });
224
+ this.progressDrafts.delete(msg.channel_id);
225
+ return;
226
+ }
227
+ await (draft ?? this.ensureProgressDraft(msg.channel_id)).finalize(reply.text);
228
+ this.progressDrafts.delete(msg.channel_id);
229
+ }
230
+ else if (this.started && !this.controlPlaneClosed) {
231
+ await this.borgee.postMessage(msg.channel_id, reply.text);
232
+ }
92
233
  }
93
234
  catch (error) {
235
+ if (useDraftProgress) {
236
+ const draft = this.progressDrafts.get(msg.channel_id);
237
+ if (draft) {
238
+ await draft.discard().catch(() => { });
239
+ this.progressDrafts.delete(msg.channel_id);
240
+ }
241
+ }
94
242
  console.error('[agents-host] failed to generate or send reply:', {
95
243
  provider: this.config.agent.provider,
96
244
  agentName: this.config.agent.agentName,
97
245
  error,
98
246
  });
99
247
  }
248
+ finally {
249
+ stopTyping();
250
+ }
251
+ }
252
+ trackActiveTurn(task) {
253
+ this.activeTurns.add(task);
254
+ void task.finally(() => {
255
+ this.activeTurns.delete(task);
256
+ });
257
+ }
258
+ enqueueProgressChannelTurn(channelId, task) {
259
+ const previous = this.progressChannelQueues.get(channelId) ?? Promise.resolve();
260
+ const next = previous
261
+ .catch(() => { })
262
+ .then(task)
263
+ .finally(() => {
264
+ if (this.progressChannelQueues.get(channelId) === next) {
265
+ this.progressChannelQueues.delete(channelId);
266
+ }
267
+ });
268
+ this.progressChannelQueues.set(channelId, next);
269
+ return next;
270
+ }
271
+ ensureProgressDraft(channelId) {
272
+ let draft = this.progressDrafts.get(channelId);
273
+ if (!draft) {
274
+ draft = new DraftMessageController(this.borgee, channelId, () => !this.controlPlaneClosed);
275
+ this.progressDrafts.set(channelId, draft);
276
+ }
277
+ return draft;
100
278
  }
101
279
  }
@@ -1,7 +1,10 @@
1
- import type { ChannelMessageEvent, MeResponseUser } from '../types.js';
1
+ import type { ChannelMessageEvent, MeResponseUser, PostedMessage } from '../types.js';
2
2
  export interface ChatControlPlane {
3
3
  connect(onMessage: (message: ChannelMessageEvent) => void): Promise<void>;
4
4
  close(): Promise<void>;
5
- postMessage(channelId: string, content: string): Promise<void>;
5
+ postMessage(channelId: string, content: string): Promise<PostedMessage>;
6
+ editMessage(messageId: string, content: string): Promise<void>;
7
+ deleteMessage(messageId: string): Promise<void>;
8
+ startTyping(channelId: string): () => void;
6
9
  getMe(): Promise<MeResponseUser>;
7
10
  }
@@ -1,7 +1,7 @@
1
1
  import { type BorgeePluginClient, type BorgeePluginOptions, type InboundMessageEvent } from '@borgee/plugin-sdk';
2
- import type { ChannelMessageEvent, MeResponseUser } from '../types.js';
2
+ import type { ChannelMessageEvent, MeResponseUser, PostedMessage } from '../types.js';
3
3
  import type { ChatControlPlane } from './chat-control-plane.js';
4
- type PluginClientLike = Pick<BorgeePluginClient, 'close' | 'connect' | 'getMe' | 'on' | 'sendMessage'>;
4
+ type PluginClientLike = Pick<BorgeePluginClient, 'agentId' | 'close' | 'connect' | 'deleteMessage' | 'editMessage' | 'getMe' | 'on' | 'sendMessage' | 'startTyping'>;
5
5
  type PluginClientFactory = (options: BorgeePluginOptions) => PluginClientLike;
6
6
  /**
7
7
  * Thin adapter over `@borgee/plugin-sdk` (the same BPP/`/ws/plugin` SDK used by
@@ -13,10 +13,14 @@ export declare class SdkChatControlPlane implements ChatControlPlane {
13
13
  private pendingMessages;
14
14
  private unsubscribe;
15
15
  private connected;
16
+ private me;
16
17
  constructor(baseUrl: string, apiKey: string, createClient?: PluginClientFactory, pluginId?: string);
17
18
  connect(onMessage: (message: ChannelMessageEvent) => void): Promise<void>;
18
19
  close(): Promise<void>;
19
- postMessage(channelId: string, content: string): Promise<void>;
20
+ postMessage(channelId: string, content: string): Promise<PostedMessage>;
21
+ editMessage(messageId: string, content: string): Promise<void>;
22
+ deleteMessage(messageId: string): Promise<void>;
23
+ startTyping(channelId: string): () => void;
20
24
  getMe(): Promise<MeResponseUser>;
21
25
  }
22
26
  export declare function mapInboundToChannelMessage(event: InboundMessageEvent): ChannelMessageEvent;
@@ -9,6 +9,7 @@ export class SdkChatControlPlane {
9
9
  pendingMessages = [];
10
10
  unsubscribe = null;
11
11
  connected = false;
12
+ me = null;
12
13
  constructor(baseUrl, apiKey, createClient = createBorgeePlugin, pluginId) {
13
14
  this.client = createClient({ baseUrl, apiKey, pluginId });
14
15
  }
@@ -24,6 +25,9 @@ export class SdkChatControlPlane {
24
25
  try {
25
26
  await this.client.connect();
26
27
  this.connected = true;
28
+ if (this.client.agentId) {
29
+ this.me = { id: this.client.agentId };
30
+ }
27
31
  for (const message of this.pendingMessages) {
28
32
  onMessage(message);
29
33
  }
@@ -44,15 +48,29 @@ export class SdkChatControlPlane {
44
48
  await this.client.close();
45
49
  }
46
50
  async postMessage(channelId, content) {
47
- await this.client.sendMessage({ channelId, body: content });
51
+ const sent = await this.client.sendMessage({ channelId, body: content });
52
+ return { messageId: sent.messageId };
53
+ }
54
+ async editMessage(messageId, content) {
55
+ await this.client.editMessage({ messageId, body: content });
56
+ }
57
+ async deleteMessage(messageId) {
58
+ await this.client.deleteMessage({ messageId });
59
+ }
60
+ startTyping(channelId) {
61
+ return this.client.startTyping(channelId);
48
62
  }
49
63
  async getMe() {
64
+ if (this.me) {
65
+ return this.me;
66
+ }
50
67
  const me = await this.client.getMe();
51
- return {
68
+ this.me = {
52
69
  id: me.id,
53
70
  display_name: me.displayName,
54
71
  role: me.kind,
55
72
  };
73
+ return this.me;
56
74
  }
57
75
  }
58
76
  export function mapInboundToChannelMessage(event) {
@@ -1,8 +1,9 @@
1
1
  import type { ProviderAdapter } from '../provider-adapter.js';
2
- import type { ProviderInput, ProviderReply } from '../../types.js';
2
+ import type { ProviderGenerateOptions, ProviderInput, ProviderReply } from '../../types.js';
3
3
  import { ClaudeCliClient } from './cli-client.js';
4
4
  export declare class ClaudeProviderAdapter implements ProviderAdapter {
5
5
  private readonly cli;
6
6
  constructor(cli: ClaudeCliClient);
7
- generateReply(input: ProviderInput): Promise<ProviderReply>;
7
+ generateReply(input: ProviderInput, options?: ProviderGenerateOptions): Promise<ProviderReply>;
8
+ dispose(): Promise<void>;
8
9
  }
@@ -4,7 +4,7 @@ export class ClaudeProviderAdapter {
4
4
  constructor(cli) {
5
5
  this.cli = cli;
6
6
  }
7
- async generateReply(input) {
7
+ async generateReply(input, options) {
8
8
  const prompt = buildPrompt({
9
9
  agentName: input.agentName,
10
10
  provider: input.provider,
@@ -12,7 +12,12 @@ export class ClaudeProviderAdapter {
12
12
  incomingAuthorId: input.incomingAuthorId,
13
13
  incomingContent: input.incomingContent,
14
14
  });
15
- const text = await this.cli.generateReply(input.channelId, prompt);
15
+ const text = await this.cli.generateReply(input.channelId, prompt, {
16
+ onProgress: options?.onProgress,
17
+ });
16
18
  return { text };
17
19
  }
20
+ async dispose() {
21
+ await this.cli.dispose();
22
+ }
18
23
  }
@@ -1,21 +1,28 @@
1
+ import spawn from 'cross-spawn';
2
+ import type { ProviderGenerateOptions } from '../../types.js';
3
+ interface ClaudeCliRuntime {
4
+ spawn: typeof spawn;
5
+ }
1
6
  /**
2
7
  * CLI client for the Claude Code CLI (`claude`) non-interactive mode, with
3
8
  * native per-channel session continuity.
4
9
  *
5
- * Verified CLI behavior (`claude --help`):
6
- * - `--session-id <uuid>` starts a *new* conversation pinned to that UUID.
7
- * - `-r/--resume <uuid>` resumes an *existing* conversation by session ID.
8
- * These are documented as distinct operations, so this client tracks which
9
- * channels have already started a session and switches from `--session-id`
10
- * (first turn) to `--resume` (every turn after) accordingly. No message
11
- * history is kept on our side — the CLI's own session storage is the single
12
- * source of truth for conversation memory.
10
+ * Claude session memory remains entirely inside the Claude CLI. This client
11
+ * only pins one native session id per Borgee channel and serializes turns per
12
+ * channel so `--resume` is never called concurrently for the same session.
13
13
  */
14
14
  export declare class ClaudeCliClient {
15
15
  private readonly command;
16
16
  private readonly args;
17
- private readonly sessionIdsByChannel;
18
- constructor(command: string, args: string[]);
19
- generateReply(channelId: string, prompt: string): Promise<string>;
17
+ private readonly runtime;
18
+ private readonly channels;
19
+ private stopped;
20
+ constructor(command: string, args: string[], runtimeOverrides?: Partial<ClaudeCliRuntime>);
21
+ generateReply(channelId: string, prompt: string, options?: ProviderGenerateOptions): Promise<string>;
22
+ dispose(): Promise<void>;
23
+ private getOrCreateChannelState;
24
+ private processChannelQueue;
25
+ private runTurn;
20
26
  private run;
21
27
  }
28
+ export {};
@@ -1,68 +1,321 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import spawn from 'cross-spawn';
3
+ const DEFAULT_RUNTIME = {
4
+ spawn,
5
+ };
6
+ const STREAM_JSON_ARGS = ['--verbose', '--output-format', 'stream-json', '--include-partial-messages'];
7
+ function normalizeError(error) {
8
+ return error instanceof Error ? error : new Error(String(error));
9
+ }
10
+ function createDeferredTurn(prompt, options) {
11
+ let settled = false;
12
+ let resolvePromise;
13
+ let rejectPromise;
14
+ const promise = new Promise((resolve, reject) => {
15
+ resolvePromise = resolve;
16
+ rejectPromise = reject;
17
+ });
18
+ return {
19
+ prompt,
20
+ options,
21
+ promise,
22
+ resolve(value) {
23
+ if (settled)
24
+ return;
25
+ settled = true;
26
+ resolvePromise(value);
27
+ },
28
+ reject(error) {
29
+ if (settled)
30
+ return;
31
+ settled = true;
32
+ rejectPromise(normalizeError(error));
33
+ },
34
+ };
35
+ }
36
+ function asObject(value) {
37
+ return typeof value === 'object' && value !== null ? value : null;
38
+ }
39
+ function asString(value) {
40
+ return typeof value === 'string' ? value : undefined;
41
+ }
42
+ function hasVisibleText(value) {
43
+ return typeof value === 'string' && value.trim().length > 0;
44
+ }
45
+ function readTextBlocks(blocks) {
46
+ if (!Array.isArray(blocks)) {
47
+ return [];
48
+ }
49
+ const segments = [];
50
+ for (const entry of blocks) {
51
+ const block = asObject(entry);
52
+ if (!block)
53
+ continue;
54
+ if (block.type === 'text' && hasVisibleText(asString(block.text))) {
55
+ segments.push(asString(block.text));
56
+ }
57
+ }
58
+ return segments;
59
+ }
60
+ function extractTextDelta(event) {
61
+ if (event.type === 'content_block_delta') {
62
+ const delta = asObject(event.delta);
63
+ if (!delta)
64
+ return null;
65
+ const deltaType = asString(delta.type);
66
+ if (deltaType !== undefined && deltaType !== 'text_delta') {
67
+ return null;
68
+ }
69
+ return asString(delta.text) ?? null;
70
+ }
71
+ if (event.type === 'content_block_start') {
72
+ const block = asObject(event.content_block) ?? asObject(event.block);
73
+ if (!block || block.type !== 'text') {
74
+ return null;
75
+ }
76
+ return asString(block.text) ?? null;
77
+ }
78
+ return null;
79
+ }
80
+ function extractToolSummary(event) {
81
+ if (event.type === 'tool_use') {
82
+ const toolName = asString(event.name) ?? asString(event.tool_name);
83
+ return hasVisibleText(toolName) ? `Running ${toolName}…` : 'Running tool…';
84
+ }
85
+ if (event.type === 'content_block_start') {
86
+ const block = asObject(event.content_block) ?? asObject(event.block);
87
+ if (!block || block.type !== 'tool_use') {
88
+ return null;
89
+ }
90
+ const toolName = asString(block.name);
91
+ return hasVisibleText(toolName) ? `Running ${toolName}…` : 'Running tool…';
92
+ }
93
+ return null;
94
+ }
95
+ function extractFinalText(event) {
96
+ if (event.type === 'result') {
97
+ const result = asString(event.result);
98
+ if (result !== undefined) {
99
+ return result;
100
+ }
101
+ }
102
+ if (event.type === 'message' || event.type === 'assistant') {
103
+ const message = asObject(event.message) ?? event;
104
+ const joined = readTextBlocks(message.content).join('');
105
+ if (joined.length > 0) {
106
+ return joined;
107
+ }
108
+ }
109
+ return null;
110
+ }
111
+ class ClaudeStreamCollector {
112
+ onProgress;
113
+ publicText = '';
114
+ finalText = null;
115
+ lastPublished = null;
116
+ constructor(onProgress) {
117
+ this.onProgress = onProgress;
118
+ }
119
+ consume(event) {
120
+ const payload = event.type === 'stream_event' ? asObject(event.event) ?? event : event;
121
+ const textDelta = extractTextDelta(payload);
122
+ if (textDelta !== null) {
123
+ this.publicText += textDelta;
124
+ this.publish(this.publicText);
125
+ }
126
+ if (!hasVisibleText(this.publicText)) {
127
+ const toolSummary = extractToolSummary(payload);
128
+ if (toolSummary) {
129
+ this.publish(toolSummary);
130
+ }
131
+ }
132
+ const finalText = extractFinalText(event) ?? extractFinalText(payload);
133
+ if (finalText !== null) {
134
+ this.finalText = finalText;
135
+ }
136
+ }
137
+ getFinalText() {
138
+ const text = this.finalText ?? this.publicText;
139
+ return hasVisibleText(text) ? text : '';
140
+ }
141
+ publish(text) {
142
+ if (!this.onProgress || !hasVisibleText(text) || text === this.lastPublished) {
143
+ return;
144
+ }
145
+ this.lastPublished = text;
146
+ this.onProgress({ text });
147
+ }
148
+ }
3
149
  /**
4
150
  * CLI client for the Claude Code CLI (`claude`) non-interactive mode, with
5
151
  * native per-channel session continuity.
6
152
  *
7
- * Verified CLI behavior (`claude --help`):
8
- * - `--session-id <uuid>` starts a *new* conversation pinned to that UUID.
9
- * - `-r/--resume <uuid>` resumes an *existing* conversation by session ID.
10
- * These are documented as distinct operations, so this client tracks which
11
- * channels have already started a session and switches from `--session-id`
12
- * (first turn) to `--resume` (every turn after) accordingly. No message
13
- * history is kept on our side — the CLI's own session storage is the single
14
- * source of truth for conversation memory.
153
+ * Claude session memory remains entirely inside the Claude CLI. This client
154
+ * only pins one native session id per Borgee channel and serializes turns per
155
+ * channel so `--resume` is never called concurrently for the same session.
15
156
  */
16
157
  export class ClaudeCliClient {
17
158
  command;
18
159
  args;
19
- sessionIdsByChannel = new Map();
20
- constructor(command, args) {
160
+ runtime;
161
+ channels = new Map();
162
+ stopped = false;
163
+ constructor(command, args, runtimeOverrides = {}) {
21
164
  this.command = command;
22
165
  this.args = args;
166
+ this.runtime = { ...DEFAULT_RUNTIME, ...runtimeOverrides };
167
+ }
168
+ async generateReply(channelId, prompt, options) {
169
+ if (this.stopped) {
170
+ throw new Error('Claude CLI backend stopped');
171
+ }
172
+ const state = this.getOrCreateChannelState(channelId);
173
+ const turn = createDeferredTurn(prompt, options);
174
+ state.queue.push(turn);
175
+ this.processChannelQueue(channelId, state);
176
+ return turn.promise;
23
177
  }
24
- async generateReply(channelId, prompt) {
25
- const existingSessionId = this.sessionIdsByChannel.get(channelId);
26
- const sessionId = existingSessionId ?? randomUUID();
27
- const sessionArgs = existingSessionId
28
- ? ['--resume', sessionId]
29
- : ['--session-id', sessionId];
30
- const text = await this.run([...this.args, ...sessionArgs], prompt);
31
- // Only remember the session once the CLI call actually succeeds, so a
32
- // failed first turn doesn't leave us permanently trying to `--resume`
33
- // a session that was never created.
34
- this.sessionIdsByChannel.set(channelId, sessionId);
178
+ async dispose() {
179
+ if (this.stopped)
180
+ return;
181
+ this.stopped = true;
182
+ const stoppedError = new Error('Claude CLI backend stopped');
183
+ for (const state of this.channels.values()) {
184
+ state.activeTurn?.reject(stoppedError);
185
+ for (const queuedTurn of state.queue) {
186
+ queuedTurn.reject(stoppedError);
187
+ }
188
+ state.queue = [];
189
+ state.activeChild?.kill('SIGTERM');
190
+ }
191
+ }
192
+ getOrCreateChannelState(channelId) {
193
+ let state = this.channels.get(channelId);
194
+ if (!state) {
195
+ state = {
196
+ processing: false,
197
+ queue: [],
198
+ sessionEstablished: false,
199
+ };
200
+ this.channels.set(channelId, state);
201
+ }
202
+ return state;
203
+ }
204
+ processChannelQueue(channelId, state) {
205
+ if (state.processing) {
206
+ return;
207
+ }
208
+ state.processing = true;
209
+ void (async () => {
210
+ try {
211
+ while (!this.stopped) {
212
+ const turn = state.queue.shift();
213
+ if (!turn) {
214
+ return;
215
+ }
216
+ state.activeTurn = turn;
217
+ try {
218
+ const text = await this.runTurn(state, turn);
219
+ turn.resolve(text);
220
+ }
221
+ catch (error) {
222
+ turn.reject(error);
223
+ }
224
+ finally {
225
+ state.activeTurn = undefined;
226
+ }
227
+ }
228
+ }
229
+ finally {
230
+ state.processing = false;
231
+ if (state.queue.length > 0 && !this.stopped) {
232
+ this.processChannelQueue(channelId, state);
233
+ }
234
+ }
235
+ })();
236
+ }
237
+ async runTurn(state, turn) {
238
+ if (this.stopped) {
239
+ throw new Error('Claude CLI backend stopped');
240
+ }
241
+ const sessionId = state.sessionEstablished ? state.sessionId : randomUUID();
242
+ const sessionArgs = state.sessionEstablished ? ['--resume', sessionId] : ['--session-id', sessionId];
243
+ const text = await this.run(state, [...this.args, ...sessionArgs, ...STREAM_JSON_ARGS], turn.prompt, turn.options);
244
+ state.sessionId = sessionId;
245
+ state.sessionEstablished = true;
35
246
  return text;
36
247
  }
37
- async run(args, prompt) {
248
+ async run(state, args, prompt, options) {
38
249
  return new Promise((resolve, reject) => {
39
- const child = spawn(this.command, args, {
250
+ const child = this.runtime.spawn(this.command, args, {
40
251
  stdio: ['pipe', 'pipe', 'pipe'],
41
252
  });
42
- let stdout = '';
253
+ state.activeChild = child;
254
+ const collector = new ClaudeStreamCollector(options?.onProgress);
43
255
  let stderr = '';
256
+ let lineBuffer = '';
257
+ let settled = false;
258
+ const settleReject = (error) => {
259
+ if (settled)
260
+ return;
261
+ settled = true;
262
+ reject(normalizeError(error));
263
+ };
264
+ const settleResolve = (value) => {
265
+ if (settled)
266
+ return;
267
+ settled = true;
268
+ resolve(value);
269
+ };
44
270
  child.stdout.setEncoding('utf8');
45
271
  child.stderr.setEncoding('utf8');
46
272
  child.stdout.on('data', (chunk) => {
47
- stdout += chunk;
273
+ lineBuffer += chunk;
274
+ while (true) {
275
+ const newlineIndex = lineBuffer.indexOf('\n');
276
+ if (newlineIndex === -1) {
277
+ break;
278
+ }
279
+ const line = lineBuffer.slice(0, newlineIndex).trim();
280
+ lineBuffer = lineBuffer.slice(newlineIndex + 1);
281
+ if (!line)
282
+ continue;
283
+ try {
284
+ const event = JSON.parse(line);
285
+ collector.consume(event);
286
+ }
287
+ catch (error) {
288
+ settleReject(new Error(`Claude stream-json parse failed: ${normalizeError(error).message}`));
289
+ child.kill('SIGTERM');
290
+ return;
291
+ }
292
+ }
48
293
  });
49
294
  child.stderr.on('data', (chunk) => {
50
295
  stderr += chunk;
51
296
  });
52
- child.on('error', (error) => {
53
- reject(error);
297
+ child.once('error', (error) => {
298
+ settleReject(error);
54
299
  });
55
- child.on('close', (code) => {
56
- if (code !== 0) {
57
- reject(new Error(`Claude CLI failed with code ${code}: ${stderr.trim()}`));
58
- return;
300
+ child.once('close', (code) => {
301
+ if (state.activeChild === child) {
302
+ state.activeChild = undefined;
59
303
  }
60
- const text = stdout.trim();
61
- if (!text) {
62
- reject(new Error('Claude CLI returned empty output'));
304
+ if (lineBuffer.trim().length > 0 && !settled) {
305
+ try {
306
+ collector.consume(JSON.parse(lineBuffer.trim()));
307
+ lineBuffer = '';
308
+ }
309
+ catch (error) {
310
+ settleReject(new Error(`Claude stream-json parse failed: ${normalizeError(error).message}`));
311
+ return;
312
+ }
313
+ }
314
+ if (code !== 0) {
315
+ settleReject(new Error(`Claude CLI failed with code ${code}: ${stderr.trim()}`));
63
316
  return;
64
317
  }
65
- resolve(text);
318
+ settleResolve(collector.getFinalText());
66
319
  });
67
320
  child.stdin.write(prompt);
68
321
  child.stdin.end();
@@ -1,9 +1,9 @@
1
1
  import type { ProviderAdapter } from '../provider-adapter.js';
2
- import type { ProviderInput, ProviderReply } from '../../types.js';
2
+ import type { ProviderGenerateOptions, ProviderInput, ProviderReply } from '../../types.js';
3
3
  import { CopilotCliClient } from './cli-client.js';
4
4
  export declare class CopilotProviderAdapter implements ProviderAdapter {
5
5
  private readonly cli;
6
6
  constructor(cli: CopilotCliClient);
7
- generateReply(input: ProviderInput): Promise<ProviderReply>;
7
+ generateReply(input: ProviderInput, options?: ProviderGenerateOptions): Promise<ProviderReply>;
8
8
  dispose(): Promise<void>;
9
9
  }
@@ -4,7 +4,7 @@ export class CopilotProviderAdapter {
4
4
  constructor(cli) {
5
5
  this.cli = cli;
6
6
  }
7
- async generateReply(input) {
7
+ async generateReply(input, options) {
8
8
  const prompt = buildPrompt({
9
9
  agentName: input.agentName,
10
10
  provider: input.provider,
@@ -12,7 +12,9 @@ export class CopilotProviderAdapter {
12
12
  incomingAuthorId: input.incomingAuthorId,
13
13
  incomingContent: input.incomingContent,
14
14
  });
15
- const text = await this.cli.generateReply(input.channelId, prompt);
15
+ const text = await this.cli.generateReply(input.channelId, prompt, {
16
+ onProgress: options?.onProgress,
17
+ });
16
18
  return { text };
17
19
  }
18
20
  async dispose() {
@@ -1,5 +1,6 @@
1
1
  import spawn from 'cross-spawn';
2
2
  import { PROTOCOL_VERSION, client, methods, ndJsonStream } from '@agentclientprotocol/sdk';
3
+ import type { ProviderGenerateOptions } from '../../types.js';
3
4
  interface CopilotAcpRuntime {
4
5
  spawn: typeof spawn;
5
6
  client: typeof client;
@@ -38,7 +39,7 @@ export declare class CopilotCliClient {
38
39
  private disposing;
39
40
  private backendClosed;
40
41
  constructor(command: string, _ignoredArgs?: string[], runtimeOverrides?: Partial<CopilotAcpRuntime>);
41
- generateReply(channelId: string, prompt: string): Promise<string>;
42
+ generateReply(channelId: string, prompt: string, options?: ProviderGenerateOptions): Promise<string>;
42
43
  dispose(): Promise<void>;
43
44
  private ensureStarted;
44
45
  private startBackend;
@@ -17,7 +17,88 @@ const DEFAULT_RUNTIME = {
17
17
  shutdownGracePeriodMs: DEFAULT_SHUTDOWN_GRACE_PERIOD_MS,
18
18
  shutdownForceKillWaitMs: DEFAULT_SHUTDOWN_FORCE_KILL_WAIT_MS,
19
19
  };
20
- function createDeferredTurn(prompt) {
20
+ function hasVisibleText(value) {
21
+ return typeof value === 'string' && value.trim().length > 0;
22
+ }
23
+ function formatToolProgress(title, status) {
24
+ const normalizedTitle = hasVisibleText(title) ? title.trim() : null;
25
+ switch (status) {
26
+ case 'completed':
27
+ return normalizedTitle ? `Completed ${normalizedTitle}` : 'Completed tool call';
28
+ case 'failed':
29
+ return normalizedTitle ? `Failed ${normalizedTitle}` : 'Tool call failed';
30
+ case 'pending':
31
+ case 'in_progress':
32
+ case undefined:
33
+ case null:
34
+ return normalizedTitle ? `Running ${normalizedTitle}…` : 'Running tool…';
35
+ default:
36
+ return normalizedTitle ? `${status} ${normalizedTitle}` : status;
37
+ }
38
+ }
39
+ function formatPlanProgress(entries) {
40
+ const current = entries.find((entry) => entry.status === 'in_progress') ??
41
+ entries.find((entry) => entry.status === 'pending') ??
42
+ entries[0];
43
+ return hasVisibleText(current?.content) ? `Plan: ${current.content.trim()}` : null;
44
+ }
45
+ class CopilotProgressCollector {
46
+ onProgress;
47
+ publicText = '';
48
+ lastPublished = null;
49
+ toolTitles = new Map();
50
+ constructor(onProgress) {
51
+ this.onProgress = onProgress;
52
+ }
53
+ consume(update) {
54
+ switch (update.update.sessionUpdate) {
55
+ case 'agent_message_chunk':
56
+ if (update.update.content.type !== 'text') {
57
+ return;
58
+ }
59
+ this.publicText += update.update.content.text;
60
+ this.publish(this.publicText);
61
+ return;
62
+ case 'tool_call':
63
+ this.toolTitles.set(update.update.toolCallId, update.update.title);
64
+ this.publishFallback(formatToolProgress(update.update.title, update.update.status));
65
+ return;
66
+ case 'tool_call_update': {
67
+ const nextTitle = update.update.title ?? this.toolTitles.get(update.update.toolCallId);
68
+ if (hasVisibleText(nextTitle)) {
69
+ this.toolTitles.set(update.update.toolCallId, nextTitle);
70
+ }
71
+ this.publishFallback(formatToolProgress(nextTitle, update.update.status));
72
+ return;
73
+ }
74
+ case 'plan':
75
+ this.publishFallback(formatPlanProgress(update.update.entries.map((entry) => ({
76
+ content: entry.content,
77
+ status: entry.status,
78
+ }))));
79
+ return;
80
+ default:
81
+ return;
82
+ }
83
+ }
84
+ getFinalText() {
85
+ return this.publicText.trim();
86
+ }
87
+ publishFallback(text) {
88
+ if (hasVisibleText(this.publicText)) {
89
+ return;
90
+ }
91
+ this.publish(text);
92
+ }
93
+ publish(text) {
94
+ if (!this.onProgress || !hasVisibleText(text) || text === this.lastPublished) {
95
+ return;
96
+ }
97
+ this.lastPublished = text;
98
+ this.onProgress({ text });
99
+ }
100
+ }
101
+ function createDeferredTurn(prompt, options) {
21
102
  let settled = false;
22
103
  let resolvePromise;
23
104
  let rejectPromise;
@@ -27,6 +108,7 @@ function createDeferredTurn(prompt) {
27
108
  });
28
109
  return {
29
110
  prompt,
111
+ options,
30
112
  promise,
31
113
  resolve(value) {
32
114
  if (settled)
@@ -95,13 +177,13 @@ export class CopilotCliClient {
95
177
  });
96
178
  void this.fatalPromise.catch(() => { });
97
179
  }
98
- async generateReply(channelId, prompt) {
180
+ async generateReply(channelId, prompt, options) {
99
181
  if (this.fatalError) {
100
182
  throw this.fatalError;
101
183
  }
102
184
  const state = this.getOrCreateChannelState(channelId);
103
185
  this.clearIdleTimer(state);
104
- const turn = createDeferredTurn(prompt);
186
+ const turn = createDeferredTurn(prompt, options);
105
187
  state.queue.push(turn);
106
188
  this.processChannelQueue(channelId, state);
107
189
  return turn.promise;
@@ -211,7 +293,7 @@ export class CopilotCliClient {
211
293
  const session = await this.getOrCreateSession(channelId, state);
212
294
  let reply;
213
295
  try {
214
- reply = await this.runTurn(session, turn.prompt);
296
+ reply = await this.runTurn(session, turn.prompt, turn.options);
215
297
  }
216
298
  catch (error) {
217
299
  if (isSessionTainted(error)) {
@@ -281,12 +363,12 @@ export class CopilotCliClient {
281
363
  }
282
364
  }
283
365
  }
284
- async runTurn(session, prompt) {
366
+ async runTurn(session, prompt, options) {
285
367
  const promptPromise = this.raceWithFatal(session.prompt(prompt));
286
368
  const promptFailure = new Promise((_, reject) => {
287
369
  void promptPromise.catch((error) => reject(markSessionTainted(error)));
288
370
  });
289
- let text = '';
371
+ const collector = new CopilotProgressCollector(options?.onProgress);
290
372
  for (;;) {
291
373
  let update;
292
374
  try {
@@ -309,16 +391,13 @@ export class CopilotCliClient {
309
391
  if (response.stopReason !== 'end_turn') {
310
392
  throw new Error(`Copilot ACP turn stopped with stopReason "${response.stopReason}"`);
311
393
  }
312
- const output = text.trim();
394
+ const output = collector.getFinalText();
313
395
  if (!output) {
314
396
  throw new Error('Copilot ACP returned empty output');
315
397
  }
316
398
  return output;
317
399
  }
318
- if (update.update.sessionUpdate === 'agent_message_chunk' &&
319
- update.update.content.type === 'text') {
320
- text += update.update.content.text;
321
- }
400
+ collector.consume(update);
322
401
  }
323
402
  }
324
403
  async raceWithFatal(promise) {
@@ -1,5 +1,5 @@
1
- import type { ProviderInput, ProviderReply } from '../types.js';
1
+ import type { ProviderGenerateOptions, ProviderInput, ProviderReply } from '../types.js';
2
2
  export interface ProviderAdapter {
3
- generateReply(input: ProviderInput): Promise<ProviderReply>;
3
+ generateReply(input: ProviderInput, options?: ProviderGenerateOptions): Promise<ProviderReply>;
4
4
  dispose?(): Promise<void>;
5
5
  }
package/dist/types.d.ts CHANGED
@@ -106,3 +106,12 @@ export interface ProviderInput {
106
106
  export interface ProviderReply {
107
107
  text: string;
108
108
  }
109
+ export interface PostedMessage {
110
+ messageId: string;
111
+ }
112
+ export interface ProviderProgressUpdate {
113
+ text: string;
114
+ }
115
+ export interface ProviderGenerateOptions {
116
+ onProgress?: (update: ProviderProgressUpdate) => void;
117
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@borgee/agents-host",
3
- "version": "0.1.8",
3
+ "version": "0.2.0",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -24,7 +24,7 @@
24
24
  "@agentclientprotocol/sdk": "^1.2.1",
25
25
  "cross-spawn": "^7.0.6",
26
26
  "yaml": "^2.8.1",
27
- "@borgee/plugin-sdk": "0.1.1"
27
+ "@borgee/plugin-sdk": "0.1.2"
28
28
  },
29
29
  "devDependencies": {
30
30
  "@types/cross-spawn": "^6.0.6",