@alexgorbatchev/pi-agentation 3.0.0 → 3.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.
package/agentation.ts CHANGED
@@ -1,12 +1,25 @@
1
- import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
1
+ import { DynamicBorder, type ExtensionAPI, type ExtensionContext } from "@mariozechner/pi-coding-agent";
2
+ import { Container, Text } from "@mariozechner/pi-tui";
3
+ import fs from "node:fs";
4
+ import path from "node:path";
2
5
  import process from "node:process";
3
6
 
4
- const LOOP_SKILL_NAME = "agentation-fix-loop";
5
- const LOOP_PROMPT = `/skill:${LOOP_SKILL_NAME}`;
7
+ const AGENTATION_SKILL_NAME = "agentation";
8
+ const AGENTATION_SKILL_PROMPT = `/skill:${AGENTATION_SKILL_NAME}`;
9
+ const AGENTATION_EXECUTABLE_ENV_NAME = "PI_AGENTATION_AGENTATION_BIN";
6
10
  const PROJECT_SELECTION_ENTRY_TYPE = "agentation-project-selection";
11
+ const BATCH_CONTEXT_MESSAGE_TYPE = "agentation-batch-context";
7
12
  const PROJECT_ID_PATTERN = /^projectId=(?:"([^"\r\n]+)"|'([^'\r\n]+)')$/;
13
+ const AGENTATION_SKILL_INVOCATION_PATTERN = new RegExp(`^${AGENTATION_SKILL_PROMPT.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?:\\s+(.+))?$`);
14
+ const AGENTATION_ACTION_PATTERN = /\bagentation\s+(ack|resolve|reply|dismiss)\s+([^\s]+)/g;
15
+ const WATCH_TIMEOUT_SECONDS = "300";
16
+ const WATCH_BATCH_WINDOW = "10";
17
+ const LOOP_IDLE_WAIT_MS = 500;
18
+ const WATCH_RETRY_DELAY_MS = 5_000;
8
19
 
9
20
  type ExecResult = Awaited<ReturnType<ExtensionAPI["exec"]>>;
21
+ type AgentationAction = "ack" | "resolve" | "reply" | "dismiss";
22
+ type BatchSource = "pending" | "watch";
10
23
 
11
24
  type CommandOutcome = {
12
25
  code: number | undefined;
@@ -20,9 +33,76 @@ interface IProjectSelectionData {
20
33
  projectId: string;
21
34
  }
22
35
 
36
+ interface IAgentationAnnotation extends Record<string, unknown> {
37
+ id: string;
38
+ }
39
+
40
+ interface IAgentationBatchResponse {
41
+ annotations: IAgentationAnnotation[];
42
+ count: number;
43
+ timeout?: boolean;
44
+ }
45
+
46
+ interface IAgentationWatchTimeoutResponse {
47
+ message?: string;
48
+ timeout: true;
49
+ }
50
+
51
+ type AgentationPollResponse = IAgentationBatchResponse | IAgentationWatchTimeoutResponse;
52
+
53
+ interface IAnnotationProgress {
54
+ id: string;
55
+ isHandled: boolean;
56
+ lastAction?: AgentationAction;
57
+ wasAcknowledged: boolean;
58
+ }
59
+
60
+ interface IActiveBatch {
61
+ annotations: IAgentationAnnotation[];
62
+ progressById: Map<string, IAnnotationProgress>;
63
+ projectId: string;
64
+ rawJson: string;
65
+ source: BatchSource;
66
+ }
67
+
68
+ type AgentationUiPhase = "error" | "initializing" | "processing" | "watching";
69
+
70
+ interface IAgentationUiState {
71
+ annotationCount?: number;
72
+ detail: string;
73
+ phase: AgentationUiPhase;
74
+ projectId: string | null;
75
+ source?: BatchSource;
76
+ }
77
+
78
+ interface ILoopRuntimeState {
79
+ activeBatch: IActiveBatch | null;
80
+ agentationExecutablePath: string | null;
81
+ currentContext: ExtensionContext | null;
82
+ currentProjectId: string | null;
83
+ hasNotifiedIncompleteBatch: boolean;
84
+ isLoopEnabled: boolean;
85
+ pendingLoopInvocationProjectId: string | null;
86
+ uiState: IAgentationUiState | null;
87
+ watchAbortController: AbortController | null;
88
+ watchGeneration: number;
89
+ watchTask: Promise<void> | null;
90
+ }
91
+
23
92
  export default function agentation(pi: ExtensionAPI): void {
24
- let currentProjectId: string | null = null;
25
- let isLoopEnabled = true;
93
+ const runtimeState: ILoopRuntimeState = {
94
+ activeBatch: null,
95
+ agentationExecutablePath: null,
96
+ currentContext: null,
97
+ currentProjectId: null,
98
+ hasNotifiedIncompleteBatch: false,
99
+ isLoopEnabled: true,
100
+ pendingLoopInvocationProjectId: null,
101
+ uiState: null,
102
+ watchAbortController: null,
103
+ watchGeneration: 0,
104
+ watchTask: null,
105
+ };
26
106
 
27
107
  const isLoopSkillAvailable = (): boolean => {
28
108
  return pi.getCommands().some((command) => {
@@ -30,18 +110,81 @@ export default function agentation(pi: ExtensionAPI): void {
30
110
  return false;
31
111
  }
32
112
 
33
- return command.name === LOOP_SKILL_NAME || command.name === `skill:${LOOP_SKILL_NAME}`;
113
+ return command.name === AGENTATION_SKILL_NAME || command.name === `skill:${AGENTATION_SKILL_NAME}`;
34
114
  });
35
115
  };
36
116
 
37
- const reportError = (ctx: ExtensionContext, message: string): void => {
117
+ const setCurrentContext = (ctx: ExtensionContext): void => {
118
+ runtimeState.currentContext = ctx;
119
+ };
120
+
121
+ const clearUiState = (ctx: ExtensionContext | null): void => {
122
+ runtimeState.uiState = null;
123
+ if (ctx === null || !ctx.hasUI) {
124
+ return;
125
+ }
126
+
127
+ ctx.ui.setStatus("agentation", undefined);
128
+ ctx.ui.setWidget("agentation", undefined);
129
+ };
130
+
131
+ const setUiState = (ctx: ExtensionContext | null, uiState: IAgentationUiState): void => {
132
+ runtimeState.uiState = uiState;
133
+ if (ctx === null || !ctx.hasUI) {
134
+ return;
135
+ }
136
+
137
+ const statusText = formatWidgetTitle(uiState);
138
+
139
+ ctx.ui.setWidget("agentation", (_tui, theme) => {
140
+ const borderColorName = getUiPhaseColorName(uiState.phase);
141
+ const borderColor = (text: string): string => theme.fg(borderColorName, text);
142
+ const container = new Container();
143
+ const titleText = borderColor(theme.bold(statusText));
144
+
145
+ container.addChild(new DynamicBorder(borderColor));
146
+ container.addChild(new Text(titleText, 1, 0));
147
+ container.addChild(new DynamicBorder(borderColor));
148
+ return container;
149
+ });
150
+ };
151
+
152
+ const reportError = (ctx: ExtensionContext | null, message: string): void => {
38
153
  console.error(message);
39
- ctx.ui.notify(message, "error");
154
+ setUiState(ctx, {
155
+ detail: message,
156
+ phase: "error",
157
+ projectId: runtimeState.currentProjectId,
158
+ source: runtimeState.activeBatch?.source,
159
+ });
160
+ ctx?.ui.notify(message, "error");
161
+ };
162
+
163
+ const stopWatchLoop = (): void => {
164
+ runtimeState.watchGeneration += 1;
165
+ runtimeState.watchAbortController?.abort();
166
+ runtimeState.watchAbortController = null;
167
+ runtimeState.watchTask = null;
168
+ };
169
+
170
+ const resetRuntimeStateForSession = (ctx: ExtensionContext): void => {
171
+ clearUiState(runtimeState.currentContext);
172
+ stopWatchLoop();
173
+ setCurrentContext(ctx);
174
+ runtimeState.activeBatch = null;
175
+ runtimeState.agentationExecutablePath = resolveAgentationExecutablePath(ctx.cwd);
176
+ runtimeState.currentProjectId = restoreProjectSelection(ctx);
177
+ runtimeState.hasNotifiedIncompleteBatch = false;
178
+ runtimeState.isLoopEnabled = true;
179
+ runtimeState.pendingLoopInvocationProjectId = null;
40
180
  };
41
181
 
42
182
  const shutdownForFatalError = (ctx: ExtensionContext, message: string): void => {
43
- currentProjectId = null;
44
- isLoopEnabled = false;
183
+ stopWatchLoop();
184
+ runtimeState.activeBatch = null;
185
+ runtimeState.currentProjectId = null;
186
+ runtimeState.isLoopEnabled = false;
187
+ runtimeState.pendingLoopInvocationProjectId = null;
45
188
  process.exitCode = 1;
46
189
  reportError(ctx, message);
47
190
  ctx.shutdown();
@@ -52,30 +195,16 @@ export default function agentation(pi: ExtensionAPI): void {
52
195
  return true;
53
196
  }
54
197
 
55
- shutdownForFatalError(ctx, `Missing required skill ${LOOP_PROMPT}. Exiting.`);
198
+ shutdownForFatalError(ctx, `Missing required skill ${AGENTATION_SKILL_PROMPT}. Exiting.`);
56
199
  return false;
57
200
  };
58
201
 
59
- const queueLoopPrompt = (projectId: string, deliverAsFollowUp: boolean): void => {
60
- if (!isLoopEnabled) {
61
- return;
62
- }
63
-
64
- const loopPrompt = `${LOOP_PROMPT} ${projectId}`;
65
- if (deliverAsFollowUp) {
66
- pi.sendUserMessage(loopPrompt, { deliverAs: "followUp" });
67
- return;
68
- }
69
-
70
- pi.sendUserMessage(loopPrompt);
71
- };
72
-
73
202
  const persistProjectSelection = (projectId: string): void => {
74
- if (currentProjectId === projectId) {
203
+ if (runtimeState.currentProjectId === projectId) {
75
204
  return;
76
205
  }
77
206
 
78
- currentProjectId = projectId;
207
+ runtimeState.currentProjectId = projectId;
79
208
  pi.appendEntry(PROJECT_SELECTION_ENTRY_TYPE, { projectId });
80
209
  };
81
210
 
@@ -103,13 +232,12 @@ export default function agentation(pi: ExtensionAPI): void {
103
232
  }
104
233
  }
105
234
 
106
- currentProjectId = latestProjectId;
107
235
  return latestProjectId;
108
236
  };
109
237
 
110
- const execCommand = async (command: string, args: string[]): Promise<CommandOutcome> => {
238
+ const execCommand = async (command: string, args: string[], signal?: AbortSignal): Promise<CommandOutcome> => {
111
239
  try {
112
- const result: ExecResult = await pi.exec(command, args);
240
+ const result: ExecResult = await pi.exec(command, args, { signal });
113
241
  return {
114
242
  code: result.code,
115
243
  killed: result.killed,
@@ -127,11 +255,16 @@ export default function agentation(pi: ExtensionAPI): void {
127
255
  }
128
256
  };
129
257
 
258
+ const execAgentationCommand = async (args: string[], signal?: AbortSignal): Promise<CommandOutcome> => {
259
+ const agentationExecutablePath = runtimeState.agentationExecutablePath ?? "agentation";
260
+ return execCommand(agentationExecutablePath, args, signal);
261
+ };
262
+
130
263
  const listKnownProjectIds = async (): Promise<{ errorMessage?: string; projectIds: string[] }> => {
131
- let projectsResult = await execCommand("agentation", ["projects", "--json"]);
264
+ let projectsResult = await execAgentationCommand(["projects", "--json"]);
132
265
  if (!didCommandSucceed(projectsResult)) {
133
- await execCommand("agentation", ["start", "--background"]);
134
- projectsResult = await execCommand("agentation", ["projects", "--json"]);
266
+ await execAgentationCommand(["start", "--background"]);
267
+ projectsResult = await execAgentationCommand(["projects", "--json"]);
135
268
  }
136
269
 
137
270
  if (!didCommandSucceed(projectsResult)) {
@@ -233,18 +366,253 @@ export default function agentation(pi: ExtensionAPI): void {
233
366
  return selectedProjectId;
234
367
  };
235
368
 
236
- const initializeLoopForSession = async (ctx: ExtensionContext): Promise<void> => {
237
- restoreProjectSelection(ctx);
369
+ const clearActiveBatch = (): void => {
370
+ runtimeState.activeBatch = null;
371
+ runtimeState.hasNotifiedIncompleteBatch = false;
372
+ };
238
373
 
239
- if (!isLoopEnabled) {
374
+ const createActiveBatch = (
375
+ projectId: string,
376
+ source: BatchSource,
377
+ batchResponse: IAgentationBatchResponse
378
+ ): IActiveBatch => {
379
+ const progressById = new Map<string, IAnnotationProgress>();
380
+ for (const annotation of batchResponse.annotations) {
381
+ progressById.set(annotation.id, {
382
+ id: annotation.id,
383
+ isHandled: false,
384
+ wasAcknowledged: false,
385
+ });
386
+ }
387
+
388
+ return {
389
+ annotations: batchResponse.annotations,
390
+ progressById,
391
+ projectId,
392
+ rawJson: JSON.stringify(batchResponse, null, 2),
393
+ source,
394
+ };
395
+ };
396
+
397
+ const createBatchContextMessage = (activeBatch: IActiveBatch): string => {
398
+ return [
399
+ "Agentation extension batch context:",
400
+ `- projectId: ${activeBatch.projectId}`,
401
+ `- source: ${activeBatch.source}`,
402
+ `- annotationCount: ${activeBatch.annotations.length}`,
403
+ "- The extension already fetched this batch.",
404
+ "- Do NOT call `agentation pending`, `agentation watch`, `agentation start`, `agentation status`, or `agentation projects`.",
405
+ "- Use `agentation ack`, `agentation resolve`, `agentation reply`, and `agentation dismiss` only for annotation IDs from this batch.",
406
+ "- Run those Agentation action commands as separate bash commands so the extension can track batch completion correctly.",
407
+ "",
408
+ "Current batch JSON:",
409
+ "```json",
410
+ activeBatch.rawJson,
411
+ "```",
412
+ ].join("\n");
413
+ };
414
+
415
+ const createNoBatchContextMessage = (projectId: string): string => {
416
+ return [
417
+ "Agentation extension batch context:",
418
+ `- projectId: ${projectId}`,
419
+ "- There is no active batch available right now.",
420
+ "- Do NOT call `agentation pending` or `agentation watch` yourself; the extension owns polling.",
421
+ "- If you need to recover from an interrupted batch, restart pi-agentation.",
422
+ ].join("\n");
423
+ };
424
+
425
+ const dispatchActiveBatch = (projectId: string): void => {
426
+ const loopPrompt = `${AGENTATION_SKILL_PROMPT} ${projectId}`;
427
+ const currentContext = runtimeState.currentContext;
428
+ const activeBatch = runtimeState.activeBatch;
429
+ const source = activeBatch?.source;
430
+
431
+ runtimeState.pendingLoopInvocationProjectId = projectId;
432
+ runtimeState.hasNotifiedIncompleteBatch = false;
433
+ setUiState(currentContext, {
434
+ annotationCount: activeBatch?.annotations.length,
435
+ detail:
436
+ currentContext !== null && !currentContext.isIdle()
437
+ ? `Triggered ${AGENTATION_SKILL_PROMPT}. Batch queued; pi is busy.`
438
+ : `Triggered ${AGENTATION_SKILL_PROMPT}. Pi is processing the batch.`,
439
+ phase: "processing",
440
+ projectId,
441
+ source,
442
+ });
443
+
444
+ if (currentContext !== null && !currentContext.isIdle()) {
445
+ pi.sendUserMessage(loopPrompt, { deliverAs: "followUp" });
240
446
  return;
241
447
  }
242
448
 
449
+ pi.sendUserMessage(loopPrompt);
450
+ };
451
+
452
+ const dispatchBatch = (projectId: string, source: BatchSource, batchResponse: IAgentationBatchResponse): void => {
453
+ runtimeState.activeBatch = createActiveBatch(projectId, source, batchResponse);
454
+ setUiState(runtimeState.currentContext, {
455
+ annotationCount: batchResponse.annotations.length,
456
+ detail: `${batchResponse.annotations.length} annotation${batchResponse.annotations.length === 1 ? "" : "s"} received. Triggering ${AGENTATION_SKILL_PROMPT}.`,
457
+ phase: "processing",
458
+ projectId,
459
+ source,
460
+ });
461
+ runtimeState.currentContext?.ui.notify(
462
+ `Agentation batch ready for ${projectId} (${batchResponse.annotations.length} annotation${batchResponse.annotations.length === 1 ? "" : "s"
463
+ })`,
464
+ "info"
465
+ );
466
+ dispatchActiveBatch(projectId);
467
+ };
468
+
469
+ const shouldContinueWatchLoop = (generation: number, projectId: string): boolean => {
470
+ return (
471
+ runtimeState.isLoopEnabled &&
472
+ runtimeState.currentProjectId === projectId &&
473
+ runtimeState.watchGeneration === generation
474
+ );
475
+ };
476
+
477
+ const runWatchLoop = async (generation: number, projectId: string, signal: AbortSignal): Promise<void> => {
478
+ let nextBatchSource: BatchSource = "pending";
479
+
480
+ while (shouldContinueWatchLoop(generation, projectId)) {
481
+ if (signal.aborted) {
482
+ return;
483
+ }
484
+
485
+ const currentContext = runtimeState.currentContext;
486
+ if (runtimeState.activeBatch !== null || (currentContext !== null && !currentContext.isIdle())) {
487
+ await waitForDelay(LOOP_IDLE_WAIT_MS, signal);
488
+ continue;
489
+ }
490
+
491
+ const commandArgs =
492
+ nextBatchSource === "pending"
493
+ ? ["pending", projectId, "--json"]
494
+ : ["watch", projectId, "--timeout", WATCH_TIMEOUT_SECONDS, "--batch-window", WATCH_BATCH_WINDOW, "--json"];
495
+ const commandOutcome = await execAgentationCommand(commandArgs, signal);
496
+ if (!shouldContinueWatchLoop(generation, projectId) || signal.aborted) {
497
+ return;
498
+ }
499
+
500
+ if (!didCommandSucceed(commandOutcome)) {
501
+ if (commandOutcome.killed && signal.aborted) {
502
+ return;
503
+ }
504
+
505
+ reportError(
506
+ runtimeState.currentContext,
507
+ `Agentation ${nextBatchSource} failed for ${projectId}: ${formatCommandOutcome(commandOutcome)}`
508
+ );
509
+ await waitForDelay(WATCH_RETRY_DELAY_MS, signal);
510
+ nextBatchSource = "pending";
511
+ continue;
512
+ }
513
+
514
+ const pollResponse = parseAgentationPollResponse(commandOutcome.stdout);
515
+ if (pollResponse === null) {
516
+ reportError(
517
+ runtimeState.currentContext,
518
+ `Agentation ${nextBatchSource} returned an unexpected JSON response for ${projectId}.`
519
+ );
520
+ await waitForDelay(WATCH_RETRY_DELAY_MS, signal);
521
+ nextBatchSource = "pending";
522
+ continue;
523
+ }
524
+
525
+ if (isAgentationWatchTimeoutResponse(pollResponse)) {
526
+ const detail = pollResponse.message ?? `No new annotations in the last ${WATCH_TIMEOUT_SECONDS}s. Restarting live watch.`;
527
+ setUiState(runtimeState.currentContext, {
528
+ detail,
529
+ phase: "watching",
530
+ projectId,
531
+ source: nextBatchSource,
532
+ });
533
+ nextBatchSource = "watch";
534
+ continue;
535
+ }
536
+
537
+ if (pollResponse.annotations.length === 0) {
538
+ const detail =
539
+ nextBatchSource === "pending"
540
+ ? "No pending annotations. Live watch active."
541
+ : pollResponse.timeout === true
542
+ ? `No new annotations in the last ${WATCH_TIMEOUT_SECONDS}s. Restarting live watch.`
543
+ : "Live watch active.";
544
+ setUiState(runtimeState.currentContext, {
545
+ detail,
546
+ phase: "watching",
547
+ projectId,
548
+ source: nextBatchSource,
549
+ });
550
+ nextBatchSource = "watch";
551
+ continue;
552
+ }
553
+
554
+ dispatchBatch(projectId, nextBatchSource, pollResponse);
555
+ nextBatchSource = "watch";
556
+ }
557
+ };
558
+
559
+ const startWatchLoop = (ctx: ExtensionContext, projectId: string): void => {
560
+ setCurrentContext(ctx);
561
+
562
+ if (!runtimeState.isLoopEnabled) {
563
+ return;
564
+ }
565
+
566
+ if (!ensureLoopSkillAvailable(ctx)) {
567
+ return;
568
+ }
569
+
570
+ if (runtimeState.watchTask !== null && runtimeState.currentProjectId === projectId) {
571
+ setUiState(ctx, {
572
+ detail: "Live watch active.",
573
+ phase: "watching",
574
+ projectId,
575
+ source: "watch",
576
+ });
577
+ return;
578
+ }
579
+
580
+ stopWatchLoop();
581
+ runtimeState.currentProjectId = projectId;
582
+ setUiState(ctx, {
583
+ detail: "Checking queue…",
584
+ phase: "initializing",
585
+ projectId,
586
+ source: "pending",
587
+ });
588
+
589
+ const watchAbortController = new AbortController();
590
+ const generation = runtimeState.watchGeneration;
591
+ runtimeState.watchAbortController = watchAbortController;
592
+ runtimeState.watchTask = runWatchLoop(generation, projectId, watchAbortController.signal).finally(() => {
593
+ if (runtimeState.watchGeneration !== generation) {
594
+ return;
595
+ }
596
+
597
+ runtimeState.watchAbortController = null;
598
+ runtimeState.watchTask = null;
599
+ });
600
+ };
601
+
602
+ const initializeLoopForSession = async (ctx: ExtensionContext): Promise<void> => {
603
+ resetRuntimeStateForSession(ctx);
604
+ setUiState(ctx, {
605
+ detail: "Resolving project…",
606
+ phase: "initializing",
607
+ projectId: runtimeState.currentProjectId,
608
+ source: "pending",
609
+ });
610
+
243
611
  if (!ensureLoopSkillAvailable(ctx)) {
244
612
  return;
245
613
  }
246
614
 
247
- let projectId = currentProjectId;
615
+ let projectId = runtimeState.currentProjectId;
248
616
  if (projectId === null) {
249
617
  projectId = await resolveProjectId(ctx);
250
618
  if (projectId === null) {
@@ -253,8 +621,51 @@ export default function agentation(pi: ExtensionAPI): void {
253
621
  persistProjectSelection(projectId);
254
622
  }
255
623
 
256
- ctx.ui.notify(`Agentation loop started for ${projectId}`, "info");
257
- queueLoopPrompt(projectId, !ctx.isIdle());
624
+ startWatchLoop(ctx, projectId);
625
+ ctx.ui.notify(`Agentation extension watch started for ${projectId}`, "info");
626
+ };
627
+
628
+ const updateBatchProgressFromCommand = (command: string): void => {
629
+ const activeBatch = runtimeState.activeBatch;
630
+ if (activeBatch === null) {
631
+ return;
632
+ }
633
+
634
+ const actions = extractAgentationActionsFromCommand(command);
635
+ if (actions.length === 0) {
636
+ return;
637
+ }
638
+
639
+ for (const action of actions) {
640
+ const annotationProgress = activeBatch.progressById.get(action.annotationId);
641
+ if (annotationProgress === undefined) {
642
+ continue;
643
+ }
644
+
645
+ if (action.action === "ack") {
646
+ annotationProgress.wasAcknowledged = true;
647
+ continue;
648
+ }
649
+
650
+ annotationProgress.isHandled = true;
651
+ annotationProgress.lastAction = action.action;
652
+ }
653
+
654
+ const isBatchHandled = Array.from(activeBatch.progressById.values()).every((annotationProgress) => {
655
+ return annotationProgress.isHandled;
656
+ });
657
+ if (!isBatchHandled) {
658
+ return;
659
+ }
660
+
661
+ clearActiveBatch();
662
+ setUiState(runtimeState.currentContext, {
663
+ detail: "Batch completed. Live watch active.",
664
+ phase: "watching",
665
+ projectId: activeBatch.projectId,
666
+ source: "watch",
667
+ });
668
+ runtimeState.currentContext?.ui.notify(`Agentation batch completed for ${activeBatch.projectId}`, "success");
258
669
  };
259
670
 
260
671
  pi.on("session_start", async (_event, ctx) => {
@@ -269,62 +680,108 @@ export default function agentation(pi: ExtensionAPI): void {
269
680
  await initializeLoopForSession(ctx);
270
681
  });
271
682
 
272
- pi.on("agent_end", async (_event, ctx) => {
273
- if (!isLoopEnabled) {
274
- return;
683
+ pi.on("input", async (event, ctx) => {
684
+ setCurrentContext(ctx);
685
+ runtimeState.pendingLoopInvocationProjectId = parseLoopInvocationProjectId(event.text);
686
+ return { action: "continue" };
687
+ });
688
+
689
+ pi.on("before_agent_start", async (_event, ctx) => {
690
+ setCurrentContext(ctx);
691
+
692
+ const pendingProjectId = runtimeState.pendingLoopInvocationProjectId;
693
+ if (pendingProjectId === null) {
694
+ return undefined;
275
695
  }
276
696
 
277
- if (!ensureLoopSkillAvailable(ctx)) {
278
- return;
697
+ runtimeState.pendingLoopInvocationProjectId = null;
698
+
699
+ const activeBatch = runtimeState.activeBatch;
700
+ const content =
701
+ activeBatch !== null && activeBatch.projectId === pendingProjectId
702
+ ? createBatchContextMessage(activeBatch)
703
+ : createNoBatchContextMessage(pendingProjectId);
704
+
705
+ return {
706
+ message: {
707
+ content,
708
+ customType: BATCH_CONTEXT_MESSAGE_TYPE,
709
+ display: false,
710
+ },
711
+ };
712
+ });
713
+
714
+ pi.on("tool_result", async (event, ctx) => {
715
+ setCurrentContext(ctx);
716
+
717
+ if (event.toolName !== "bash" || event.isError || !isBashToolInput(event.input)) {
718
+ return undefined;
279
719
  }
280
720
 
281
- if (currentProjectId === null) {
282
- reportError(ctx, "Agentation loop is enabled, but no project ID is selected for this session.");
721
+ updateBatchProgressFromCommand(event.input.command);
722
+ return undefined;
723
+ });
724
+
725
+ pi.on("agent_end", async (_event, ctx) => {
726
+ setCurrentContext(ctx);
727
+
728
+ if (runtimeState.activeBatch === null || runtimeState.hasNotifiedIncompleteBatch) {
283
729
  return;
284
730
  }
285
731
 
286
- queueLoopPrompt(currentProjectId, !ctx.isIdle());
732
+ runtimeState.hasNotifiedIncompleteBatch = true;
733
+ setUiState(ctx, {
734
+ annotationCount: runtimeState.activeBatch.annotations.length,
735
+ detail: "Batch still incomplete. Restart pi-agentation to retry.",
736
+ phase: "processing",
737
+ projectId: runtimeState.activeBatch.projectId,
738
+ source: runtimeState.activeBatch.source,
739
+ });
740
+ ctx.ui.notify("Current Agentation batch is still incomplete. Restart pi-agentation to retry.", "warning");
287
741
  });
288
742
 
289
743
  pi.on("session_shutdown", async () => {
290
- currentProjectId = null;
291
- isLoopEnabled = false;
744
+ clearUiState(runtimeState.currentContext);
745
+ stopWatchLoop();
746
+ runtimeState.activeBatch = null;
747
+ runtimeState.agentationExecutablePath = null;
748
+ runtimeState.currentContext = null;
749
+ runtimeState.currentProjectId = null;
750
+ runtimeState.pendingLoopInvocationProjectId = null;
292
751
  });
293
752
 
294
- pi.registerCommand("agentation-loop-start", {
295
- description: "Start the automatic Agentation fix loop",
296
- handler: async (_args, ctx) => {
297
- if (isLoopEnabled) {
298
- ctx.ui.notify("Agentation loop is already running", "info");
299
- return;
300
- }
301
-
302
- if (!ensureLoopSkillAvailable(ctx)) {
303
- return;
304
- }
753
+ }
305
754
 
306
- let projectId = currentProjectId ?? restoreProjectSelection(ctx);
307
- if (projectId === null) {
308
- projectId = await resolveProjectId(ctx);
309
- if (projectId === null) {
310
- return;
311
- }
312
- persistProjectSelection(projectId);
313
- }
755
+ function formatWidgetTitle(uiState: IAgentationUiState): string {
756
+ const projectLabel = uiState.projectId ?? "resolving";
757
+ const phaseLabel = formatUiPhase(uiState.phase);
758
+ return `Agentation Fork / ${projectLabel} / ${phaseLabel}`;
759
+ }
314
760
 
315
- isLoopEnabled = true;
316
- ctx.ui.notify(`Agentation loop resumed for ${projectId}`, "info");
317
- queueLoopPrompt(projectId, !ctx.isIdle());
318
- },
319
- });
761
+ function getUiPhaseColorName(phase: AgentationUiPhase): "accent" | "error" | "success" | "warning" {
762
+ switch (phase) {
763
+ case "error":
764
+ return "error";
765
+ case "initializing":
766
+ return "warning";
767
+ case "processing":
768
+ return "accent";
769
+ case "watching":
770
+ return "success";
771
+ }
772
+ }
320
773
 
321
- pi.registerCommand("agentation-loop-stop", {
322
- description: "Stop the automatic Agentation fix loop",
323
- handler: async (_args, ctx) => {
324
- isLoopEnabled = false;
325
- ctx.ui.notify("Agentation loop paused", "warning");
326
- },
327
- });
774
+ function formatUiPhase(phase: AgentationUiPhase): string {
775
+ switch (phase) {
776
+ case "error":
777
+ return "Error";
778
+ case "initializing":
779
+ return "Initializing";
780
+ case "processing":
781
+ return "Running";
782
+ case "watching":
783
+ return "Watching";
784
+ }
328
785
  }
329
786
 
330
787
  function didCommandSucceed(commandOutcome: CommandOutcome): boolean {
@@ -405,6 +862,110 @@ function extractProjectIdsFromRgOutput(output: string): string[] {
405
862
  return normalizeProjectIds(projectIds);
406
863
  }
407
864
 
865
+ function resolveAgentationExecutablePath(cwd: string): string {
866
+ const explicitExecutablePath = process.env[AGENTATION_EXECUTABLE_ENV_NAME];
867
+ if (isExecutablePath(explicitExecutablePath)) {
868
+ return explicitExecutablePath;
869
+ }
870
+
871
+ const localExecutablePath = findNearestNodeModulesExecutable("agentation", cwd);
872
+ if (localExecutablePath !== null) {
873
+ return localExecutablePath;
874
+ }
875
+
876
+ return "agentation";
877
+ }
878
+
879
+ function findNearestNodeModulesExecutable(executableName: string, startDirectory: string): string | null {
880
+ let currentDirectory = path.resolve(startDirectory);
881
+
882
+ while (true) {
883
+ const candidateExecutablePath = path.join(currentDirectory, "node_modules", ".bin", executableName);
884
+ if (isExecutablePath(candidateExecutablePath)) {
885
+ return candidateExecutablePath;
886
+ }
887
+
888
+ const parentDirectory = path.dirname(currentDirectory);
889
+ if (parentDirectory === currentDirectory) {
890
+ return null;
891
+ }
892
+
893
+ currentDirectory = parentDirectory;
894
+ }
895
+ }
896
+
897
+ function isExecutablePath(filePath: string | undefined): filePath is string {
898
+ if (typeof filePath !== "string" || filePath.trim() === "") {
899
+ return false;
900
+ }
901
+
902
+ try {
903
+ fs.accessSync(filePath, fs.constants.X_OK);
904
+ return true;
905
+ } catch {
906
+ return false;
907
+ }
908
+ }
909
+
910
+ function parseLoopInvocationProjectId(text: string): string | null {
911
+ const firstLine = text.split(/\r?\n/, 1)[0]?.trim() ?? "";
912
+ const match = AGENTATION_SKILL_INVOCATION_PATTERN.exec(firstLine);
913
+ const projectId = match?.[1];
914
+ if (projectId === undefined) {
915
+ return null;
916
+ }
917
+
918
+ return normalizeProjectId(projectId);
919
+ }
920
+
921
+ function extractAgentationActionsFromCommand(
922
+ command: string
923
+ ): Array<{ action: AgentationAction; annotationId: string }> {
924
+ const actions: Array<{ action: AgentationAction; annotationId: string }> = [];
925
+
926
+ for (const match of command.matchAll(AGENTATION_ACTION_PATTERN)) {
927
+ const action = match[1];
928
+ const annotationId = match[2];
929
+ if (action === undefined || annotationId === undefined || !isAgentationAction(action)) {
930
+ continue;
931
+ }
932
+
933
+ actions.push({ action, annotationId });
934
+ }
935
+
936
+ return actions;
937
+ }
938
+
939
+ function waitForDelay(milliseconds: number, signal: AbortSignal): Promise<void> {
940
+ if (signal.aborted) {
941
+ return Promise.resolve();
942
+ }
943
+
944
+ return new Promise((resolve) => {
945
+ const timeoutHandle = setTimeout(() => {
946
+ signal.removeEventListener("abort", onAbort);
947
+ resolve();
948
+ }, milliseconds);
949
+
950
+ const onAbort = (): void => {
951
+ clearTimeout(timeoutHandle);
952
+ signal.removeEventListener("abort", onAbort);
953
+ resolve();
954
+ };
955
+
956
+ signal.addEventListener("abort", onAbort, { once: true });
957
+ });
958
+ }
959
+
960
+ function parseAgentationPollResponse(jsonText: string): AgentationPollResponse | null {
961
+ try {
962
+ const parsed = JSON.parse(jsonText);
963
+ return isAgentationPollResponse(parsed) ? parsed : null;
964
+ } catch {
965
+ return null;
966
+ }
967
+ }
968
+
408
969
  function isRecord(value: unknown): value is Record<string, unknown> {
409
970
  return typeof value === "object" && value !== null;
410
971
  }
@@ -422,6 +983,69 @@ function isProjectSelectionData(value: unknown): value is IProjectSelectionData
422
983
  return typeof projectId === "string" && projectId.trim() !== "";
423
984
  }
424
985
 
986
+ function isAgentationAnnotation(value: unknown): value is IAgentationAnnotation {
987
+ if (!isRecord(value)) {
988
+ return false;
989
+ }
990
+
991
+ return typeof value["id"] === "string" && value["id"].trim() !== "";
992
+ }
993
+
994
+ function isAgentationPollResponse(value: unknown): value is AgentationPollResponse {
995
+ return isAgentationBatchResponse(value) || isAgentationWatchTimeoutResponse(value);
996
+ }
997
+
998
+ function isAgentationBatchResponse(value: unknown): value is IAgentationBatchResponse {
999
+ if (!isRecord(value)) {
1000
+ return false;
1001
+ }
1002
+
1003
+ const count = value["count"];
1004
+ const annotations = value["annotations"];
1005
+ const timeout = value["timeout"];
1006
+
1007
+ if (typeof count !== "number" || !Array.isArray(annotations) || !annotations.every(isAgentationAnnotation)) {
1008
+ return false;
1009
+ }
1010
+
1011
+ if (timeout !== undefined && typeof timeout !== "boolean") {
1012
+ return false;
1013
+ }
1014
+
1015
+ return true;
1016
+ }
1017
+
1018
+ function isAgentationWatchTimeoutResponse(value: unknown): value is IAgentationWatchTimeoutResponse {
1019
+ if (!isRecord(value)) {
1020
+ return false;
1021
+ }
1022
+
1023
+ const timeout = value["timeout"];
1024
+ const message = value["message"];
1025
+
1026
+ if (timeout !== true) {
1027
+ return false;
1028
+ }
1029
+
1030
+ if (message !== undefined && typeof message !== "string") {
1031
+ return false;
1032
+ }
1033
+
1034
+ return true;
1035
+ }
1036
+
1037
+ function isBashToolInput(value: unknown): value is { command: string } {
1038
+ if (!isRecord(value)) {
1039
+ return false;
1040
+ }
1041
+
1042
+ return typeof value["command"] === "string";
1043
+ }
1044
+
1045
+ function isAgentationAction(value: string): value is AgentationAction {
1046
+ return value === "ack" || value === "resolve" || value === "reply" || value === "dismiss";
1047
+ }
1048
+
425
1049
  function parseJsonStringArray(jsonText: string): string[] | null {
426
1050
  try {
427
1051
  const parsed = JSON.parse(jsonText);