@controlflow-ai/daemon 0.1.3 → 0.1.5

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/bin/daemon.js CHANGED
@@ -1,2 +1,7 @@
1
1
  #!/usr/bin/env bun
2
- import '../src/daemon.ts';
2
+ import { main } from '../src/daemon.ts';
3
+
4
+ main().catch((error) => {
5
+ console.error(error instanceof Error ? error.message : String(error));
6
+ process.exit(1);
7
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@controlflow-ai/daemon",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "server": "bun run src/server.ts",
@@ -157,6 +157,19 @@ function formatMessageAttachmentSummary(message: Message): string {
157
157
  return ` [attachments: ${items.join('; ')}]`;
158
158
  }
159
159
 
160
+ function formatMergedDeliveredContent(context: DeliveryContext | undefined, currentMessage: Message): string {
161
+ if (!context || context.messages.length === 0) return sanitizeProviderIds(currentMessage.content);
162
+ const lines = context.messages.map((message) => {
163
+ const marker = message.id === currentMessage.id ? ' current' : '';
164
+ const parent = message.parent_id === null ? '' : ` parent=${message.parent_id}`;
165
+ const recipient = message.recipient ? ` to=@${sanitizeProviderIds(message.recipient)}` : '';
166
+ const sender = sanitizeProviderIds(message.sender);
167
+ const content = sanitizeProviderIds(message.content).trim();
168
+ return `[${message.id}${marker}] @${sender}${recipient}${parent}:\n${content}`;
169
+ });
170
+ return `Merged delivered message content:\n${lines.join('\n\n')}`;
171
+ }
172
+
160
173
  function formatSavedRoomContext(savedMessages: RoomSavedMessage[] | undefined): string {
161
174
  if (!savedMessages || savedMessages.length === 0) return '';
162
175
  const lines = savedMessages.slice(0, 10).map((saved) => {
@@ -228,7 +241,8 @@ export function buildPalPrompt(input: AgentRuntimeRunInput): string {
228
241
  const chatName = sanitizeProviderIds(input.message.chat_name);
229
242
  const chatRef = input.message.chat_id;
230
243
  const sender = sanitizeProviderIds(input.message.sender);
231
- const content = sanitizeProviderIds(input.message.content);
244
+ const currentContent = sanitizeProviderIds(input.message.content);
245
+ const content = formatMergedDeliveredContent(input.deliveryContext, input.message);
232
246
  const freshnessFlags = `--base-message-id ${input.message.id} --hold-if-stale`;
233
247
  const projectContext = input.projectContext
234
248
  ? input.projectContext.accessible
@@ -270,7 +284,7 @@ ${formatRoomModeAndSkillContext({ roomMode: input.roomMode, enabledSkills: input
270
284
  ${messageContext}
271
285
  ${savedRoomContext}
272
286
  ${runtimeAttachments}
273
- ${formatAllMentionOnlyHandoff(content, messageContextLabel)}
287
+ ${formatAllMentionOnlyHandoff(currentContent, messageContextLabel)}
274
288
  Content:
275
289
  ${content}
276
290
 
package/src/app.ts CHANGED
@@ -133,7 +133,7 @@ interface AgentRoomSubscriptionBody {
133
133
  mode?: AgentRoomSubscriptionMode;
134
134
  }
135
135
 
136
- const agentRoomSubscriptionModes: AgentRoomSubscriptionMode[] = ['all', 'periodic', 'mentions', 'muted', 'off'];
136
+ const agentRoomSubscriptionModes: AgentRoomSubscriptionMode[] = ['all', 'available', 'periodic', 'mentions', 'muted', 'off'];
137
137
  const agentActivityKinds: AgentActivityKind[] = ['lifecycle', 'working', 'thinking', 'output', 'tool', 'error'];
138
138
  const roomTaskStatuses: RoomTaskStatus[] = ['todo', 'in_progress', 'in_review', 'done', 'closed'];
139
139
  const roomReminderStatuses: RoomReminderStatus[] = ['scheduled', 'fired', 'canceled'];
@@ -144,7 +144,7 @@ const workflowRunStatuses: WorkflowRunStatus[] = ['running', 'completed', 'faile
144
144
 
145
145
  function parseAgentRoomSubscriptionMode(value: unknown): AgentRoomSubscriptionMode {
146
146
  if (typeof value !== 'string' || !agentRoomSubscriptionModes.includes(value as AgentRoomSubscriptionMode)) {
147
- throw new HttpError(400, 'INVALID_SUBSCRIPTION_MODE', 'mode must be all, periodic, mentions, muted, or off');
147
+ throw new HttpError(400, 'INVALID_SUBSCRIPTION_MODE', 'mode must be all, available, periodic, mentions, muted, or off');
148
148
  }
149
149
  return value as AgentRoomSubscriptionMode;
150
150
  }
@@ -2162,6 +2162,33 @@ export function route(store: MessageStore, request: Request, options: AppRouteOp
2162
2162
  }
2163
2163
  }
2164
2164
 
2165
+ const roomAgentInterruptMatch = pathname.match(/^\/api\/rooms\/([^/]+)\/agents\/([^/]+)\/interrupt$/);
2166
+ if (request.method === 'POST' && roomAgentInterruptMatch) {
2167
+ const room = store.resolveRoom(decodeURIComponent(roomAgentInterruptMatch[1]!));
2168
+ if (!room) throw new HttpError(404, 'ROOM_NOT_FOUND', 'room not found');
2169
+ const agent = decodeURIComponent(roomAgentInterruptMatch[2]!);
2170
+ if (!store.getAgent(agent)) throw new HttpError(404, 'AGENT_NOT_FOUND', 'agent not found');
2171
+ try {
2172
+ const run = store.requestRoomAgentRunAction({ roomId: room.id, agent, action: 'interrupt' });
2173
+ const pendingBacklogCount = store.countPendingDeliveriesAfterMessage({
2174
+ roomId: room.id,
2175
+ agent,
2176
+ afterMessageId: run.trigger_message_id ?? run.message_id,
2177
+ });
2178
+ return json({
2179
+ run,
2180
+ pending_backlog_count: pendingBacklogCount,
2181
+ message: `Stop requested for @${agent} current processing. Pending messages were not cleared. Already completed file changes, commands, and external side effects are not rolled back.`,
2182
+ });
2183
+ } catch (error) {
2184
+ const message = error instanceof Error ? error.message : String(error);
2185
+ if (message.includes('running run for agent') && message.includes('was not found')) {
2186
+ throw new HttpError(404, 'RUN_NOT_FOUND', 'no running runtime for this agent in this room');
2187
+ }
2188
+ throw error;
2189
+ }
2190
+ }
2191
+
2165
2192
  const roomAgentSubscriptionMatch = pathname.match(/^\/api\/rooms\/([^/]+)\/agents\/([^/]+)\/subscription$/);
2166
2193
  if (request.method === 'PATCH' && roomAgentSubscriptionMatch) {
2167
2194
  return readJson<AgentRoomSubscriptionBody>(request).then((body) => {
@@ -2653,6 +2680,23 @@ export function route(store: MessageStore, request: Request, options: AppRouteOp
2653
2680
  });
2654
2681
  }
2655
2682
 
2683
+ const deliveryCancelMatch = pathname.match(/^\/api\/deliveries\/([^/]+)\/cancel$/);
2684
+ if (request.method === 'POST' && deliveryCancelMatch) {
2685
+ return readJson<FinishDeliveryBody>(request).then((body) => {
2686
+ const connection = requireDaemonConnection(store, request);
2687
+ const ownerId = connection?.connectionId ?? body.connection_id ?? body.daemon_id;
2688
+ if (!ownerId?.trim()) throw new HttpError(400, 'MISSING_DAEMON_ID', 'daemon_id or connection auth is required');
2689
+ if (!body.claim_token?.trim()) throw new HttpError(400, 'MISSING_CLAIM_TOKEN', 'claim_token is required');
2690
+ const delivery = store.cancelClaimedDelivery(deliveryCancelMatch[1]!, {
2691
+ daemonId: ownerId,
2692
+ connectionId: connection?.connectionId ?? body.connection_id,
2693
+ claimToken: body.claim_token,
2694
+ runId: body.run_id,
2695
+ });
2696
+ return json({ delivery });
2697
+ });
2698
+ }
2699
+
2656
2700
  if (request.method === 'POST' && pathname === '/api/messages') {
2657
2701
  return readJson<SendBody>(request).then(async (body) => {
2658
2702
  const connection = assertDaemonConnection(store, request);
@@ -2929,7 +2973,7 @@ export function route(store: MessageStore, request: Request, options: AppRouteOp
2929
2973
  });
2930
2974
  }
2931
2975
 
2932
- const actionMatch = pathname.match(/^\/api\/runs\/([^/]+)\/(kill|restart)$/);
2976
+ const actionMatch = pathname.match(/^\/api\/runs\/([^/]+)\/(kill|restart|interrupt)$/);
2933
2977
  if (request.method === 'POST' && actionMatch) {
2934
2978
  const run = store.requestRunAction(actionMatch[1]!, actionMatch[2] as RunAction);
2935
2979
  return json({ run });
package/src/cli.ts CHANGED
@@ -23,6 +23,7 @@ Usage:
23
23
  bun run src/cli.ts rooms handoff --from-room <room-id> --from <agent> --purpose <goal> [--source-message <id>] [--name <room>] [--team agent:mode ...] [--stdin]
24
24
  bun run src/cli.ts rooms invite --room general --agent codex [--mode mentions|all|periodic|muted|off]
25
25
  bun run src/cli.ts rooms restart-agent --room general --agent codex [--json]
26
+ bun run src/cli.ts rooms interrupt-agent --room general --agent codex [--json]
26
27
  bun run src/cli.ts debate start --chat general --a palbeta --b clawed [--turns 6] <topic>
27
28
  bun run src/cli.ts rooms list [--json]
28
29
  bun run src/cli.ts rooms members --room <room-id-or-name> [--json]
@@ -139,15 +140,15 @@ function roomNameFromPurpose(purpose: string): string {
139
140
  return `handoff-${slug || Date.now().toString(36)}`;
140
141
  }
141
142
 
142
- function parseTeamFlags(values: string[]): Array<{ agent: string; mode?: 'all' | 'periodic' | 'mentions' | 'muted' | 'off' }> | undefined {
143
+ function parseTeamFlags(values: string[]): Array<{ agent: string; mode?: 'all' | 'available' | 'periodic' | 'mentions' | 'muted' | 'off' }> | undefined {
143
144
  if (!values.length) return undefined;
144
145
  return values.map((value) => {
145
146
  const [agent, mode] = value.split(':');
146
147
  if (!agent?.trim()) throw new Error('--team requires agent or agent:mode');
147
- if (mode && !['all', 'periodic', 'mentions', 'muted', 'off'].includes(mode)) {
148
- throw new Error('--team mode must be all, periodic, mentions, muted, or off');
148
+ if (mode && !['all', 'available', 'periodic', 'mentions', 'muted', 'off'].includes(mode)) {
149
+ throw new Error('--team mode must be all, available, periodic, mentions, muted, or off');
149
150
  }
150
- return { agent: agent.trim(), mode: mode as 'all' | 'periodic' | 'mentions' | 'muted' | 'off' | undefined };
151
+ return { agent: agent.trim(), mode: mode as 'all' | 'available' | 'periodic' | 'mentions' | 'muted' | 'off' | undefined };
151
152
  });
152
153
  }
153
154
 
@@ -614,6 +615,21 @@ async function main(): Promise<void> {
614
615
  }
615
616
  return;
616
617
  }
618
+ if (sub === 'interrupt-agent' || sub === 'stop-agent') {
619
+ const roomRef = flag(args.flags, 'room') ?? flag(args.flags, 'chat-id') ?? args.values[1];
620
+ const agent = flag(args.flags, 'agent') ?? args.values[2];
621
+ if (!roomRef) throw new Error(`rooms ${sub} requires --room <room-id-or-name>`);
622
+ if (!agent) throw new Error(`rooms ${sub} requires --agent <agent-key>`);
623
+ const result = await daemonClient.interruptRoomAgent(roomRef, agent);
624
+ if (args.flags.json) {
625
+ printJson(result);
626
+ } else {
627
+ console.log(`interrupt requested for @${sanitizeProviderIds(agent)} in ${sanitizeProviderIds(roomRef)}: run=${result.run.id} status=${result.run.status} pending_backlog=${result.pending_backlog_count}`);
628
+ console.log('Pending messages were not cleared; they remain available for the next wakeup.');
629
+ console.log('Already completed file changes, commands, and external side effects are not rolled back.');
630
+ }
631
+ return;
632
+ }
617
633
  throw new Error(`unknown rooms subcommand: ${sub}`);
618
634
  }
619
635
 
package/src/client.ts CHANGED
@@ -134,6 +134,12 @@ export interface HeldDraftResolutionResult {
134
134
  notify?: unknown;
135
135
  }
136
136
 
137
+ export interface InterruptRoomAgentResult {
138
+ run: AgentRun;
139
+ pending_backlog_count: number;
140
+ message: string;
141
+ }
142
+
137
143
  export interface ListHeldDraftsRequest {
138
144
  agent?: string;
139
145
  room_id?: string | null;
@@ -388,6 +394,10 @@ export class LockClient {
388
394
  return data.run;
389
395
  }
390
396
 
397
+ async interruptRoomAgent(room: string, agent: string): Promise<InterruptRoomAgentResult> {
398
+ return this.post<InterruptRoomAgentResult>(`/api/rooms/${encodeURIComponent(room)}/agents/${encodeURIComponent(agent)}/interrupt`, {});
399
+ }
400
+
391
401
  async scheduleReminder(input: CreateRoomReminderRequest): Promise<RoomReminder> {
392
402
  const data = await this.post<{ reminder: RoomReminder }>('/api/reminders', input);
393
403
  return data.reminder;
@@ -667,6 +677,11 @@ export class LockClient {
667
677
  return data.delivery;
668
678
  }
669
679
 
680
+ async cancelClaimedDelivery(id: string, input: FinishDeliveryRequest): Promise<MessageDelivery> {
681
+ const data = await this.post<{ delivery: MessageDelivery }>(`/api/deliveries/${id}/cancel`, input);
682
+ return data.delivery;
683
+ }
684
+
670
685
  async getOrCreateSession(input: GetOrCreateSessionRequest): Promise<AgentSession> {
671
686
  const data = await this.post<{ session: AgentSession }>('/api/sessions', input);
672
687
  return data.session;
@@ -1,7 +1,7 @@
1
1
  import { defaultDaemonToken, defaultDaemonUrl } from './config.js';
2
2
  import { localHeaders } from './local-auth.js';
3
3
  import type { AgentRoomSubscriptionMode, AgentRun, ApiResponse, Chat, HeldMessageDraft, Message, RoomChannel, RoomParticipant, RoomReminder, RoomSavedMessage, RoomTask, RoomTaskStatus, RunAction } from './types.js';
4
- import type { ClaimRoomTaskRequest, CreateArtifactRequest, CreateHandoffRoomRequest, CreateRoomReminderRequest, CreateRoomSavedMessageRequest, CreateRoomTasksRequest, HeldDraftResolutionResult, ListHeldDraftsRequest, ListRoomRemindersRequest, ListRoomSavedMessagesRequest, SendMessageResult, SendRequest } from './client.js';
4
+ import type { ClaimRoomTaskRequest, CreateArtifactRequest, CreateHandoffRoomRequest, CreateRoomReminderRequest, CreateRoomSavedMessageRequest, CreateRoomTasksRequest, HeldDraftResolutionResult, InterruptRoomAgentResult, ListHeldDraftsRequest, ListRoomRemindersRequest, ListRoomSavedMessagesRequest, SendMessageResult, SendRequest } from './client.js';
5
5
 
6
6
  export class DaemonClient {
7
7
  readonly baseUrl: string;
@@ -115,6 +115,10 @@ export class DaemonClient {
115
115
  return data.run;
116
116
  }
117
117
 
118
+ async interruptRoomAgent(room: string, agent: string): Promise<InterruptRoomAgentResult> {
119
+ return this.post<InterruptRoomAgentResult>(`/local/rooms/${encodeURIComponent(room)}/agents/${encodeURIComponent(agent)}/interrupt`, {});
120
+ }
121
+
118
122
  async scheduleReminder(input: CreateRoomReminderRequest): Promise<RoomReminder> {
119
123
  const data = await this.post<{ reminder: RoomReminder }>('/local/reminders', input);
120
124
  return data.reminder;
package/src/daemon.ts CHANGED
@@ -8,7 +8,7 @@ import { formatMessage } from './format.js';
8
8
  import { startLocalApi } from './local-api.js';
9
9
  import type { LocalApiAgentPrincipal } from './local-auth.js';
10
10
  import { loadOrCreateDaemonToken } from './token-file.js';
11
- import type { ComputerAgentAssignment, Message, MessageDelivery } from './types.js';
11
+ import type { AgentRun, ComputerAgentAssignment, Message, MessageDelivery, RunAction } from './types.js';
12
12
  import type { AgentRuntime } from './agent-runtime.js';
13
13
  import { knownRuntimeNames, resolveRuntimeDriver } from './runtime-registry.js';
14
14
  import { buildRuntimeLaunchContext, type AgentPermissionProfile } from './agent-permissions.js';
@@ -457,7 +457,7 @@ async function loadPermissionProfile(client: LockClient, agent: string): Promise
457
457
  }
458
458
 
459
459
  async function ackSteeredDeliveries(
460
- client: LockClient,
460
+ client: Pick<LockClient, 'ackDelivery'>,
461
461
  active: ActiveRunHandle,
462
462
  options: { daemonId: string; connectionId?: string | null },
463
463
  runId: string,
@@ -475,7 +475,7 @@ async function ackSteeredDeliveries(
475
475
  }
476
476
 
477
477
  async function failSteeredDeliveries(
478
- client: LockClient,
478
+ client: Pick<LockClient, 'failDelivery'>,
479
479
  active: ActiveRunHandle,
480
480
  options: { daemonId: string; connectionId?: string | null },
481
481
  runId: string,
@@ -494,11 +494,57 @@ async function failSteeredDeliveries(
494
494
  }
495
495
  }
496
496
 
497
+ export async function finishStoppedRunAction(
498
+ client: Pick<LockClient, 'finishRun' | 'ackDelivery' | 'cancelClaimedDelivery' | 'failDelivery'>,
499
+ input: {
500
+ action: 'kill' | 'restart' | 'interrupt';
501
+ runId: string;
502
+ exitCode: number | null;
503
+ output: string;
504
+ delivery: MessageDelivery;
505
+ active: ActiveRunHandle;
506
+ daemonId: string;
507
+ connectionId?: string | null;
508
+ },
509
+ ): Promise<'done' | 'restart'> {
510
+ if (!input.delivery.claim_token) throw new Error(`delivery ${input.delivery.id} is not claimed`);
511
+ if (input.action === 'restart') {
512
+ await client.finishRun(input.runId, { status: 'restarted', exit_code: input.exitCode, output: input.output });
513
+ await failSteeredDeliveries(client, input.active, { daemonId: input.daemonId, connectionId: input.connectionId }, input.runId, 'active run restarted before completion');
514
+ return 'restart';
515
+ }
516
+ if (input.action === 'kill') {
517
+ await client.finishRun(input.runId, { status: 'killed', exit_code: input.exitCode, output: input.output });
518
+ await client.ackDelivery(input.delivery.id, { daemon_id: input.daemonId, connection_id: input.connectionId, claim_token: input.delivery.claim_token, run_id: input.runId });
519
+ await ackSteeredDeliveries(client, input.active, { daemonId: input.daemonId, connectionId: input.connectionId }, input.runId);
520
+ return 'done';
521
+ }
522
+
523
+ await client.finishRun(input.runId, { status: 'cancelled', exit_code: input.exitCode, output: input.output });
524
+ await client.cancelClaimedDelivery(input.delivery.id, { daemon_id: input.daemonId, connection_id: input.connectionId, claim_token: input.delivery.claim_token, run_id: input.runId });
525
+ await failSteeredDeliveries(client, input.active, { daemonId: input.daemonId, connectionId: input.connectionId }, input.runId, 'active run interrupted before completion');
526
+ return 'done';
527
+ }
528
+
529
+ export async function resolveStoppedRunActionAfterRuntime(
530
+ client: Pick<LockClient, 'getRun'>,
531
+ input: { runId: string; observedAction: RunAction | null },
532
+ ): Promise<RunAction | null> {
533
+ if (input.observedAction) return input.observedAction;
534
+ const latest = await client.getRun(input.runId).catch((error) => {
535
+ console.warn(`[daemon] failed to re-check run action run=${input.runId}: ${error instanceof Error ? error.message : String(error)}`);
536
+ return null as AgentRun | null;
537
+ });
538
+ if (latest?.status === 'running' && latest.action) return latest.action;
539
+ return null;
540
+ }
541
+
497
542
  async function ackCoveredPendingDeliveries(
498
543
  client: LockClient,
499
544
  input: {
500
545
  agent: string;
501
546
  chatId: string;
547
+ startMessageId: number;
502
548
  triggerMessageId: number;
503
549
  maxCoveredMessageId: number;
504
550
  daemonId: string;
@@ -507,12 +553,13 @@ async function ackCoveredPendingDeliveries(
507
553
  runId: string;
508
554
  },
509
555
  ): Promise<void> {
510
- if (input.maxCoveredMessageId <= input.triggerMessageId) return;
556
+ if (input.maxCoveredMessageId <= input.startMessageId) return;
511
557
  const pending = await client.listDeliveries(input.agent, 'pending', PENDING_DELIVERY_CANDIDATE_LIMIT);
512
558
  const covered = pending.filter((delivery) => (
513
559
  delivery.chat_id === input.chatId
514
- && delivery.message_id > input.triggerMessageId
560
+ && delivery.message_id > input.startMessageId
515
561
  && delivery.message_id <= input.maxCoveredMessageId
562
+ && delivery.message_id !== input.triggerMessageId
516
563
  ));
517
564
  for (const delivery of covered) {
518
565
  try {
@@ -674,6 +721,7 @@ async function executeRun(client: LockClient, delivery: MessageDelivery, message
674
721
  extraArgs: options.extraArgs,
675
722
  });
676
723
  const maxCoveredMessageId = deliveryContext?.messages.reduce((max, item) => Math.max(max, item.id), message.id) ?? message.id;
724
+ const startMessageId = deliveryContext?.startMessageId ?? message.id;
677
725
 
678
726
  try {
679
727
  const runtimeAttachments = runtimeAttachmentsForMessage({ message });
@@ -736,6 +784,56 @@ async function executeRun(client: LockClient, delivery: MessageDelivery, message
736
784
  return 'done';
737
785
  }
738
786
 
787
+ const stoppedByAction = await resolveStoppedRunActionAfterRuntime(client, {
788
+ runId: run.id,
789
+ observedAction: result.stoppedByAction,
790
+ });
791
+ if (stoppedByAction && stoppedByAction !== result.stoppedByAction) {
792
+ console.log(`${logPrefix} run=${run.id} persisted action=${stoppedByAction} observed after runtime exit`);
793
+ }
794
+
795
+ if (stoppedByAction === 'restart') {
796
+ console.log(`${logPrefix} run=${run.id} finishing with restart`);
797
+ return finishStoppedRunAction(client, {
798
+ action: 'restart',
799
+ runId: run.id,
800
+ exitCode: result.exitCode,
801
+ output: result.output,
802
+ delivery,
803
+ active: activeHandle,
804
+ daemonId: options.daemonId,
805
+ connectionId: options.connectionId,
806
+ });
807
+ }
808
+
809
+ if (stoppedByAction === 'kill') {
810
+ console.log(`${logPrefix} run=${run.id} finishing with killed`);
811
+ return finishStoppedRunAction(client, {
812
+ action: 'kill',
813
+ runId: run.id,
814
+ exitCode: result.exitCode,
815
+ output: result.output,
816
+ delivery,
817
+ active: activeHandle,
818
+ daemonId: options.daemonId,
819
+ connectionId: options.connectionId,
820
+ });
821
+ }
822
+
823
+ if (stoppedByAction === 'interrupt') {
824
+ console.log(`${logPrefix} run=${run.id} finishing with cancelled`);
825
+ return finishStoppedRunAction(client, {
826
+ action: 'interrupt',
827
+ runId: run.id,
828
+ exitCode: result.exitCode,
829
+ output: result.output,
830
+ delivery,
831
+ active: activeHandle,
832
+ daemonId: options.daemonId,
833
+ connectionId: options.connectionId,
834
+ });
835
+ }
836
+
739
837
  if (options.dryRun && result.output.trim()) {
740
838
  console.log(`${logPrefix} dry-run sending output back to chat`);
741
839
  await client.sendMessage({
@@ -747,21 +845,6 @@ async function executeRun(client: LockClient, delivery: MessageDelivery, message
747
845
  });
748
846
  }
749
847
 
750
- if (result.stoppedByAction === 'restart') {
751
- console.log(`${logPrefix} run=${run.id} finishing with restart`);
752
- await client.finishRun(run.id, { status: 'restarted', exit_code: result.exitCode, output: result.output });
753
- await failSteeredDeliveries(client, activeHandle, options, run.id, 'active run restarted before completion');
754
- return 'restart';
755
- }
756
-
757
- if (result.stoppedByAction === 'kill') {
758
- console.log(`${logPrefix} run=${run.id} finishing with killed`);
759
- await client.finishRun(run.id, { status: 'killed', exit_code: result.exitCode, output: result.output });
760
- await client.ackDelivery(delivery.id, { daemon_id: options.daemonId, connection_id: options.connectionId, claim_token: delivery.claim_token, run_id: run.id });
761
- await ackSteeredDeliveries(client, activeHandle, options, run.id);
762
- return 'done';
763
- }
764
-
765
848
  if (result.exitCode === 0) {
766
849
  console.log(`${logPrefix} run=${run.id} finishing with completed`);
767
850
  await client.finishRun(run.id, { status: 'completed', exit_code: result.exitCode, output: result.output });
@@ -769,6 +852,7 @@ async function executeRun(client: LockClient, delivery: MessageDelivery, message
769
852
  await ackCoveredPendingDeliveries(client, {
770
853
  agent: options.agent,
771
854
  chatId: message.chat_id,
855
+ startMessageId,
772
856
  triggerMessageId: message.id,
773
857
  maxCoveredMessageId,
774
858
  daemonId: options.daemonId,
@@ -1058,7 +1142,7 @@ async function reconcileManagedAgents(input: {
1058
1142
  return activatedAgents;
1059
1143
  }
1060
1144
 
1061
- async function main(): Promise<void> {
1145
+ export async function main(): Promise<void> {
1062
1146
  const rawArgs = parseArgs();
1063
1147
  // When launched as `bun run src/daemon.ts --agent foo`, the first arg is a flag,
1064
1148
  // not a subcommand. Shift it back into flags/values.
package/src/db.ts CHANGED
@@ -1448,9 +1448,9 @@ export class MessageStore {
1448
1448
  this.palHome = options.palHome ?? defaultPalHomeDir();
1449
1449
  ensureParentDir(path);
1450
1450
  this.db = new Database(path);
1451
+ this.db.exec('PRAGMA busy_timeout = 5000');
1451
1452
  this.db.exec('PRAGMA journal_mode = WAL');
1452
1453
  this.db.exec('PRAGMA foreign_keys = ON');
1453
- this.db.exec('PRAGMA busy_timeout = 5000');
1454
1454
  runMigrations(this.db);
1455
1455
  }
1456
1456
 
@@ -4646,7 +4646,7 @@ export class MessageStore {
4646
4646
  const previous = this.db.query(`
4647
4647
  SELECT COALESCE(MAX(message_id), 0) AS message_id
4648
4648
  FROM message_deliveries
4649
- WHERE agent = ? AND chat_id = ? AND message_id < ?
4649
+ WHERE agent = ? AND chat_id = ? AND message_id < ? AND status IN ('acked', 'failed', 'canceled')
4650
4650
  `).get(input.agent, room.id, trigger.id) as { message_id: number } | null;
4651
4651
  const startMessageId = Math.max(Number(cursor ?? 0), Number(previous?.message_id ?? 0), 0);
4652
4652
  const total = this.db.query(`
@@ -5294,7 +5294,7 @@ export class MessageStore {
5294
5294
  SELECT md.*
5295
5295
  FROM message_deliveries md
5296
5296
  INNER JOIN (
5297
- SELECT pending.chat_id, MIN(pending.rowid) AS first_rowid
5297
+ SELECT pending.chat_id, MAX(pending.rowid) AS first_rowid
5298
5298
  FROM message_deliveries pending
5299
5299
  WHERE pending.agent = ? AND pending.status = ?
5300
5300
  AND NOT EXISTS (
@@ -5320,7 +5320,7 @@ export class MessageStore {
5320
5320
  SELECT md.*
5321
5321
  FROM message_deliveries md
5322
5322
  INNER JOIN (
5323
- SELECT chat_id, MIN(rowid) AS first_rowid
5323
+ SELECT chat_id, MAX(rowid) AS first_rowid
5324
5324
  FROM message_deliveries
5325
5325
  WHERE agent = ? AND status = ?
5326
5326
  GROUP BY chat_id
@@ -5338,6 +5338,16 @@ export class MessageStore {
5338
5338
  return rows.map(rowToDelivery);
5339
5339
  }
5340
5340
 
5341
+ countPendingDeliveriesAfterMessage(input: { roomId: string; agent: string; afterMessageId: number }): number {
5342
+ this.releaseExpiredDeliveries();
5343
+ const row = this.db.query(`
5344
+ SELECT COUNT(*) AS count
5345
+ FROM message_deliveries
5346
+ WHERE chat_id = ? AND agent = ? AND status = 'pending' AND message_id > ?
5347
+ `).get(input.roomId, input.agent.trim(), input.afterMessageId) as { count: number } | null;
5348
+ return Number(row?.count ?? 0);
5349
+ }
5350
+
5341
5351
  listRecentDeliveriesForAgent(agent: string, limit = 20): MessageDelivery[] {
5342
5352
  this.releaseExpiredDeliveries();
5343
5353
  const rows = this.db.query(`
@@ -5429,7 +5439,7 @@ export class MessageStore {
5429
5439
  }
5430
5440
  const allRows = this.db.query(`
5431
5441
  SELECT agent FROM agent_room_subscriptions
5432
- WHERE room_id = ? AND channel_id IS NULL AND mode IN ('all', 'periodic')
5442
+ WHERE room_id = ? AND channel_id IS NULL AND mode IN ('all', 'available', 'periodic')
5433
5443
  `).all(room.id) as Array<{ agent: string }>;
5434
5444
  for (const row of allRows) candidates.add(row.agent);
5435
5445
 
@@ -5479,6 +5489,8 @@ export class MessageStore {
5479
5489
  const mode = subscription?.mode ?? 'mentions';
5480
5490
  if (mode === 'off') return false;
5481
5491
  if (direct) return true;
5492
+ const hasAnyMention = message.recipient !== null || (message.mentions ?? []).length > 0;
5493
+ if (mode === 'available') return !hasAnyMention;
5482
5494
  if (mode === 'all') return true;
5483
5495
  return false;
5484
5496
  }
@@ -5773,6 +5785,27 @@ export class MessageStore {
5773
5785
  return this.getDelivery(id)!;
5774
5786
  }
5775
5787
 
5788
+ cancelClaimedDelivery(id: string, input: FinishDeliveryInput): MessageDelivery {
5789
+ const existing = this.getDelivery(id);
5790
+ if (existing?.status === 'canceled') {
5791
+ if ((existing.connection_id ?? existing.daemon_id) === (input.connectionId ?? input.daemonId) && existing.claim_token === input.claimToken && existing.run_id === (input.runId ?? null)) {
5792
+ return existing;
5793
+ }
5794
+ throw new Error(`terminal delivery ${id} conflicts with cancel request`);
5795
+ }
5796
+ if (existing && ['acked', 'failed'].includes(existing.status)) {
5797
+ throw new Error(`terminal delivery ${id} cannot be canceled from ${existing.status}`);
5798
+ }
5799
+
5800
+ const result = this.db.query(`
5801
+ UPDATE message_deliveries
5802
+ SET status = 'canceled', run_id = COALESCE(?, run_id)
5803
+ WHERE id = ? AND status IN ('claimed', 'processing_completed') AND (connection_id = ? OR (connection_id IS NULL AND daemon_id = ?)) AND claim_token = ?
5804
+ `).run(input.runId ?? null, id, input.connectionId ?? input.daemonId, input.daemonId, input.claimToken);
5805
+ if (result.changes === 0) throw new Error(`claimed delivery ${id} was not found`);
5806
+ return this.getDelivery(id)!;
5807
+ }
5808
+
5776
5809
  ackDelivery(id: string, input: FinishDeliveryInput): MessageDelivery {
5777
5810
  const existing = this.getDelivery(id);
5778
5811
  if (existing?.status === 'acked') {
package/src/local-api.ts CHANGED
@@ -244,7 +244,7 @@ function requireMessageWritePrincipal(principal: LocalApiPrincipal, body: SendBo
244
244
  function requireAgentRoomScope(principal: LocalApiPrincipal, roomRef: string): void {
245
245
  if (principal.kind === 'daemon') return;
246
246
  if (roomRef !== principal.chatId) {
247
- throw new HttpError(403, 'LOCAL_AGENT_FORBIDDEN', 'agent-scoped token can only manage tasks in its active room by chat_id');
247
+ throw new HttpError(403, 'LOCAL_AGENT_FORBIDDEN', 'agent-scoped token can only access its active room by chat_id');
248
248
  }
249
249
  }
250
250
 
@@ -540,6 +540,17 @@ export async function localRoute(request: Request, options: LocalApiOptions): Pr
540
540
  return json({ run: await client.restartRoomAgent(roomRef, restartAgent) });
541
541
  }
542
542
 
543
+ const roomAgentInterruptMatch = pathname.match(/^\/local\/rooms\/([^/]+)\/agents\/([^/]+)\/interrupt$/);
544
+ if (request.method === 'POST' && roomAgentInterruptMatch) {
545
+ const roomRef = decodeURIComponent(roomAgentInterruptMatch[1]!);
546
+ requireAgentRoomScope(principal, roomRef);
547
+ const interruptAgent = decodeURIComponent(roomAgentInterruptMatch[2]!);
548
+ if (principal.kind === 'agent' && interruptAgent !== principal.agent) {
549
+ throw new HttpError(403, 'LOCAL_AGENT_FORBIDDEN', 'agent-scoped token can only interrupt its own runtime');
550
+ }
551
+ return json(await client.interruptRoomAgent(roomRef, interruptAgent));
552
+ }
553
+
543
554
  if (request.method === 'GET' && pathname === '/local/reminders') {
544
555
  return json({
545
556
  reminders: await client.listReminders({
@@ -92,7 +92,7 @@ export function up(db: Database): void {
92
92
  agent TEXT NOT NULL,
93
93
  room_id TEXT NOT NULL REFERENCES chats(id) ON DELETE CASCADE,
94
94
  channel_id TEXT REFERENCES room_channels(id) ON DELETE CASCADE,
95
- mode TEXT NOT NULL DEFAULT 'mentions' CHECK (mode IN ('all', 'periodic', 'mentions', 'muted', 'off')),
95
+ mode TEXT NOT NULL DEFAULT 'mentions' CHECK (mode IN ('all', 'available', 'periodic', 'mentions', 'muted', 'off')),
96
96
  created_at TEXT NOT NULL DEFAULT (datetime('now')),
97
97
  updated_at TEXT NOT NULL DEFAULT (datetime('now')),
98
98
  UNIQUE(agent, room_id, channel_id)
@@ -9,7 +9,7 @@ export function up(db: Database): void {
9
9
  id TEXT PRIMARY KEY,
10
10
  project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
11
11
  agent TEXT NOT NULL,
12
- mode TEXT NOT NULL DEFAULT 'mentions' CHECK (mode IN ('all', 'periodic', 'mentions', 'muted', 'off')),
12
+ mode TEXT NOT NULL DEFAULT 'mentions' CHECK (mode IN ('all', 'available', 'periodic', 'mentions', 'muted', 'off')),
13
13
  created_at TEXT NOT NULL DEFAULT (datetime('now')),
14
14
  updated_at TEXT NOT NULL DEFAULT (datetime('now')),
15
15
  UNIQUE(project_id, agent)
@@ -0,0 +1,78 @@
1
+ import type { Database } from 'bun:sqlite';
2
+
3
+ export const version = 46;
4
+ export const name = 'agent_run_interrupt';
5
+ export const transaction = false;
6
+
7
+ export function up(db: Database): void {
8
+ const row = db.query(`
9
+ SELECT sql
10
+ FROM sqlite_master
11
+ WHERE type = 'table' AND name = 'agent_runs'
12
+ `).get() as { sql?: string } | null;
13
+ const sql = row?.sql ?? '';
14
+ if (sql.includes("'interrupt'") && sql.includes("'cancelled'")) return;
15
+
16
+ db.exec('PRAGMA foreign_keys = OFF');
17
+ db.exec('PRAGMA legacy_alter_table = ON');
18
+ try {
19
+ db.exec(`
20
+ DROP INDEX IF EXISTS idx_agent_runs_message_id;
21
+ DROP INDEX IF EXISTS idx_agent_runs_status;
22
+ DROP INDEX IF EXISTS idx_agent_runs_session_id;
23
+ DROP INDEX IF EXISTS idx_agent_runs_delivery_id;
24
+ DROP INDEX IF EXISTS idx_agent_runs_connection_id;
25
+ DROP INDEX IF EXISTS idx_agent_runs_computer_room_active;
26
+
27
+ ALTER TABLE agent_runs RENAME TO agent_runs_legacy;
28
+
29
+ CREATE TABLE agent_runs (
30
+ id TEXT PRIMARY KEY,
31
+ message_id INTEGER NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
32
+ agent TEXT NOT NULL,
33
+ status TEXT NOT NULL CHECK (status IN ('running', 'completed', 'failed', 'killed', 'restarted', 'cancelled')),
34
+ action TEXT CHECK (action IN ('kill', 'restart', 'interrupt')),
35
+ attempt INTEGER NOT NULL DEFAULT 1,
36
+ pid INTEGER,
37
+ cwd TEXT NOT NULL,
38
+ started_at TEXT NOT NULL DEFAULT (datetime('now')),
39
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
40
+ ended_at TEXT,
41
+ exit_code INTEGER,
42
+ output TEXT NOT NULL DEFAULT '',
43
+ session_id TEXT REFERENCES agent_sessions(id) ON DELETE SET NULL,
44
+ trigger_message_id INTEGER REFERENCES messages(id) ON DELETE SET NULL,
45
+ daemon_id TEXT REFERENCES daemon_instances(id) ON DELETE SET NULL,
46
+ delivery_id TEXT REFERENCES message_deliveries(id) ON DELETE SET NULL,
47
+ computer_id TEXT REFERENCES computers(id) ON DELETE SET NULL,
48
+ connection_id TEXT REFERENCES computer_connections(id) ON DELETE SET NULL,
49
+ runtime_provider TEXT,
50
+ runtime_invocation_id TEXT
51
+ );
52
+
53
+ INSERT INTO agent_runs (
54
+ id, message_id, agent, status, action, attempt, pid, cwd, started_at, updated_at, ended_at,
55
+ exit_code, output, session_id, trigger_message_id, daemon_id, delivery_id, computer_id,
56
+ connection_id, runtime_provider, runtime_invocation_id
57
+ )
58
+ SELECT
59
+ id, message_id, agent, status, action, attempt, pid, cwd, started_at, updated_at, ended_at,
60
+ exit_code, output, session_id, trigger_message_id, daemon_id, delivery_id, computer_id,
61
+ connection_id, runtime_provider, runtime_invocation_id
62
+ FROM agent_runs_legacy;
63
+
64
+ DROP TABLE agent_runs_legacy;
65
+
66
+ CREATE INDEX IF NOT EXISTS idx_agent_runs_message_id ON agent_runs(message_id, attempt);
67
+ CREATE INDEX IF NOT EXISTS idx_agent_runs_status ON agent_runs(status, updated_at);
68
+ CREATE INDEX IF NOT EXISTS idx_agent_runs_session_id ON agent_runs(session_id, attempt);
69
+ CREATE INDEX IF NOT EXISTS idx_agent_runs_delivery_id ON agent_runs(delivery_id);
70
+ CREATE INDEX IF NOT EXISTS idx_agent_runs_connection_id ON agent_runs(connection_id);
71
+ CREATE INDEX IF NOT EXISTS idx_agent_runs_computer_room_active
72
+ ON agent_runs(computer_id, agent, status, session_id);
73
+ `);
74
+ } finally {
75
+ db.exec('PRAGMA legacy_alter_table = OFF');
76
+ db.exec('PRAGMA foreign_keys = ON');
77
+ }
78
+ }
@@ -0,0 +1,57 @@
1
+ import type { Database } from 'bun:sqlite';
2
+
3
+ export const version = 47;
4
+ export const name = 'available_receive_mode';
5
+
6
+ export function up(db: Database): void {
7
+ db.exec(`
8
+ ALTER TABLE agent_room_subscriptions RENAME TO agent_room_subscriptions_old;
9
+
10
+ CREATE TABLE agent_room_subscriptions (
11
+ id TEXT PRIMARY KEY,
12
+ agent TEXT NOT NULL,
13
+ room_id TEXT NOT NULL REFERENCES chats(id) ON DELETE CASCADE,
14
+ channel_id TEXT REFERENCES room_channels(id) ON DELETE CASCADE,
15
+ mode TEXT NOT NULL DEFAULT 'mentions' CHECK (mode IN ('all', 'available', 'periodic', 'mentions', 'muted', 'off')),
16
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
17
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
18
+ last_read_message_id INTEGER NOT NULL DEFAULT 0,
19
+ last_read_at TEXT,
20
+ UNIQUE(agent, room_id, channel_id)
21
+ );
22
+
23
+ INSERT INTO agent_room_subscriptions (
24
+ id, agent, room_id, channel_id, mode, created_at, updated_at, last_read_message_id, last_read_at
25
+ )
26
+ SELECT id, agent, room_id, channel_id, mode, created_at, updated_at, last_read_message_id, last_read_at
27
+ FROM agent_room_subscriptions_old;
28
+
29
+ DROP TABLE agent_room_subscriptions_old;
30
+
31
+ CREATE INDEX IF NOT EXISTS idx_agent_room_subscriptions_room
32
+ ON agent_room_subscriptions(room_id, mode, agent);
33
+ CREATE INDEX IF NOT EXISTS idx_agent_room_subscriptions_read_state
34
+ ON agent_room_subscriptions(agent, room_id, last_read_message_id);
35
+
36
+ ALTER TABLE project_agent_defaults RENAME TO project_agent_defaults_old;
37
+
38
+ CREATE TABLE project_agent_defaults (
39
+ id TEXT PRIMARY KEY,
40
+ project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
41
+ agent TEXT NOT NULL,
42
+ mode TEXT NOT NULL DEFAULT 'mentions' CHECK (mode IN ('all', 'available', 'periodic', 'mentions', 'muted', 'off')),
43
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
44
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
45
+ UNIQUE(project_id, agent)
46
+ );
47
+
48
+ INSERT INTO project_agent_defaults (id, project_id, agent, mode, created_at, updated_at)
49
+ SELECT id, project_id, agent, mode, created_at, updated_at
50
+ FROM project_agent_defaults_old;
51
+
52
+ DROP TABLE project_agent_defaults_old;
53
+
54
+ CREATE INDEX IF NOT EXISTS idx_project_agent_defaults_project
55
+ ON project_agent_defaults(project_id, agent);
56
+ `);
57
+ }
package/src/migrations.ts CHANGED
@@ -44,10 +44,13 @@ import * as messageAttachmentFileKind from './migrations/042_message_attachment_
44
44
  import * as roomModeSkillRegistry from './migrations/043_room_mode_skill_registry.js';
45
45
  import * as workflowRuntime from './migrations/044_workflow_runtime.js';
46
46
  import * as skillRepositoryOwnership from './migrations/045_skill_repository_ownership.js';
47
+ import * as agentRunInterrupt from './migrations/046_agent_run_interrupt.js';
48
+ import * as availableReceiveMode from './migrations/047_available_receive_mode.js';
47
49
 
48
50
  interface Migration {
49
51
  version: number;
50
52
  name: string;
53
+ transaction?: boolean;
51
54
  up(db: Database): void;
52
55
  }
53
56
 
@@ -97,6 +100,8 @@ const migrations: Migration[] = [
97
100
  roomModeSkillRegistry,
98
101
  workflowRuntime,
99
102
  skillRepositoryOwnership,
103
+ agentRunInterrupt,
104
+ availableReceiveMode,
100
105
  ].sort((a, b) => a.version - b.version);
101
106
 
102
107
  function assertContiguousMigrations(): void {
@@ -147,9 +152,14 @@ export function runMigrations(db: Database): void {
147
152
  for (const migration of migrations) {
148
153
  if (applied.has(migration.version)) continue;
149
154
 
150
- db.transaction(() => {
155
+ const applyMigration = () => {
151
156
  migration.up(db);
152
157
  markApplied(db, migration);
153
- })();
158
+ };
159
+ if (migration.transaction === false) {
160
+ applyMigration();
161
+ } else {
162
+ db.transaction(applyMigration)();
163
+ }
154
164
  }
155
165
  }
package/src/types.ts CHANGED
@@ -5,7 +5,7 @@ export type ProjectStatus = 'active' | 'archived';
5
5
  export type MessageType = 'message' | 'system';
6
6
  export type RoomProvider = 'web' | 'lark' | 'wechat';
7
7
  export type RoomTopicCapability = 'native' | 'unsupported';
8
- export type AgentRoomSubscriptionMode = 'all' | 'periodic' | 'mentions' | 'muted' | 'off';
8
+ export type AgentRoomSubscriptionMode = 'all' | 'available' | 'periodic' | 'mentions' | 'muted' | 'off';
9
9
  export type RoomSystemEventType = 'agent_invited' | 'agent_removed' | 'agent_receive_mode_changed';
10
10
  export type RoomSystemEventActorKind = 'user' | 'agent' | 'system';
11
11
  export type PalIdentityKind = 'user' | 'bot' | 'agent' | 'room';
@@ -181,8 +181,8 @@ export interface MessageAttachment extends MessageAttachmentMetadata {
181
181
  source_ref: string | null;
182
182
  }
183
183
 
184
- export type RunStatus = 'running' | 'completed' | 'failed' | 'killed' | 'restarted';
185
- export type RunAction = 'kill' | 'restart';
184
+ export type RunStatus = 'running' | 'completed' | 'failed' | 'killed' | 'restarted' | 'cancelled';
185
+ export type RunAction = 'kill' | 'restart' | 'interrupt';
186
186
  export type DeliveryStatus = 'pending' | 'claimed' | 'processing_completed' | 'acked' | 'failed' | 'canceled';
187
187
  export type HeldDraftStatus = 'held' | 'abandoned' | 'sent_anyway';
188
188
  export type RoomTaskStatus = 'todo' | 'in_progress' | 'in_review' | 'done' | 'closed';