@ilya-lesikov/pi-pi 0.5.0 → 0.6.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.
Files changed (76) hide show
  1. package/3p/pi-ask-user/index.ts +65 -49
  2. package/3p/pi-subagents/src/agent-manager.ts +8 -0
  3. package/3p/pi-subagents/src/agent-runner.ts +112 -19
  4. package/3p/pi-subagents/src/index.ts +3 -0
  5. package/extensions/orchestrator/agents/brainstorm-reviewer.ts +32 -19
  6. package/extensions/orchestrator/agents/code-reviewer.ts +31 -17
  7. package/extensions/orchestrator/agents/constraints.ts +55 -0
  8. package/extensions/orchestrator/agents/explore.ts +13 -13
  9. package/extensions/orchestrator/agents/librarian.ts +12 -9
  10. package/extensions/orchestrator/agents/plan-reviewer.ts +31 -19
  11. package/extensions/orchestrator/agents/planner.ts +28 -23
  12. package/extensions/orchestrator/agents/registry.ts +2 -1
  13. package/extensions/orchestrator/agents/repo-context.ts +11 -0
  14. package/extensions/orchestrator/agents/task.ts +14 -11
  15. package/extensions/orchestrator/agents/tool-routing.ts +17 -32
  16. package/extensions/orchestrator/ast-search.ts +2 -1
  17. package/extensions/orchestrator/cbm.test.ts +35 -0
  18. package/extensions/orchestrator/cbm.ts +43 -13
  19. package/extensions/orchestrator/command-handlers.test.ts +390 -19
  20. package/extensions/orchestrator/command-handlers.ts +68 -28
  21. package/extensions/orchestrator/commands.test.ts +255 -2
  22. package/extensions/orchestrator/commands.ts +108 -10
  23. package/extensions/orchestrator/config.test.ts +289 -68
  24. package/extensions/orchestrator/config.ts +630 -121
  25. package/extensions/orchestrator/context.test.ts +177 -10
  26. package/extensions/orchestrator/context.ts +115 -14
  27. package/extensions/orchestrator/custom-footer.ts +2 -1
  28. package/extensions/orchestrator/doctor.test.ts +559 -0
  29. package/extensions/orchestrator/doctor.ts +664 -0
  30. package/extensions/orchestrator/event-handlers.test.ts +84 -22
  31. package/extensions/orchestrator/event-handlers.ts +1177 -341
  32. package/extensions/orchestrator/exa.test.ts +46 -0
  33. package/extensions/orchestrator/exa.ts +16 -10
  34. package/extensions/orchestrator/flant-infra.test.ts +224 -0
  35. package/extensions/orchestrator/flant-infra.ts +110 -41
  36. package/extensions/orchestrator/index.ts +13 -2
  37. package/extensions/orchestrator/integration.test.ts +2866 -118
  38. package/extensions/orchestrator/log.test.ts +219 -0
  39. package/extensions/orchestrator/log.ts +153 -0
  40. package/extensions/orchestrator/model-registry.test.ts +238 -0
  41. package/extensions/orchestrator/model-registry.ts +282 -0
  42. package/extensions/orchestrator/model-version.test.ts +27 -0
  43. package/extensions/orchestrator/model-version.ts +19 -0
  44. package/extensions/orchestrator/orchestrator.test.ts +206 -56
  45. package/extensions/orchestrator/orchestrator.ts +287 -140
  46. package/extensions/orchestrator/phases/brainstorm.test.ts +10 -7
  47. package/extensions/orchestrator/phases/brainstorm.ts +41 -36
  48. package/extensions/orchestrator/phases/implementation.ts +7 -11
  49. package/extensions/orchestrator/phases/machine.test.ts +27 -8
  50. package/extensions/orchestrator/phases/machine.ts +13 -0
  51. package/extensions/orchestrator/phases/planning.ts +57 -31
  52. package/extensions/orchestrator/phases/review-task.ts +3 -7
  53. package/extensions/orchestrator/phases/review.ts +38 -39
  54. package/extensions/orchestrator/phases/spawn-blocking.test.ts +69 -0
  55. package/extensions/orchestrator/phases/verdict.test.ts +139 -0
  56. package/extensions/orchestrator/phases/verdict.ts +82 -0
  57. package/extensions/orchestrator/plannotator.test.ts +85 -0
  58. package/extensions/orchestrator/plannotator.ts +24 -6
  59. package/extensions/orchestrator/pp-menu.test.ts +134 -0
  60. package/extensions/orchestrator/pp-menu.ts +2557 -347
  61. package/extensions/orchestrator/repo-utils.test.ts +151 -0
  62. package/extensions/orchestrator/repo-utils.ts +67 -0
  63. package/extensions/orchestrator/spawn-cleanup.test.ts +57 -0
  64. package/extensions/orchestrator/spawn-cleanup.ts +35 -0
  65. package/extensions/orchestrator/state.test.ts +76 -6
  66. package/extensions/orchestrator/state.ts +89 -26
  67. package/extensions/orchestrator/subagent-session-marker.test.ts +36 -0
  68. package/extensions/orchestrator/test-helpers.ts +217 -0
  69. package/extensions/orchestrator/tracer.test.ts +132 -0
  70. package/extensions/orchestrator/tracer.ts +127 -0
  71. package/extensions/orchestrator/transition-controller.test.ts +207 -0
  72. package/extensions/orchestrator/transition-controller.ts +259 -0
  73. package/extensions/orchestrator/usage-tracker.test.ts +435 -0
  74. package/extensions/orchestrator/usage-tracker.ts +23 -2
  75. package/extensions/orchestrator/validate-artifacts.test.ts +83 -1
  76. package/package.json +2 -1
@@ -0,0 +1,207 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { TransitionController, type TransitionHost, type MainSession } from "./transition-controller.js";
3
+
4
+ // Fake host + session: records outbound primitives and lets tests control idle +
5
+ // compaction. `controller` is pre-wired with both.
6
+ function makeHost(opts: { idle?: boolean; step?: string | null; canCompact?: boolean } = {}) {
7
+ let compactCb: { onComplete?: () => void; onError?: (e: Error) => void } | null = null;
8
+ const calls = {
9
+ userMessages: [] as Array<{ text: string; deliverAs: string }>,
10
+ customMessages: [] as Array<{ customType: string; deliverAs: string }>,
11
+ compactStarted: 0,
12
+ };
13
+ const session: MainSession = {
14
+ sendUserMessage: (text, options) => calls.userMessages.push({ text, deliverAs: options?.deliverAs ?? "" }),
15
+ sendMessage: (message, options) => calls.customMessages.push({ customType: message.customType, deliverAs: options?.deliverAs ?? "" }),
16
+ };
17
+ const host: TransitionHost = {
18
+ compact: (options) => {
19
+ if (opts.canCompact === false) return false;
20
+ calls.compactStarted++;
21
+ compactCb = options;
22
+ return true;
23
+ },
24
+ isIdle: () => opts.idle ?? false,
25
+ currentStep: () => opts.step ?? "llm_work",
26
+ };
27
+ return {
28
+ host,
29
+ session,
30
+ calls,
31
+ controller: new TransitionController(host, session),
32
+ // Simulate the SDK firing the compaction completion event.
33
+ completeCompaction: () => compactCb?.onComplete?.(),
34
+ failCompaction: (msg: string) => compactCb?.onError?.(new Error(msg)),
35
+ };
36
+ }
37
+
38
+ describe("TransitionController.send", () => {
39
+ it("delivers instruction as followUp (always starts a turn)", () => {
40
+ const { host, session, calls } = makeHost();
41
+ const c = new TransitionController(host, session);
42
+ c.send("go", "instruction");
43
+ expect(calls.userMessages).toEqual([{ text: "go", deliverAs: "followUp" }]);
44
+ });
45
+
46
+ it("rejects context role (plain user messages always start a turn)", () => {
47
+ const { host, session, calls } = makeHost();
48
+ const c = new TransitionController(host, session);
49
+ expect(() => c.send("ctx", "context")).toThrow(/sendCustom/);
50
+ expect(calls.userMessages).toHaveLength(0);
51
+ });
52
+
53
+ it("sendCustom maps roles to delivery modes", () => {
54
+ const { host, session, calls } = makeHost();
55
+ const c = new TransitionController(host, session);
56
+ c.sendCustom({ customType: "pp-context", content: "x", display: false }, "context");
57
+ c.sendCustom({ customType: "pp-artifact", content: "y", display: false }, "instruction");
58
+ expect(calls.customMessages).toEqual([
59
+ { customType: "pp-context", deliverAs: "steer" },
60
+ { customType: "pp-artifact", deliverAs: "followUp" },
61
+ ]);
62
+ });
63
+ });
64
+
65
+ describe("TransitionController.isRunning / shouldBlockAgentStart", () => {
66
+ it("running with llm_work step is running and does not block", () => {
67
+ const { host, session } = makeHost({ step: "llm_work" });
68
+ const c = new TransitionController(host, session);
69
+ expect(c.isRunning()).toBe(true);
70
+ expect(c.shouldBlockAgentStart()).toBe(false);
71
+ });
72
+
73
+ it("await_planners and await_reviewers steps are not running (block)", () => {
74
+ for (const step of ["await_planners", "await_reviewers"]) {
75
+ const { host, session } = makeHost({ step });
76
+ const c = new TransitionController(host, session);
77
+ expect(c.isRunning()).toBe(false);
78
+ expect(c.shouldBlockAgentStart()).toBe(true);
79
+ }
80
+ });
81
+
82
+ it("gateAgentStart aborts and reports when not running, no-op when running", () => {
83
+ const running = makeHost({ step: "llm_work" });
84
+ const rc = new TransitionController(running.host, running.session);
85
+ const abort1 = vi.fn();
86
+ expect(rc.gateAgentStart(abort1)).toBe(false);
87
+ expect(abort1).not.toHaveBeenCalled();
88
+
89
+ const waiting = makeHost({ step: "await_planners" });
90
+ const wc = new TransitionController(waiting.host, waiting.session);
91
+ const abort2 = vi.fn();
92
+ expect(wc.gateAgentStart(abort2)).toBe(true);
93
+ expect(abort2).toHaveBeenCalledOnce();
94
+ });
95
+
96
+ it("abortMainAgent issues the abort (controller-owned cleanup abort)", () => {
97
+ const { controller } = makeHost();
98
+ const abort = vi.fn();
99
+ controller.abortMainAgent(abort);
100
+ expect(abort).toHaveBeenCalledOnce();
101
+ expect(() => controller.abortMainAgent(undefined)).not.toThrow();
102
+ });
103
+ });
104
+
105
+ describe("TransitionController phase transition flow", () => {
106
+ it("waits for agent_end when not idle, then compacts and resumes", async () => {
107
+ const { host, session, calls, completeCompaction } = makeHost({ idle: false });
108
+ const c = new TransitionController(host, session);
109
+ const onResume = vi.fn();
110
+ const p = c.requestTransition({ kind: "phase", summary: "s", onResume, instruction: "Begin working." });
111
+ expect(c.getState()).toBe("pending");
112
+ expect(calls.compactStarted).toBe(0); // not idle -> waits
113
+
114
+ c.onAgentEnd();
115
+ expect(c.getState()).toBe("compacting");
116
+ expect(calls.compactStarted).toBe(1);
117
+
118
+ completeCompaction();
119
+ await p;
120
+ expect(onResume).toHaveBeenCalledOnce();
121
+ expect(c.getState()).toBe("running");
122
+ expect(calls.userMessages).toEqual([{ text: "Begin working.", deliverAs: "followUp" }]);
123
+ });
124
+
125
+ it("already-idle path compacts immediately without agent_end", async () => {
126
+ const { host, session, calls, completeCompaction } = makeHost({ idle: true });
127
+ const c = new TransitionController(host, session);
128
+ const p = c.requestTransition({ kind: "done", summary: "done" });
129
+ expect(c.getState()).toBe("compacting");
130
+ expect(calls.compactStarted).toBe(1);
131
+ completeCompaction();
132
+ await p;
133
+ expect(c.getState()).toBe("running");
134
+ });
135
+
136
+ it("treats compact no-op errors as a clean resume", async () => {
137
+ for (const msg of ["Nothing to compact (session too small)", "Already compacted"]) {
138
+ const { host, session, failCompaction } = makeHost({ idle: true });
139
+ const c = new TransitionController(host, session);
140
+ const onResume = vi.fn();
141
+ const p = c.requestTransition({ kind: "phase", onResume, instruction: "go" });
142
+ failCompaction(msg);
143
+ await p;
144
+ expect(onResume).toHaveBeenCalledOnce();
145
+ expect(c.getState()).toBe("running");
146
+ }
147
+ });
148
+
149
+ it("resumes via session_compact event", async () => {
150
+ const { host, session } = makeHost({ idle: false });
151
+ const c = new TransitionController(host, session);
152
+ const onResume = vi.fn();
153
+ const p = c.requestTransition({ kind: "phase", onResume, instruction: "go" });
154
+ c.onAgentEnd();
155
+ c.onSessionCompact();
156
+ await p;
157
+ expect(onResume).toHaveBeenCalledOnce();
158
+ expect(c.getState()).toBe("running");
159
+ });
160
+
161
+ it("ignores agent_end while running (no self-trigger loop)", () => {
162
+ const { host, session, calls } = makeHost({ idle: false });
163
+ const c = new TransitionController(host, session);
164
+ c.onAgentEnd();
165
+ c.onAgentEnd();
166
+ expect(calls.compactStarted).toBe(0);
167
+ expect(c.getState()).toBe("running");
168
+ });
169
+
170
+ it("ignores session_compact while running (no spurious resume)", () => {
171
+ const { host, session, calls } = makeHost();
172
+ const c = new TransitionController(host, session);
173
+ c.onSessionCompact();
174
+ expect(calls.userMessages).toHaveLength(0);
175
+ expect(c.getState()).toBe("running");
176
+ });
177
+
178
+ it("resolves the awaitable even when no live ctx can compact", async () => {
179
+ const { host, session, calls } = makeHost({ idle: true, canCompact: false });
180
+ const c = new TransitionController(host, session);
181
+ const onResume = vi.fn();
182
+ await c.requestTransition({ kind: "done", onResume });
183
+ expect(onResume).toHaveBeenCalledOnce();
184
+ expect(calls.compactStarted).toBe(0);
185
+ expect(c.getState()).toBe("running");
186
+ });
187
+
188
+ it("does not send an instruction when none is provided (await_planners notify-only)", async () => {
189
+ const { host, session, calls, completeCompaction } = makeHost({ idle: true });
190
+ const c = new TransitionController(host, session);
191
+ const p = c.requestTransition({ kind: "phase", onResume: () => {} });
192
+ completeCompaction();
193
+ await p;
194
+ expect(calls.userMessages).toHaveLength(0);
195
+ });
196
+
197
+ it("compaction completion is idempotent across onComplete and session_compact", async () => {
198
+ const { host, session, completeCompaction } = makeHost({ idle: true });
199
+ const c = new TransitionController(host, session);
200
+ const onResume = vi.fn();
201
+ const p = c.requestTransition({ kind: "phase", onResume, instruction: "go" });
202
+ completeCompaction();
203
+ c.onSessionCompact(); // second terminus — must be a no-op
204
+ await p;
205
+ expect(onResume).toHaveBeenCalledOnce();
206
+ });
207
+ });
@@ -0,0 +1,259 @@
1
+ import { getLogger } from "./log.js";
2
+
3
+ // Role of an outbound message. Drives the SDK delivery mode:
4
+ // - "context": injected background context/artifacts. Delivered as "steer" so
5
+ // it never starts a turn (the agent is or will be working).
6
+ // - "instruction": a handoff that should make the agent act. Delivered as
7
+ // "followUp" so it queues and triggers a turn once the current one settles
8
+ // (or runs immediately when idle). Never throws "Agent is already processing".
9
+ export type SendRole = "context" | "instruction";
10
+
11
+ // Dependency passed to standalone phase-module spawn functions so they can emit
12
+ // status/context messages WITHOUT importing the orchestrator (avoids circular
13
+ // imports) while still routing every send through the controller. Bound to
14
+ // TransitionController.sendCustom by the caller.
15
+ export type PhaseSend = (
16
+ message: { customType: string; content: string; display: boolean; details?: unknown },
17
+ role: SendRole,
18
+ ) => void;
19
+
20
+ // The kind of transition the controller is coordinating. Phase transitions resume
21
+ // the main loop with a "Begin working" instruction; done/stop transitions finish
22
+ // the task (cleanup already performed by the caller) and resolve their awaitable.
23
+ export type TransitionKind = "phase" | "done";
24
+
25
+ export interface TransitionRequest {
26
+ kind: TransitionKind;
27
+ // Compaction summary used by the session_before_compact handler.
28
+ summary?: string;
29
+ // Runs after compaction completes (or is skipped), before the resume message.
30
+ // Used for phase transitions to switch model + inject context/artifacts and to
31
+ // spawn planners at the right moment. Throwing here is logged, not fatal.
32
+ onResume?: () => void | Promise<void>;
33
+ // For phase transitions: the instruction sent after onResume. When omitted
34
+ // (e.g. plan/await_planners which only notifies, or done transitions), no
35
+ // instruction is sent.
36
+ instruction?: string;
37
+ }
38
+
39
+ export type ControllerState = "running" | "pending" | "compacting" | "resuming";
40
+
41
+ // The raw main-session messaging surface (satisfied by ExtensionAPI / pi). The
42
+ // controller calls these directly so it is literally the only code that calls
43
+ // sendUserMessage/sendMessage — a whole-tree grep finds zero raw sends elsewhere.
44
+ export interface MainSession {
45
+ sendUserMessage(content: string, options?: { deliverAs?: "steer" | "followUp" }): void;
46
+ sendMessage(
47
+ message: { customType: string; content: string; display: boolean; details?: unknown },
48
+ options?: { deliverAs?: "steer" | "followUp" | "nextTurn" },
49
+ ): void;
50
+ }
51
+
52
+ // Live-session surface the controller needs from the Orchestrator (the bits that
53
+ // depend on the current ExtensionContext / persisted state). Kept as an interface
54
+ // so the controller is unit-testable with a fake host.
55
+ export interface TransitionHost {
56
+ // Compaction + idle probe come from the live ExtensionContext (lastCtx).
57
+ compact(options: { customInstructions?: string; onComplete?: () => void; onError?: (err: Error) => void }): boolean;
58
+ isIdle(): boolean;
59
+ // Persisted step, used to derive the await_* "not running" predicate.
60
+ currentStep(): string | null;
61
+ }
62
+
63
+ const COMPACT_NOOP_MESSAGES = [
64
+ "Nothing to compact (session too small)",
65
+ "Already compacted",
66
+ ];
67
+
68
+ function isCompactNoop(err: unknown): boolean {
69
+ const msg = err instanceof Error ? err.message : String(err ?? "");
70
+ return COMPACT_NOOP_MESSAGES.some((m) => msg.includes(m));
71
+ }
72
+
73
+ // Single event-driven owner of phase/task transitions and main-session
74
+ // compaction + outbound messaging. See transition-controller flow in the plan:
75
+ // requestTransition -> pending; agent_end (or already-idle) -> compact ->
76
+ // compacting; session_compact / compact no-op / error -> resuming -> onResume
77
+ // -> send(instruction) -> running.
78
+ export class TransitionController {
79
+ private state: ControllerState = "running";
80
+ private active: TransitionRequest | null = null;
81
+ // Resolvers for callers that await the transition (done/stop/new-task paths).
82
+ private waiters: Array<() => void> = [];
83
+
84
+ constructor(
85
+ private readonly host: TransitionHost,
86
+ // The raw main-session messaging surface (pi). The controller calls it
87
+ // directly so no other code issues raw sendUserMessage/sendMessage.
88
+ private readonly session: MainSession,
89
+ ) {}
90
+
91
+ // Pre-bound PhaseSend handed to standalone spawn functions so they route every
92
+ // outbound message through the controller without importing the orchestrator.
93
+ readonly phaseSend: PhaseSend = (message, role) => this.sendCustom(message, role);
94
+
95
+ getState(): ControllerState {
96
+ return this.state;
97
+ }
98
+
99
+ // True only when the controller is idle (running) AND not waiting on subagents.
100
+ // The await_* states live in persisted step, not the enum. Every consumer of
101
+ // "may the agent loop start / may we nudge / may the menu proceed" reads THIS.
102
+ isRunning(): boolean {
103
+ if (this.state !== "running") return false;
104
+ const step = this.host.currentStep();
105
+ return step !== "await_planners" && step !== "await_reviewers";
106
+ }
107
+
108
+ // Inverse predicate used by in-tool / menu code to decide whether to abort.
109
+ shouldBlockAgentStart(): boolean {
110
+ return !this.isRunning();
111
+ }
112
+
113
+ // The transition/await abort gate, owned by the controller. before_agent_start
114
+ // delegates here: the controller decides AND issues the abort (via the passed
115
+ // abort fn from the live ctx) so it is the sole owner of transition aborts.
116
+ // Returns true if the agent start was aborted.
117
+ gateAgentStart(abort: () => void): boolean {
118
+ if (this.isRunning()) return false;
119
+ getLogger().debug({ s: "controller", state: this.state }, "aborting agent start (not running)");
120
+ abort();
121
+ return true;
122
+ }
123
+
124
+ // Abort the in-flight main-agent turn ahead of an interactive pause/stop/finish
125
+ // cleanup. Routed through the controller so it is the sole owner of main-session
126
+ // aborts; the caller then performs cleanup and requests a done transition.
127
+ abortMainAgent(abort: (() => void) | undefined): void {
128
+ abort?.();
129
+ }
130
+
131
+ // True while a transition is mid-flight (pending/compacting/resuming) — i.e.
132
+ // the controller initiated the current compaction. Used by session_before_compact
133
+ // to decide between supplying the transition summary vs. re-injecting artifacts
134
+ // after a natural (user-triggered) compaction.
135
+ isTransitioning(): boolean {
136
+ return this.state !== "running";
137
+ }
138
+
139
+ // Compaction summary for the in-flight transition (empty when not transitioning).
140
+ currentSummary(): string {
141
+ return this.active?.summary ?? "";
142
+ }
143
+
144
+ // Outbound plain user-message. The ONLY path for main-session user messaging.
145
+ // Plain user messages (pi.sendUserMessage) ALWAYS start a turn — even with
146
+ // deliverAs:"steer" when idle (SDK prompt()) — so this only serves the
147
+ // "instruction" role (queue + start a turn via followUp). A non-turn-starting
148
+ // "context" payload must use sendCustom (a custom message that only appends
149
+ // when idle), so passing "context" here is a misuse and is rejected.
150
+ send(text: string, role: SendRole): void {
151
+ if (role === "context") {
152
+ throw new Error("send(context) would start a turn; use sendCustom for non-turn-starting context");
153
+ }
154
+ this.session.sendUserMessage(text, { deliverAs: "followUp" });
155
+ }
156
+
157
+ // Custom (non-LLM) messages (pp-context / pp-artifact). "context" => steer
158
+ // (append-only when idle, never starts a turn); "instruction" => followUp
159
+ // (queues + starts a turn while streaming; callers that need a guaranteed turn
160
+ // when idle pair this with a send(...) instruction).
161
+ sendCustom(message: { customType: string; content: string; display: boolean; details?: unknown }, role: SendRole): void {
162
+ this.session.sendMessage(message, { deliverAs: role === "context" ? "steer" : "followUp" });
163
+ }
164
+
165
+ // Request a transition. Returns a promise that resolves once the transition
166
+ // has resumed (or finished, for done/stop). If the agent is already idle the
167
+ // controller compacts immediately; otherwise it waits for agent_end.
168
+ requestTransition(req: TransitionRequest): Promise<void> {
169
+ const log = getLogger();
170
+ if (this.state !== "running") {
171
+ // A transition is already in flight. Coalesce: ignore the new request but
172
+ // still attach the waiter so the caller is released when this settles.
173
+ if (req.kind === "phase") {
174
+ log.warn({ s: "controller", state: this.state }, "phase transition coalesced while not running — onResume/instruction dropped");
175
+ } else {
176
+ log.debug({ s: "controller", state: this.state, kind: req.kind }, "requestTransition while not running — coalescing");
177
+ }
178
+ return new Promise<void>((resolve) => this.waiters.push(resolve));
179
+ }
180
+ this.active = req;
181
+ this.state = "pending";
182
+ log.debug({ s: "controller", kind: req.kind }, "transition pending");
183
+ const promise = new Promise<void>((resolve) => this.waiters.push(resolve));
184
+ // Already-idle path: no agent turn is in flight, so no agent_end will fire.
185
+ // Compact immediately rather than waiting forever.
186
+ if (this.host.isIdle()) {
187
+ this.beginCompaction();
188
+ }
189
+ return promise;
190
+ }
191
+
192
+ // agent_end fires when the main loop goes idle. If a transition is pending,
193
+ // this is the moment to compact. Repeated agent_end while running is a no-op
194
+ // (prevents the self-trigger loop from injected followUp messages).
195
+ onAgentEnd(): void {
196
+ if (this.state !== "pending") return;
197
+ this.beginCompaction();
198
+ }
199
+
200
+ // session_compact fires after compaction completes. Only advance the transition
201
+ // we initiated (state must be compacting) — an unrelated/manual compaction
202
+ // while running must not resume the wrong transition.
203
+ onSessionCompact(): void {
204
+ if (this.state !== "compacting") return;
205
+ void this.resume();
206
+ }
207
+
208
+ private beginCompaction(): void {
209
+ const req = this.active;
210
+ if (!req) {
211
+ this.state = "running";
212
+ return;
213
+ }
214
+ this.state = "compacting";
215
+ getLogger().debug({ s: "controller", kind: req.kind }, "compacting");
216
+ const started = this.host.compact({
217
+ customInstructions: req.summary,
218
+ // onComplete is a fallback resume terminus; the authoritative one is the
219
+ // session_compact event. Whichever lands first wins (resume is idempotent).
220
+ onComplete: () => {
221
+ if (this.state === "compacting") void this.resume();
222
+ },
223
+ onError: (err: Error) => {
224
+ if (this.state !== "compacting") return;
225
+ if (isCompactNoop(err)) {
226
+ getLogger().debug({ s: "controller", err: err.message }, "compact no-op — resuming");
227
+ void this.resume();
228
+ } else {
229
+ getLogger().error({ s: "controller", err: err.message }, "compact error — resuming anyway");
230
+ void this.resume();
231
+ }
232
+ },
233
+ });
234
+ // host.compact returns false when no ctx is available (e.g. no live session);
235
+ // treat as a no-op and resume so awaiting callers don't hang.
236
+ if (!started) {
237
+ void this.resume();
238
+ }
239
+ }
240
+
241
+ private async resume(): Promise<void> {
242
+ const req = this.active;
243
+ this.state = "resuming";
244
+ getLogger().debug({ s: "controller", kind: req?.kind }, "resuming");
245
+ try {
246
+ await req?.onResume?.();
247
+ } catch (err: any) {
248
+ getLogger().error({ s: "controller", err: err?.message ?? String(err) }, "onResume failed");
249
+ }
250
+ if (req?.instruction) {
251
+ this.send(req.instruction, "instruction");
252
+ }
253
+ this.active = null;
254
+ this.state = "running";
255
+ const waiters = this.waiters;
256
+ this.waiters = [];
257
+ for (const w of waiters) w();
258
+ }
259
+ }