@agentex/agent 0.0.24 → 0.0.26

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 (164) hide show
  1. package/CHANGELOG.md +338 -0
  2. package/LICENSE +21 -0
  3. package/README.md +52 -0
  4. package/dist/derived.d.ts +5 -3
  5. package/dist/derived.d.ts.map +1 -1
  6. package/dist/derived.js +11 -7
  7. package/dist/derived.js.map +1 -1
  8. package/dist/index.d.ts +4 -0
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +3 -0
  11. package/dist/index.js.map +1 -1
  12. package/dist/providers/acp/index.d.ts +1 -1
  13. package/dist/providers/acp/index.d.ts.map +1 -1
  14. package/dist/providers/acp/index.js +5 -97
  15. package/dist/providers/acp/index.js.map +1 -1
  16. package/dist/providers/acp/session.d.ts +8 -1
  17. package/dist/providers/acp/session.d.ts.map +1 -1
  18. package/dist/providers/acp/session.js +94 -0
  19. package/dist/providers/acp/session.js.map +1 -1
  20. package/dist/providers/claude/attach.d.ts +8 -0
  21. package/dist/providers/claude/attach.d.ts.map +1 -0
  22. package/dist/providers/claude/attach.js +113 -0
  23. package/dist/providers/claude/attach.js.map +1 -0
  24. package/dist/providers/claude/goal-capability.d.ts +15 -0
  25. package/dist/providers/claude/goal-capability.d.ts.map +1 -0
  26. package/dist/providers/claude/goal-capability.js +20 -0
  27. package/dist/providers/claude/goal-capability.js.map +1 -0
  28. package/dist/providers/claude/index.d.ts.map +1 -1
  29. package/dist/providers/claude/index.js +8 -4
  30. package/dist/providers/claude/index.js.map +1 -1
  31. package/dist/providers/claude/session.d.ts +11 -9
  32. package/dist/providers/claude/session.d.ts.map +1 -1
  33. package/dist/providers/claude/session.js +29 -14
  34. package/dist/providers/claude/session.js.map +1 -1
  35. package/dist/providers/codex/attach.d.ts +9 -0
  36. package/dist/providers/codex/attach.d.ts.map +1 -0
  37. package/dist/providers/codex/attach.js +93 -0
  38. package/dist/providers/codex/attach.js.map +1 -0
  39. package/dist/providers/codex/goal-capability.d.ts +13 -0
  40. package/dist/providers/codex/goal-capability.d.ts.map +1 -0
  41. package/dist/providers/codex/goal-capability.js +18 -0
  42. package/dist/providers/codex/goal-capability.js.map +1 -0
  43. package/dist/providers/codex/index.d.ts +1 -0
  44. package/dist/providers/codex/index.d.ts.map +1 -1
  45. package/dist/providers/codex/index.js +9 -6
  46. package/dist/providers/codex/index.js.map +1 -1
  47. package/dist/providers/codex/session.d.ts +11 -7
  48. package/dist/providers/codex/session.d.ts.map +1 -1
  49. package/dist/providers/codex/session.js +24 -12
  50. package/dist/providers/codex/session.js.map +1 -1
  51. package/dist/providers/codex/transcript-normalize.d.ts +28 -0
  52. package/dist/providers/codex/transcript-normalize.d.ts.map +1 -0
  53. package/dist/providers/codex/transcript-normalize.js +191 -0
  54. package/dist/providers/codex/transcript-normalize.js.map +1 -0
  55. package/dist/providers/cursor/index.d.ts.map +1 -1
  56. package/dist/providers/cursor/index.js +2 -2
  57. package/dist/providers/cursor/index.js.map +1 -1
  58. package/dist/providers/openclaw/index.d.ts.map +1 -1
  59. package/dist/providers/openclaw/index.js +2 -2
  60. package/dist/providers/openclaw/index.js.map +1 -1
  61. package/dist/providers/opencode/index.d.ts.map +1 -1
  62. package/dist/providers/opencode/index.js +3 -5
  63. package/dist/providers/opencode/index.js.map +1 -1
  64. package/dist/providers/pi/index.d.ts.map +1 -1
  65. package/dist/providers/pi/index.js +3 -5
  66. package/dist/providers/pi/index.js.map +1 -1
  67. package/dist/providers/process/index.d.ts.map +1 -1
  68. package/dist/providers/process/index.js +2 -2
  69. package/dist/providers/process/index.js.map +1 -1
  70. package/dist/registry.d.ts +0 -1
  71. package/dist/registry.d.ts.map +1 -1
  72. package/dist/registry.js +0 -4
  73. package/dist/registry.js.map +1 -1
  74. package/dist/sessions/index.d.ts +3 -0
  75. package/dist/sessions/index.d.ts.map +1 -0
  76. package/dist/sessions/index.js +2 -0
  77. package/dist/sessions/index.js.map +1 -0
  78. package/dist/sessions/record.d.ts +43 -0
  79. package/dist/sessions/record.d.ts.map +1 -0
  80. package/dist/sessions/record.js +85 -0
  81. package/dist/sessions/record.js.map +1 -0
  82. package/dist/types.d.ts +119 -0
  83. package/dist/types.d.ts.map +1 -1
  84. package/dist/types.js.map +1 -1
  85. package/dist/utils/uuid.d.ts +7 -1
  86. package/dist/utils/uuid.d.ts.map +1 -1
  87. package/dist/utils/uuid.js +21 -1
  88. package/dist/utils/uuid.js.map +1 -1
  89. package/package.json +64 -7
  90. package/src/derived.ts +311 -0
  91. package/src/goals/controller.ts +442 -0
  92. package/src/goals/index.ts +21 -0
  93. package/src/goals/normalize.ts +173 -0
  94. package/src/goals/sentinel.ts +90 -0
  95. package/src/index.ts +270 -0
  96. package/src/providers/_shared/http-agent.ts +304 -0
  97. package/src/providers/acp/index.ts +103 -0
  98. package/src/providers/acp/parse.ts +131 -0
  99. package/src/providers/acp/session.ts +744 -0
  100. package/src/providers/claude/attach.ts +147 -0
  101. package/src/providers/claude/codec.ts +43 -0
  102. package/src/providers/claude/execute.ts +300 -0
  103. package/src/providers/claude/goal-capability.ts +21 -0
  104. package/src/providers/claude/index.ts +72 -0
  105. package/src/providers/claude/mcp.ts +82 -0
  106. package/src/providers/claude/parse.ts +824 -0
  107. package/src/providers/claude/session.ts +1192 -0
  108. package/src/providers/claude/transcript.ts +555 -0
  109. package/src/providers/codex/attach.ts +123 -0
  110. package/src/providers/codex/codec.ts +50 -0
  111. package/src/providers/codex/execute.ts +337 -0
  112. package/src/providers/codex/goal-capability.ts +19 -0
  113. package/src/providers/codex/index.ts +57 -0
  114. package/src/providers/codex/modes.ts +159 -0
  115. package/src/providers/codex/parse.ts +691 -0
  116. package/src/providers/codex/plan-mode.ts +49 -0
  117. package/src/providers/codex/session.ts +1287 -0
  118. package/src/providers/codex/transcript-normalize.ts +197 -0
  119. package/src/providers/codex/transcript.ts +487 -0
  120. package/src/providers/codex/usage-scanner.ts +178 -0
  121. package/src/providers/copilot/index.ts +19 -0
  122. package/src/providers/cursor/codec.ts +44 -0
  123. package/src/providers/cursor/execute.ts +271 -0
  124. package/src/providers/cursor/index.ts +25 -0
  125. package/src/providers/cursor/parse.ts +288 -0
  126. package/src/providers/gemini/index.ts +21 -0
  127. package/src/providers/openclaw/codec.ts +40 -0
  128. package/src/providers/openclaw/execute.ts +19 -0
  129. package/src/providers/openclaw/index.ts +29 -0
  130. package/src/providers/opencode/codec.ts +50 -0
  131. package/src/providers/opencode/event-parse.ts +141 -0
  132. package/src/providers/opencode/execute.ts +251 -0
  133. package/src/providers/opencode/http-session.ts +427 -0
  134. package/src/providers/opencode/index.ts +30 -0
  135. package/src/providers/opencode/parse.ts +203 -0
  136. package/src/providers/opencode/server.ts +0 -0
  137. package/src/providers/pi/codec.ts +44 -0
  138. package/src/providers/pi/execute.ts +297 -0
  139. package/src/providers/pi/index.ts +30 -0
  140. package/src/providers/pi/parse.ts +231 -0
  141. package/src/providers/pi/session.ts +381 -0
  142. package/src/providers/process/execute.ts +148 -0
  143. package/src/providers/process/index.ts +52 -0
  144. package/src/registry.ts +40 -0
  145. package/src/sessions/index.ts +8 -0
  146. package/src/sessions/record.ts +108 -0
  147. package/src/types.ts +1638 -0
  148. package/src/utils/ask-user-question.ts +57 -0
  149. package/src/utils/auth.ts +661 -0
  150. package/src/utils/binary.ts +179 -0
  151. package/src/utils/endpoint.ts +172 -0
  152. package/src/utils/env.ts +63 -0
  153. package/src/utils/execute-all.ts +68 -0
  154. package/src/utils/exit-plan-mode.ts +40 -0
  155. package/src/utils/instructions.ts +427 -0
  156. package/src/utils/process.ts +223 -0
  157. package/src/utils/runtime-config.ts +100 -0
  158. package/src/utils/runtime-homes.ts +49 -0
  159. package/src/utils/skill-commands.ts +493 -0
  160. package/src/utils/skills.ts +500 -0
  161. package/src/utils/template.ts +16 -0
  162. package/src/utils/tool-names.ts +51 -0
  163. package/src/utils/uuid.ts +21 -0
  164. package/src/utils/workspace.ts +156 -0
@@ -0,0 +1,442 @@
1
+ import type {
2
+ ClearGoalResult,
3
+ GoalCapability,
4
+ GoalOptions,
5
+ GoalSentinel,
6
+ GoalState,
7
+ SendHandle,
8
+ SetGoalResult,
9
+ StreamEvent,
10
+ TurnResult,
11
+ } from "../types.js";
12
+ import {
13
+ GOAL_OBJECTIVE_MAX,
14
+ type GoalStatusEvent,
15
+ type NormalizedGoalFields,
16
+ goalStateFromEvent,
17
+ isTerminalGoalStatus,
18
+ } from "./normalize.js";
19
+ import {
20
+ buildKickoffMessage,
21
+ createDefaultSentinel,
22
+ defaultNudge,
23
+ runSentinel,
24
+ } from "./sentinel.js";
25
+
26
+ /**
27
+ * Everything a session must supply so the `GoalController` can run goals on its
28
+ * behalf. The two wiring seams every session already has — an event-dispatch
29
+ * point and a turn-settle point — are exposed via `dispatch` and the session
30
+ * calling `observe`/`onTurnSettled`.
31
+ */
32
+ export interface GoalControllerDeps {
33
+ /** Provider type, for `goal_status` event base fields + display. */
34
+ providerType: string;
35
+ /** Current session/thread id for event base fields. */
36
+ getSessionId: () => string | null;
37
+ /**
38
+ * Drive one turn — a continuation, the initial kickoff, the default
39
+ * sentinel's meta-turn, or (for native providers) the `/goal` command itself.
40
+ * Usually `session.send` bound to the session.
41
+ */
42
+ send: (message: string) => Promise<SendHandle>;
43
+ /**
44
+ * Push a synthetic `goal_status` event to the host through the SAME channel
45
+ * the session uses for parsed events. Only the controller calls this, and
46
+ * only for emulation transitions — native transitions come from the parser.
47
+ */
48
+ dispatch: (event: StreamEvent) => void;
49
+ /** Transcript path for sentinel context. */
50
+ getTranscriptPath?: () => string | null;
51
+ /** This provider's goal capability (undefined → treated as emulated). */
52
+ capability?: GoalCapability;
53
+ /**
54
+ * Native arm. Implement for providers with native goal support (Claude sends
55
+ * `/goal <objective>`; Codex seeds thread state). Return true if armed
56
+ * natively — the controller then relies on parser-emitted `goal_status`
57
+ * events and does NOT run the emulation loop. Return false to fall back to
58
+ * emulation. Only invoked for `enforce:"provider"` with no custom sentinel.
59
+ */
60
+ armNative?: (objective: string) => Promise<boolean>;
61
+ /** Native clear (Claude sends `/goal clear`; Codex clears thread state). */
62
+ clearNative?: (reason: "cleared" | "blocked") => Promise<void>;
63
+ }
64
+
65
+ type Mode = "idle" | "native" | "emulate" | "advisory";
66
+
67
+ /**
68
+ * Capability descriptor for providers with no native goal surface. The library
69
+ * emulation engine enforces the goal via a sentinel + continuation loop.
70
+ */
71
+ export const EMULATED_GOAL_CAPABILITY: GoalCapability = {
72
+ mechanism: "emulated",
73
+ enforced: true,
74
+ statuses: ["active", "paused", "met", "blocked", "cleared"],
75
+ clears: "manual",
76
+ telemetry: false,
77
+ };
78
+
79
+ function nowIso(): string {
80
+ return new Date().toISOString();
81
+ }
82
+
83
+ async function safeBool(p: Promise<boolean>): Promise<boolean> {
84
+ try {
85
+ return await p;
86
+ } catch {
87
+ return false;
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Provider-agnostic goal engine. Each session constructs one and delegates
93
+ * `setGoal`/`clearGoal`/`getGoal` to it, calls `observe(event)` for every
94
+ * normalized event it dispatches, and `onTurnSettled(result)` whenever a turn
95
+ * resolves. See internal-docs/spec-goals.md §8.
96
+ */
97
+ export class GoalController {
98
+ private state: GoalState | null = null;
99
+ private mode: Mode = "idle";
100
+ private sentinel: GoalSentinel | null = null;
101
+ private maxIterations = 12;
102
+ private iterations = 0;
103
+ /** Guards re-entrancy: meta/nested settles during evaluation are ignored. */
104
+ private evaluating = false;
105
+ /** One-shot: skip advancing the loop for the next settle (set on interrupt). */
106
+ private suspendNext = false;
107
+ /** Bumped on every set/clear so a stale in-flight loop cancels itself. */
108
+ private generation = 0;
109
+
110
+ constructor(private readonly deps: GoalControllerDeps) {}
111
+
112
+ getGoal(): GoalState | null {
113
+ return this.state;
114
+ }
115
+
116
+ /**
117
+ * True when a non-terminal goal is active. Sessions use this to decide whether
118
+ * to parse + `observe()` native goal events even when the host attached no
119
+ * `onEvent` handler — so `getGoal()` stays accurate without a subscriber.
120
+ */
121
+ isTracking(): boolean {
122
+ return this.state !== null && !isTerminalGoalStatus(this.state.status);
123
+ }
124
+
125
+ /**
126
+ * Signal that the active turn was interrupted/aborted by the host. For an
127
+ * emulated goal this pauses the loop for one settle so the interrupt doesn't
128
+ * immediately trigger a fresh continuation turn. The goal stays active; the
129
+ * next normal turn (or a new setGoal) resumes it. clearGoal() abandons it.
130
+ */
131
+ notifyInterrupted(): void {
132
+ if (this.mode === "emulate") this.suspendNext = true;
133
+ }
134
+
135
+ /**
136
+ * Restore goal state on resume — reporting only. Use with `goalStateFromEvent`
137
+ * / `latestGoalFromEvents` after reading a resumed session's transcript so
138
+ * `getGoal()` reflects the prior goal. To resume ENFORCEMENT (re-arm the
139
+ * sentinel/loop), call `setGoal` again; hydrate does not restart the loop.
140
+ */
141
+ hydrate(state: GoalState): void {
142
+ this.state = state;
143
+ this.mode = "idle";
144
+ }
145
+
146
+ async setGoal(objective: string, options: GoalOptions = {}): Promise<SetGoalResult> {
147
+ if (typeof objective !== "string" || objective.trim() === "") {
148
+ throw new Error("setGoal: objective must be a non-empty string");
149
+ }
150
+ if (objective.length > GOAL_OBJECTIVE_MAX) {
151
+ throw new RangeError(
152
+ `setGoal: objective exceeds ${GOAL_OBJECTIVE_MAX} chars (got ${objective.length}); point the goal at a file instead`,
153
+ );
154
+ }
155
+
156
+ // Replace any active goal — emit a synthetic `cleared` for the old one.
157
+ if (this.state && !isTerminalGoalStatus(this.state.status)) {
158
+ this.transition({
159
+ objective: this.state.objective,
160
+ status: "cleared",
161
+ met: false,
162
+ enforced: this.state.enforced,
163
+ source: "host",
164
+ });
165
+ }
166
+
167
+ this.generation++;
168
+ const gen = this.generation;
169
+ this.iterations = 0;
170
+ this.maxIterations = options.maxIterations ?? 12;
171
+ this.sentinel = null;
172
+ this.suspendNext = false;
173
+
174
+ const enforce = options.enforce ?? "provider";
175
+ const cap = this.deps.capability;
176
+ const customSentinel = options.sentinel;
177
+
178
+ // --- advisory: record only, never gate ---
179
+ if (enforce === "advisory") {
180
+ this.mode = "advisory";
181
+ // Best-effort seed native state so a model-tools provider still "knows"
182
+ // the goal, but without any enforcement loop.
183
+ let nativeSeeded = false;
184
+ if (cap?.mechanism === "model-tools" && this.deps.armNative) {
185
+ nativeSeeded = await safeBool(this.deps.armNative(objective));
186
+ }
187
+ if (gen !== this.generation) return { armed: false, mechanism: "emulated" };
188
+ if (nativeSeeded) {
189
+ // The native provider will emit its own `active` goal_status; set
190
+ // optimistic state WITHOUT dispatching (mirrors native mode) so the host
191
+ // doesn't see a duplicate `active`.
192
+ this.state = {
193
+ objective,
194
+ status: "active",
195
+ met: false,
196
+ enforced: false,
197
+ source: "host",
198
+ updatedAt: nowIso(),
199
+ };
200
+ } else {
201
+ this.transition({
202
+ objective,
203
+ status: "active",
204
+ met: false,
205
+ enforced: false,
206
+ source: "host",
207
+ });
208
+ }
209
+ return { armed: true, mechanism: cap?.mechanism ?? "emulated" };
210
+ }
211
+
212
+ // --- native passthrough (default, no custom sentinel) ---
213
+ const wantNative = enforce === "provider" && !customSentinel && !!this.deps.armNative;
214
+ if (wantNative) {
215
+ const armed = await safeBool(this.deps.armNative!(objective));
216
+ if (gen !== this.generation) return { armed: false, mechanism: "emulated" };
217
+ if (armed) {
218
+ this.mode = "native";
219
+ // Native providers emit goal_status through their parser; set optimistic
220
+ // state for immediate getGoal() and let `observe` reconcile. Do NOT
221
+ // dispatch here (the parser is the sole emitter in native mode).
222
+ this.state = {
223
+ objective,
224
+ status: "active",
225
+ met: false,
226
+ enforced: cap?.enforced ?? true,
227
+ source: "host",
228
+ updatedAt: nowIso(),
229
+ };
230
+ return {
231
+ armed: true,
232
+ mechanism: cap?.mechanism === "model-tools" ? "model-tools" : "sentinel",
233
+ };
234
+ }
235
+ // Native arm failed → fall through to emulation.
236
+ }
237
+
238
+ // --- emulation (forced, custom sentinel, or native fallback) ---
239
+ this.mode = "emulate";
240
+ this.sentinel = customSentinel ?? this.makeDefaultSentinel();
241
+ this.transition({
242
+ objective,
243
+ status: "active",
244
+ met: false,
245
+ enforced: true,
246
+ source: "agentex",
247
+ });
248
+ this.kickoff(objective, gen);
249
+ return { armed: true, mechanism: "emulated" };
250
+ }
251
+
252
+ async clearGoal(options: { reason?: "cleared" | "blocked" } = {}): Promise<ClearGoalResult> {
253
+ if (!this.state || isTerminalGoalStatus(this.state.status)) {
254
+ return { cleared: false };
255
+ }
256
+ const reason = options.reason ?? "cleared";
257
+ const objective = this.state.objective;
258
+ const enforced = this.state.enforced;
259
+ const wasNative = this.mode === "native";
260
+
261
+ this.generation++; // cancel any in-flight emulation loop
262
+ this.mode = "idle";
263
+ this.sentinel = null;
264
+
265
+ if (wasNative && this.deps.clearNative) {
266
+ await this.deps.clearNative(reason).catch(() => {});
267
+ if (reason === "blocked") {
268
+ // Native providers (e.g. Codex) have no host-asserted "blocked" — their
269
+ // clear emits a `cleared` notification. Emit `blocked` synthetically so
270
+ // the host sees the intended terminal state; the observe terminal-guard
271
+ // then ignores the provider's trailing `cleared`.
272
+ this.transition({
273
+ objective,
274
+ status: "blocked",
275
+ met: false,
276
+ enforced,
277
+ source: "host",
278
+ blockedReason: "needs_input",
279
+ });
280
+ } else {
281
+ // Cleared: rely on the provider's authoritative `cleared` notification;
282
+ // set optimistic state (no dispatch) so getGoal() is immediate.
283
+ this.state = {
284
+ objective,
285
+ status: "cleared",
286
+ met: false,
287
+ enforced,
288
+ source: "host",
289
+ updatedAt: nowIso(),
290
+ };
291
+ }
292
+ return { cleared: true };
293
+ }
294
+
295
+ const fields: NormalizedGoalFields = {
296
+ objective,
297
+ status: reason === "blocked" ? "blocked" : "cleared",
298
+ met: false,
299
+ enforced,
300
+ source: "host",
301
+ };
302
+ if (reason === "blocked") fields.blockedReason = "needs_input";
303
+ this.transition(fields);
304
+ return { cleared: true };
305
+ }
306
+
307
+ /**
308
+ * Called by the session for every normalized event it dispatches to the host.
309
+ * In native mode the parser is the source of truth for goal_status; adopt it.
310
+ */
311
+ observe(event: StreamEvent): void {
312
+ if (event.type !== "goal_status") return;
313
+ // Once a goal is terminal, suppress stale late events FOR THE SAME goal (a
314
+ // clear-time `active`, or a `cleared` after a host-driven `blocked`) so they
315
+ // can't resurrect/downgrade it — but accept a genuinely NEW goal (a
316
+ // different, non-empty objective, e.g. the model created another).
317
+ if (this.state && isTerminalGoalStatus(this.state.status)) {
318
+ const isNewGoal = !!event.objective && event.objective !== this.state.objective;
319
+ if (!isNewGoal) return;
320
+ }
321
+ let next = goalStateFromEvent(event);
322
+ // Defensive: a provider's terminal event (e.g. Codex `thread/goal/cleared`)
323
+ // may omit the objective. Don't let an empty objective erase the known one.
324
+ if (!next.objective && this.state?.objective) {
325
+ next = { ...next, objective: this.state.objective };
326
+ }
327
+ this.state = next;
328
+ if (isTerminalGoalStatus(next.status)) {
329
+ this.mode = "idle";
330
+ this.sentinel = null;
331
+ }
332
+ }
333
+
334
+ /**
335
+ * Called by the session whenever a turn settles. Only the emulation engine
336
+ * acts here; native/advisory goals are driven by provider events.
337
+ */
338
+ async onTurnSettled(turn: TurnResult): Promise<void> {
339
+ if (this.mode !== "emulate") return;
340
+ if (this.evaluating) return; // ignore the default sentinel's meta-turn + nested settles
341
+ if (this.suspendNext) { this.suspendNext = false; return; } // interrupted → pause one turn
342
+ if (turn.status !== "completed") return; // aborted/timeout/failed/max_* → don't advance
343
+ if (!this.state || isTerminalGoalStatus(this.state.status)) return;
344
+ if (!this.sentinel) return;
345
+
346
+ const gen = this.generation;
347
+ const objective = this.state.objective;
348
+ this.evaluating = true;
349
+ try {
350
+ const verdict = await runSentinel(this.sentinel, {
351
+ objective,
352
+ lastTurn: turn,
353
+ transcriptPath: this.deps.getTranscriptPath?.() ?? null,
354
+ iterations: this.iterations,
355
+ });
356
+ if (gen !== this.generation) return; // goal replaced/cleared during eval
357
+
358
+ if (verdict.met) {
359
+ this.transition({
360
+ objective,
361
+ status: "met",
362
+ met: true,
363
+ enforced: true,
364
+ source: "sentinel",
365
+ });
366
+ this.mode = "idle";
367
+ this.sentinel = null;
368
+ return;
369
+ }
370
+
371
+ this.iterations++;
372
+ if (this.iterations >= this.maxIterations) {
373
+ this.transition({
374
+ objective,
375
+ status: "blocked",
376
+ met: false,
377
+ enforced: true,
378
+ source: "agentex",
379
+ blockedReason: "max_iterations",
380
+ });
381
+ this.mode = "idle";
382
+ this.sentinel = null;
383
+ return;
384
+ }
385
+
386
+ // Drive one continuation turn. Don't await its result — its own settle
387
+ // re-enters onTurnSettled and continues the loop.
388
+ const nudge = verdict.nudge ?? defaultNudge(objective, this.iterations);
389
+ void this.deps.send(nudge).catch(() => {});
390
+ } finally {
391
+ this.evaluating = false;
392
+ }
393
+ }
394
+
395
+ // ---- internals ----
396
+
397
+ private kickoff(objective: string, gen: number): void {
398
+ if (gen !== this.generation) return;
399
+ void this.deps.send(buildKickoffMessage(objective)).catch(() => {});
400
+ }
401
+
402
+ private makeDefaultSentinel(): GoalSentinel {
403
+ return createDefaultSentinel({
404
+ metaSend: async (message) => {
405
+ const handle = await this.deps.send(message);
406
+ return handle.result;
407
+ },
408
+ });
409
+ }
410
+
411
+ /** Build, record, and dispatch a synthetic goal_status transition. */
412
+ private transition(fields: NormalizedGoalFields): void {
413
+ const event = this.buildEvent(fields);
414
+ this.state = goalStateFromEvent(event);
415
+ this.deps.dispatch(event);
416
+ }
417
+
418
+ private buildEvent(fields: NormalizedGoalFields): GoalStatusEvent {
419
+ const event: GoalStatusEvent = {
420
+ type: "goal_status",
421
+ objective: fields.objective,
422
+ status: fields.status,
423
+ met: fields.met,
424
+ enforced: fields.enforced,
425
+ source: fields.source,
426
+ timestamp: nowIso(),
427
+ providerType: this.deps.providerType,
428
+ sessionId: this.deps.getSessionId(),
429
+ messageId: null,
430
+ eventId: null,
431
+ turnId: null,
432
+ parentToolCallId: null,
433
+ raw: { synthetic: "goal", ...fields },
434
+ };
435
+ if (fields.blockedReason !== undefined) event.blockedReason = fields.blockedReason;
436
+ if (fields.tokensUsed !== undefined) event.tokensUsed = fields.tokensUsed;
437
+ if (fields.timeUsedSeconds !== undefined) event.timeUsedSeconds = fields.timeUsedSeconds;
438
+ if (fields.tokenBudget !== undefined) event.tokenBudget = fields.tokenBudget;
439
+ if (this.mode === "emulate") event.iterations = this.iterations;
440
+ return event;
441
+ }
442
+ }
@@ -0,0 +1,21 @@
1
+ export { GoalController, EMULATED_GOAL_CAPABILITY } from "./controller.js";
2
+ export type { GoalControllerDeps } from "./controller.js";
3
+ export {
4
+ GOAL_OBJECTIVE_MAX,
5
+ CODEX_GOAL_TOOLS,
6
+ isTerminalGoalStatus,
7
+ normalizeClaudeGoalAttachment,
8
+ normalizeCodexGoalStatus,
9
+ normalizeCodexGoalRecord,
10
+ goalStateFromEvent,
11
+ latestGoalFromEvents,
12
+ } from "./normalize.js";
13
+ export type { GoalStatusEvent, NormalizedGoalFields } from "./normalize.js";
14
+ export {
15
+ runSentinel,
16
+ createDefaultSentinel,
17
+ buildKickoffMessage,
18
+ buildAssessmentPrompt,
19
+ parseAssessment,
20
+ defaultNudge,
21
+ } from "./sentinel.js";
@@ -0,0 +1,173 @@
1
+ import type {
2
+ GoalBlockedReason,
3
+ GoalSource,
4
+ GoalState,
5
+ GoalStatus,
6
+ StreamEvent,
7
+ } from "../types.js";
8
+
9
+ /** Max objective length, matching both native providers (Claude + Codex). */
10
+ export const GOAL_OBJECTIVE_MAX = 4000;
11
+
12
+ /** The `goal_status` member of the StreamEvent union. */
13
+ export type GoalStatusEvent = Extract<StreamEvent, { type: "goal_status" }>;
14
+
15
+ /**
16
+ * Codex's goal-management tool names. These surface as ordinary
17
+ * `tool_call`/`tool_result` events, NOT as `goal_status` — goal state is keyed
18
+ * off the authoritative `thread_goal_updated` notification instead. This Set is
19
+ * a host-facing recognizer for code that wants to spot the model managing its
20
+ * goal from the tool stream; the library does not consume it internally.
21
+ */
22
+ export const CODEX_GOAL_TOOLS = new Set(["get_goal", "create_goal", "update_goal"]);
23
+
24
+ /**
25
+ * Normalized goal fields — everything a `goal_status` event needs except the
26
+ * `BaseStreamEventFields` envelope. Parsers fill the envelope; the controller
27
+ * fills it for synthetic (emulation) transitions.
28
+ */
29
+ export interface NormalizedGoalFields {
30
+ objective: string;
31
+ status: GoalStatus;
32
+ met: boolean;
33
+ enforced: boolean;
34
+ source: GoalSource;
35
+ blockedReason?: GoalBlockedReason;
36
+ tokensUsed?: number;
37
+ timeUsedSeconds?: number;
38
+ tokenBudget?: number;
39
+ }
40
+
41
+ /** A goal status is terminal when no further work happens against it. */
42
+ export function isTerminalGoalStatus(status: GoalStatus): boolean {
43
+ return status === "met" || status === "cleared" || status === "blocked";
44
+ }
45
+
46
+ function numberOrUndefined(value: unknown): number | undefined {
47
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
48
+ }
49
+
50
+ /**
51
+ * Normalize a Claude `goal_status` attachment
52
+ * (`{type:"goal_status", met, sentinel, condition}`) into goal fields. Returns
53
+ * null when the object isn't a goal_status attachment. Claude goals are always
54
+ * sentinel-enforced and binary (`met:false → active`, `met:true → met`).
55
+ */
56
+ export function normalizeClaudeGoalAttachment(
57
+ attachment: Record<string, unknown> | null | undefined,
58
+ ): NormalizedGoalFields | null {
59
+ if (!attachment || attachment["type"] !== "goal_status") return null;
60
+ const condition = typeof attachment["condition"] === "string" ? attachment["condition"] : "";
61
+ const met = attachment["met"] === true;
62
+ return {
63
+ objective: condition,
64
+ status: met ? "met" : "active",
65
+ met,
66
+ enforced: true,
67
+ source: "sentinel",
68
+ };
69
+ }
70
+
71
+ /**
72
+ * Map a raw Codex goal status string into the normalized ladder. Codex's
73
+ * documented statuses are `active|paused|complete|budget-limited`; we also
74
+ * tolerate the reverse-engineered/aliased spellings (`completed`, `achieved`,
75
+ * `budget_limited`, `budgetLimited`, `pursuing`, `blocked`, `cleared`) so the
76
+ * parser survives wire drift.
77
+ */
78
+ export function normalizeCodexGoalStatus(raw: string | null | undefined): {
79
+ status: GoalStatus;
80
+ met: boolean;
81
+ blockedReason?: GoalBlockedReason;
82
+ } {
83
+ switch ((raw ?? "").toLowerCase()) {
84
+ case "complete":
85
+ case "completed":
86
+ case "achieved":
87
+ return { status: "met", met: true };
88
+ case "paused":
89
+ return { status: "paused", met: false };
90
+ case "budget-limited":
91
+ case "budget_limited":
92
+ case "budgetlimited": // camelCase `budgetLimited`, lowercased
93
+ return { status: "blocked", met: false, blockedReason: "budget" };
94
+ case "blocked":
95
+ return { status: "blocked", met: false, blockedReason: "needs_input" };
96
+ case "cleared":
97
+ return { status: "cleared", met: false };
98
+ case "active":
99
+ case "pursuing":
100
+ default:
101
+ return { status: "active", met: false };
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Normalize a Codex goal record (`{objective, status, tokensUsed?,
107
+ * timeUsedSeconds?, tokenBudget?}`) — as carried by `thread_goal_updated` /
108
+ * `thread/goal/updated` notifications or `get_goal`/`update_goal` tool output —
109
+ * into goal fields. Codex goals are advisory (`enforced:false`). `source`
110
+ * reflects who drove the transition: the model can only ever write `active`
111
+ * (create) and `complete` (update); other statuses come from user/system.
112
+ */
113
+ export function normalizeCodexGoalRecord(
114
+ goal: Record<string, unknown> | null | undefined,
115
+ source: GoalSource = "model",
116
+ ): NormalizedGoalFields | null {
117
+ if (!goal || typeof goal !== "object") return null;
118
+ const objective = typeof goal["objective"] === "string" ? goal["objective"] : "";
119
+ const { status, met, blockedReason } = normalizeCodexGoalStatus(
120
+ typeof goal["status"] === "string" ? goal["status"] : null,
121
+ );
122
+ // The model only authors active/complete; paused/blocked/cleared are system.
123
+ const resolvedSource: GoalSource =
124
+ status === "active" || status === "met" ? source : "agentex";
125
+ const fields: NormalizedGoalFields = {
126
+ objective,
127
+ status,
128
+ met,
129
+ enforced: false,
130
+ source: resolvedSource,
131
+ };
132
+ if (blockedReason) fields.blockedReason = blockedReason;
133
+ const tokensUsed = numberOrUndefined(goal["tokensUsed"] ?? goal["tokens_used"]);
134
+ const timeUsed = numberOrUndefined(goal["timeUsedSeconds"] ?? goal["time_used_seconds"]);
135
+ const tokenBudget = numberOrUndefined(goal["tokenBudget"] ?? goal["token_budget"]);
136
+ if (tokensUsed !== undefined) fields.tokensUsed = tokensUsed;
137
+ if (timeUsed !== undefined) fields.timeUsedSeconds = timeUsed;
138
+ if (tokenBudget !== undefined) fields.tokenBudget = tokenBudget;
139
+ return fields;
140
+ }
141
+
142
+ /**
143
+ * Fold a sequence of events into the latest goal state — the building block for
144
+ * resume. Pass the events read back from a transcript (`provider.transcript.read`)
145
+ * or any StreamEvent stream; the most recent `goal_status` wins. Returns null
146
+ * when no goal was ever set (or the goal's last state was terminal and you want
147
+ * to treat that as "no active goal", which the caller decides via `.status`).
148
+ */
149
+ export function latestGoalFromEvents(events: Iterable<StreamEvent>): GoalState | null {
150
+ let latest: GoalState | null = null;
151
+ for (const event of events) {
152
+ if (event.type === "goal_status") latest = goalStateFromEvent(event);
153
+ }
154
+ return latest;
155
+ }
156
+
157
+ /** Project a `goal_status` stream event back into the durable `GoalState`. */
158
+ export function goalStateFromEvent(event: GoalStatusEvent): GoalState {
159
+ const state: GoalState = {
160
+ objective: event.objective,
161
+ status: event.status,
162
+ met: event.met,
163
+ enforced: event.enforced,
164
+ source: event.source,
165
+ updatedAt: event.timestamp,
166
+ };
167
+ if (event.blockedReason !== undefined) state.blockedReason = event.blockedReason;
168
+ if (event.tokensUsed !== undefined) state.tokensUsed = event.tokensUsed;
169
+ if (event.timeUsedSeconds !== undefined) state.timeUsedSeconds = event.timeUsedSeconds;
170
+ if (event.tokenBudget !== undefined) state.tokenBudget = event.tokenBudget;
171
+ if (event.iterations !== undefined) state.iterations = event.iterations;
172
+ return state;
173
+ }