macro-agent 0.0.17 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -15,6 +15,22 @@ import type { AgentId } from "../../store/types/index.js";
15
15
  import { SessionMapper } from "../../acp/session-mapper.js";
16
16
  import type { ACPSessionId } from "../../acp/types.js";
17
17
 
18
+ // ─────────────────────────────────────────────────────────────────
19
+ // Helpers
20
+ // ─────────────────────────────────────────────────────────────────
21
+
22
+ /** Extract a plain-text output string from `rawOutput` (string | ContentBlock[] | undefined). */
23
+ function extractToolOutput(rawOutput: unknown): string | undefined {
24
+ if (typeof rawOutput === "string") return rawOutput;
25
+ if (Array.isArray(rawOutput)) {
26
+ return rawOutput
27
+ .filter((item: any) => item.type === "text" && typeof item.text === "string")
28
+ .map((item: any) => item.text as string)
29
+ .join("\n") || undefined;
30
+ }
31
+ return undefined;
32
+ }
33
+
18
34
  // ─────────────────────────────────────────────────────────────────
19
35
  // Types
20
36
  // ─────────────────────────────────────────────────────────────────
@@ -79,6 +95,20 @@ export class ACPOverMAPHandler {
79
95
  this.defaultCwd = config.defaultCwd ?? process.cwd();
80
96
  }
81
97
 
98
+ /**
99
+ * Abort all active streams targeting a specific agent.
100
+ * Called when an agent is stopped via MAP protocol to interrupt
101
+ * any in-progress ACP prompt streaming.
102
+ */
103
+ abortStreamsForAgent(agentId: AgentId): void {
104
+ for (const [streamId, streamState] of this.streams) {
105
+ if (streamState.agentId === agentId) {
106
+ console.error(`[ACP-over-MAP] Aborting stream ${streamId} for stopped agent ${agentId}`);
107
+ streamState.abortController.abort();
108
+ }
109
+ }
110
+ }
111
+
82
112
  /**
83
113
  * Process an ACP request and return the response.
84
114
  * @param targetAgentId - Target agent for the request
@@ -386,11 +416,17 @@ export class ACPOverMAPHandler {
386
416
  this.ensureConversation(sessionId as ACPSessionId, agentId);
387
417
 
388
418
  // Accumulate response content for history recording
389
- const buffer: { textChunks: string[]; toolCalls: Record<string, unknown>[] } = {
390
- textChunks: [],
391
- toolCalls: [],
419
+ const buffer: { parts: Array<{ type: "text"; text: string } | ({ type: "tool" } & Record<string, unknown>)> } = {
420
+ parts: [],
392
421
  };
393
422
 
423
+ // Track latest plan for persistence
424
+ let latestPlan: Array<{ content: string; priority: string; status: string }> | null = null;
425
+
426
+ // Track tool info from initial tool_call events (title, name, input)
427
+ // so we can merge them when tool_call_update arrives with status "completed"
428
+ const toolInfoCache = new Map<string, { title?: string; name?: string; input?: unknown }>();
429
+
394
430
  try {
395
431
  // Stream responses from the agent
396
432
  let updateCount = 0;
@@ -400,23 +436,49 @@ export class ACPOverMAPHandler {
400
436
  return { stopReason: "cancelled" };
401
437
  }
402
438
 
403
- // Accumulate content for history persistence
439
+ // Accumulate content for history persistence (preserving text/tool interleaving order)
404
440
  const u = update as Record<string, unknown>;
405
441
  const updateType = u.sessionUpdate as string ?? u.type as string;
406
442
  if (updateType === "agent_message_chunk") {
407
443
  const content = u.content as { type?: string; text?: string } | undefined;
408
444
  if (content?.text) {
409
- buffer.textChunks.push(content.text);
445
+ const last = buffer.parts[buffer.parts.length - 1];
446
+ if (last && last.type === "text") {
447
+ last.text += content.text;
448
+ } else {
449
+ buffer.parts.push({ type: "text", text: content.text });
450
+ }
451
+ }
452
+ } else if (updateType === "plan") {
453
+ const entries = (u as { entries?: Array<{ content: string; priority: string; status: string }> }).entries;
454
+ if (entries) {
455
+ latestPlan = entries;
410
456
  }
411
457
  } else if (updateType === "tool_call" || updateType === "tool_call_update") {
458
+ const toolCallId = u.toolCallId as string | undefined;
412
459
  const status = u.status as string | undefined;
413
- if (status === "completed" || (updateType === "tool_call" && status !== "running")) {
414
- buffer.toolCalls.push({
415
- toolCallId: u.toolCallId,
416
- title: u.title,
417
- status: u.status,
460
+ const meta = u._meta as { claudeCode?: { toolName?: string } } | undefined;
461
+
462
+ // Cache tool info from initial tool_call events
463
+ if (updateType === "tool_call" && toolCallId) {
464
+ toolInfoCache.set(toolCallId, {
465
+ title: u.title as string | undefined,
466
+ name: meta?.claudeCode?.toolName,
418
467
  input: u.rawInput,
419
- output: u.output,
468
+ });
469
+ }
470
+
471
+ if (status === "completed" || status === "failed") {
472
+ // Merge cached info for tool_call_update events that lack title/input
473
+ const cached = toolCallId ? toolInfoCache.get(toolCallId) : undefined;
474
+ buffer.parts.push({
475
+ type: "tool",
476
+ toolCallId,
477
+ title: u.title ?? cached?.title,
478
+ name: meta?.claudeCode?.toolName ?? cached?.name,
479
+ status: u.status,
480
+ input: u.rawInput ?? cached?.input,
481
+ output: extractToolOutput(u.rawOutput),
420
482
  });
421
483
  }
422
484
  }
@@ -426,15 +488,24 @@ export class ACPOverMAPHandler {
426
488
  updateCount++;
427
489
  }
428
490
 
429
- console.error(`[ACP-over-MAP] Prompt completed for agent ${agentId}, ${updateCount} updates`);
491
+ // Check if the loop ended because of cancellation
492
+ const wasCancelled = streamState.abortController.signal.aborted;
493
+ const stopReason = wasCancelled ? "cancelled" : "end_turn";
494
+
495
+ console.error(`[ACP-over-MAP] Prompt completed for agent ${agentId}, ${updateCount} updates, stopReason=${stopReason}`);
430
496
 
431
497
  // Persist conversation turns for history
432
498
  this.recordPromptTurns(sessionId as ACPSessionId, agentId, messageContent, buffer);
433
499
 
500
+ // Persist latest plan in EventStore for history loading across restarts
501
+ if (latestPlan) {
502
+ this.eventStore.updateAgentPlan(agentId as AgentId, latestPlan);
503
+ }
504
+
434
505
  // Emit updated session info after prompt completes
435
506
  this.emitSessionInfo(streamState, sessionId, emitNotification);
436
507
 
437
- return { stopReason: "end_turn" };
508
+ return { stopReason };
438
509
  } catch (error) {
439
510
  console.error(`[ACP-over-MAP] Prompt error:`, error);
440
511
  return {
@@ -455,29 +526,33 @@ export class ACPOverMAPHandler {
455
526
  // Prefer server's resolved session ID over client's potentially stale one
456
527
  const sessionId = streamState.sessionId ?? paramSessionId ?? sessionIdFromContext;
457
528
 
458
- // Signal cancellation
459
- streamState.abortController.abort();
529
+ const agentId = sessionId
530
+ ? this.sessionMapper.getAgentId(sessionId as ACPSessionId)
531
+ : streamState.agentId;
460
532
 
461
- if (!sessionId) {
462
- return { cancelled: true };
463
- }
533
+ console.error(
534
+ `[ACP-over-MAP] Cancel - streamId=${streamState.streamId} sessionId=${sessionId} agentId=${agentId}`,
535
+ );
464
536
 
465
- // Get the mapped agent
466
- const agentId = this.sessionMapper.getAgentId(sessionId as ACPSessionId);
467
- if (!agentId) {
468
- return { cancelled: true };
469
- }
537
+ // 1. Abort the for-await loop in handlePrompt so it stops yielding updates
538
+ streamState.abortController.abort();
470
539
 
471
- // Terminate the agent
472
- try {
473
- await this.agentManager.terminate(agentId, "cancelled");
474
- } catch (error) {
475
- console.warn(`[ACP-over-MAP] Error terminating agent ${agentId}:`, error);
540
+ // 2. Cancel the agent's active session (sends session/cancel to the subprocess)
541
+ // This does NOT terminate the agent — it only interrupts the current prompt.
542
+ // The subprocess stays alive and can accept new prompts.
543
+ // Use map/agents/stop for full agent termination.
544
+ if (agentId) {
545
+ const session = this.agentManager.getSession(agentId);
546
+ if (session) {
547
+ try {
548
+ await session.cancel();
549
+ console.error(`[ACP-over-MAP] Session cancelled for agent ${agentId}`);
550
+ } catch (error) {
551
+ console.warn(`[ACP-over-MAP] session.cancel() failed for ${agentId}:`, error);
552
+ }
553
+ }
476
554
  }
477
555
 
478
- // Clean up
479
- this.sessionMapper.removeMapping(sessionId as ACPSessionId);
480
-
481
556
  return { cancelled: true };
482
557
  }
483
558
 
@@ -605,6 +680,11 @@ export class ACPOverMAPHandler {
605
680
  order: "asc",
606
681
  limit: limit ?? 200,
607
682
  });
683
+
684
+ // Include persisted plan and agent cwd if available
685
+ const agent = historyAgentId ? this.eventStore.getAgent(historyAgentId as AgentId) : undefined;
686
+ const plan = agent?.plan ?? [];
687
+
608
688
  return {
609
689
  turns: turns.map((turn) => ({
610
690
  role:
@@ -614,6 +694,8 @@ export class ACPOverMAPHandler {
614
694
  timestamp: turn.timestamp,
615
695
  content: turn.content,
616
696
  })),
697
+ plan,
698
+ cwd: agent?.cwd ?? null,
617
699
  };
618
700
  }
619
701
 
@@ -668,7 +750,7 @@ export class ACPOverMAPHandler {
668
750
  acpSessionId: ACPSessionId,
669
751
  agentId: AgentId,
670
752
  userMessage: string,
671
- buffer: { textChunks: string[]; toolCalls: Record<string, unknown>[] },
753
+ buffer: { parts: Array<{ type: "text"; text: string } | ({ type: "tool" } & Record<string, unknown>)> },
672
754
  ): void {
673
755
  const now = Date.now();
674
756
 
@@ -691,17 +773,8 @@ export class ACPOverMAPHandler {
691
773
  });
692
774
  }
693
775
 
694
- // Record assistant turn with accumulated content
695
- const assistantText = buffer.textChunks.join("");
696
- const parts: unknown[] = [];
697
-
698
- if (assistantText) {
699
- parts.push({ type: "text", text: assistantText });
700
- }
701
-
702
- for (const tool of buffer.toolCalls) {
703
- parts.push({ type: "tool", ...tool });
704
- }
776
+ // Record assistant turn with accumulated content (parts already in order)
777
+ const parts = buffer.parts;
705
778
 
706
779
  if (parts.length > 0) {
707
780
  this.eventStore.emit({
@@ -65,6 +65,7 @@ import {
65
65
  type ScopeId,
66
66
  } from "../types.js";
67
67
  import type { AgentId } from "../../store/types/index.js";
68
+ import type { AgentStopReason } from "../../agent/types.js";
68
69
  import {
69
70
  createConnectionManager,
70
71
  type ConnectionManager,
@@ -712,6 +713,10 @@ export class MAPAdapterImpl implements MAPAdapter {
712
713
  "map/agents/get": async (params) =>
713
714
  this.handleGetAgent(participantId, params),
714
715
 
716
+ // Agent lifecycle
717
+ "map/agents/stop": async (params) =>
718
+ this.handleStopAgent(participantId, params),
719
+
715
720
  // Scope queries
716
721
  "map/scopes/list": async () => this.handleListScopes(participantId),
717
722
  "map/scopes/get": async (params) =>
@@ -764,6 +769,7 @@ export class MAPAdapterImpl implements MAPAdapter {
764
769
  "map/send": "canMessage",
765
770
  "map/agents/list": "canQuery",
766
771
  "map/agents/get": "canQuery",
772
+ "map/agents/stop": "canStop",
767
773
  "map/scopes/list": "canQuery",
768
774
  "map/scopes/get": "canQuery",
769
775
  "map/scopes/create": "canManageScopes",
@@ -808,16 +814,27 @@ export class MAPAdapterImpl implements MAPAdapter {
808
814
  break;
809
815
  }
810
816
 
811
- const result = await session.rpcHandler.process(value, {
812
- participantId: session.participantId,
813
- capabilities: participant.capabilities,
814
- signal: session.abortController.signal,
817
+ // Process messages concurrently (same pattern as toad reference).
818
+ // The message loop must not block on any handler, otherwise
819
+ // session/cancel can't be delivered while session/prompt is running.
820
+ // The stream writer queues writes safely, so concurrent responses
821
+ // don't interleave.
822
+ const processAndRespond = async () => {
823
+ const result = await session.rpcHandler.process(value, {
824
+ participantId: session.participantId,
825
+ capabilities: participant.capabilities,
826
+ signal: session.abortController.signal,
827
+ });
828
+
829
+ if (result.type === "response") {
830
+ await this.sendToSession(session, result.response);
831
+ }
832
+ };
833
+ processAndRespond().catch((err) => {
834
+ if (!session.abortController.signal.aborted) {
835
+ console.error(`[MAPAdapter] Error processing message:`, err);
836
+ }
815
837
  });
816
-
817
- // Send response if needed
818
- if (result.type === "response") {
819
- await this.sendToSession(session, result.response);
820
- }
821
838
  }
822
839
  } catch (error) {
823
840
  if (!session.abortController.signal.aborted) {
@@ -1181,6 +1198,59 @@ export class MAPAdapterImpl implements MAPAdapter {
1181
1198
  return { agent: agent ?? null };
1182
1199
  }
1183
1200
 
1201
+ private async handleStopAgent(
1202
+ _participantId: ParticipantId,
1203
+ params: unknown,
1204
+ ): Promise<{ stopping: boolean; agent?: AgentInfo }> {
1205
+ const { agentId, reason, force } = (params as {
1206
+ agentId?: AgentId;
1207
+ reason?: string;
1208
+ force?: boolean;
1209
+ }) ?? {};
1210
+
1211
+ if (!agentId) {
1212
+ throw RPCError.invalidParams("agentId required");
1213
+ }
1214
+
1215
+ if (!this.services.agentManager) {
1216
+ throw RPCError.internalError("Agent manager not available");
1217
+ }
1218
+
1219
+ // Abort any active ACP streams for this agent
1220
+ if (this.acpOverMapHandler) {
1221
+ this.acpOverMapHandler.abortStreamsForAgent(agentId);
1222
+ }
1223
+
1224
+ // Terminate the agent
1225
+ try {
1226
+ await this.services.agentManager.terminate(
1227
+ agentId,
1228
+ (reason ?? "cancelled") as AgentStopReason,
1229
+ );
1230
+ } catch (error) {
1231
+ console.error(`[MAPAdapter] Error stopping agent ${agentId}:`, error);
1232
+ throw RPCError.internalError(
1233
+ `Failed to stop agent: ${error instanceof Error ? error.message : String(error)}`,
1234
+ );
1235
+ }
1236
+
1237
+ // Emit agent state changed event for subscribers
1238
+ this.emitEvent({
1239
+ eventId: `stop-${Date.now()}`,
1240
+ type: "agent.state.changed" as MAPEventType,
1241
+ timestamp: Date.now(),
1242
+ agentId,
1243
+ data: {
1244
+ agentId,
1245
+ current: "stopped",
1246
+ previous: "running",
1247
+ reason: reason ?? "cancelled",
1248
+ },
1249
+ });
1250
+
1251
+ return { stopping: true };
1252
+ }
1253
+
1184
1254
  private async handleListScopes(
1185
1255
  participantId: ParticipantId,
1186
1256
  ): Promise<{ scopes: ScopeInfo[] }> {
@@ -1056,6 +1056,46 @@ describe('Event Archival', () => {
1056
1056
  await store1.close();
1057
1057
  await store2.close();
1058
1058
  });
1059
+
1060
+ it('should preserve agent plan across reload (rebuildViews)', async () => {
1061
+ const planStore = await createEventStore({
1062
+ baseDir: testDir,
1063
+ instanceId: 'plan-reload-test',
1064
+ });
1065
+
1066
+ // Spawn an agent
1067
+ planStore.emit({
1068
+ type: 'spawn',
1069
+ source: { agent_id: 'agent_plan' },
1070
+ payload: {
1071
+ agent_id: 'agent_plan',
1072
+ session_id: 'sess_plan',
1073
+ task: 'test plan persistence',
1074
+ },
1075
+ });
1076
+
1077
+ // Set plan via updateAgentPlan
1078
+ const plan = [
1079
+ { content: 'Read files', priority: 'high', status: 'completed' },
1080
+ { content: 'Write code', priority: 'medium', status: 'in_progress' },
1081
+ ];
1082
+ planStore.updateAgentPlan('agent_plan' as AgentId, plan);
1083
+
1084
+ // Verify plan is set
1085
+ const before = planStore.getAgent('agent_plan' as AgentId);
1086
+ expect(before?.plan).toEqual(plan);
1087
+
1088
+ // Persist, then reload (simulates server restart: load from SQLite + rebuildViews)
1089
+ await planStore.persist();
1090
+ await planStore.reload();
1091
+
1092
+ // Plan should survive the reload
1093
+ const after = planStore.getAgent('agent_plan' as AgentId);
1094
+ expect(after).toBeDefined();
1095
+ expect(after?.plan).toEqual(plan);
1096
+
1097
+ await planStore.close();
1098
+ });
1059
1099
  });
1060
1100
  });
1061
1101
 
@@ -203,6 +203,7 @@ export interface EventStore {
203
203
  // Agent view
204
204
  getAgent(agentId: AgentId): Agent | null;
205
205
  listAgents(filter?: { state?: AgentState; parent?: AgentId | null }): Agent[];
206
+ updateAgentPlan(agentId: AgentId, plan: Array<{ content: string; priority: string; status: string }>): void;
206
207
 
207
208
  // Task view
208
209
  getTask(taskId: TaskId): Task | null;
@@ -519,6 +520,25 @@ export async function createEventStore(config: StoreConfig = {}): Promise<EventS
519
520
  return agents;
520
521
  }
521
522
 
523
+ /**
524
+ * Update an agent's plan entries (persisted to SQLite via TinyBase)
525
+ */
526
+ function updateAgentPlan(
527
+ agentId: AgentId,
528
+ plan: Array<{ content: string; priority: string; status: string }>,
529
+ ): void {
530
+ const row = store.getRow('agents', agentId);
531
+ if (!row.id) return;
532
+
533
+ store.setPartialRow('agents', agentId, {
534
+ plan: JSON.stringify(plan),
535
+ last_activity_at: Date.now(),
536
+ });
537
+
538
+ const agent = rowToAgent(store.getRow('agents', agentId));
539
+ notifyAgentChange(agentId, agent);
540
+ }
541
+
522
542
  /**
523
543
  * Get task by ID
524
544
  */
@@ -1203,6 +1223,7 @@ export async function createEventStore(config: StoreConfig = {}): Promise<EventS
1203
1223
  // Views
1204
1224
  getAgent,
1205
1225
  listAgents,
1226
+ updateAgentPlan,
1206
1227
  getTask,
1207
1228
  listTasks,
1208
1229
  getMessages,
@@ -1269,6 +1290,17 @@ function initializeTables(store: Store): void {
1269
1290
  * Rebuild materialized views from the event log
1270
1291
  */
1271
1292
  function rebuildViews(store: Store): void {
1293
+ // Preserve out-of-band agent fields that aren't derived from events.
1294
+ // `plan` is written directly via updateAgentPlan(), not through events,
1295
+ // so it would be lost when we clear and replay.
1296
+ const savedAgentPlan = new Map<string, string>();
1297
+ for (const rowId of store.getRowIds('agents')) {
1298
+ const row = store.getRow('agents', rowId);
1299
+ if (row.plan && row.plan !== '[]') {
1300
+ savedAgentPlan.set(rowId, row.plan as string);
1301
+ }
1302
+ }
1303
+
1272
1304
  // Clear existing views
1273
1305
  for (const rowId of store.getRowIds('agents')) {
1274
1306
  store.delRow('agents', rowId);
@@ -1324,6 +1356,14 @@ function rebuildViews(store: Store): void {
1324
1356
  for (const event of events) {
1325
1357
  applyEventToViews(store, event, noop, noop, noop, noop, noop, noop);
1326
1358
  }
1359
+
1360
+ // Restore out-of-band agent fields preserved before the wipe
1361
+ for (const [agentId, plan] of savedAgentPlan) {
1362
+ const row = store.getRow('agents', agentId);
1363
+ if (row.id) {
1364
+ store.setPartialRow('agents', agentId, { plan });
1365
+ }
1366
+ }
1327
1367
  }
1328
1368
 
1329
1369
  /**
@@ -1422,6 +1462,7 @@ function applySpawnEvent(
1422
1462
  role: payload.role ?? '',
1423
1463
  config: JSON.stringify(payload.config ?? {}),
1424
1464
  cwd: payload.cwd ?? process.cwd(),
1465
+ plan: '[]',
1425
1466
  created_at: event.timestamp,
1426
1467
  started_at: 0,
1427
1468
  stopped_at: 0,
@@ -1770,6 +1811,7 @@ function rowToAgent(row: Record<string, unknown>): Agent {
1770
1811
  role: (row.role as string) || undefined,
1771
1812
  config: row.config ? JSON.parse(row.config as string) : {},
1772
1813
  cwd: (row.cwd as string) || process.cwd(),
1814
+ plan: row.plan ? JSON.parse(row.plan as string) : [],
1773
1815
  created_at: row.created_at as Timestamp,
1774
1816
  started_at: (row.started_at as number) || undefined,
1775
1817
  stopped_at: (row.stopped_at as number) || undefined,
@@ -40,6 +40,7 @@ export interface Agent {
40
40
  role?: string;
41
41
  config: AgentConfig;
42
42
  cwd: string;
43
+ plan: Array<{ content: string; priority: string; status: string }>;
43
44
  created_at: Timestamp;
44
45
  started_at?: Timestamp;
45
46
  stopped_at?: Timestamp;