@moxxy/sdk 0.43.0 → 0.45.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 (246) hide show
  1. package/dist/agent-account-limit.d.ts +41 -0
  2. package/dist/agent-account-limit.d.ts.map +1 -0
  3. package/dist/agent-account-limit.js +64 -0
  4. package/dist/agent-account-limit.js.map +1 -0
  5. package/dist/agent-computer-use.d.ts +10 -0
  6. package/dist/agent-computer-use.d.ts.map +1 -0
  7. package/dist/agent-computer-use.js +24 -0
  8. package/dist/agent-computer-use.js.map +1 -0
  9. package/dist/agent-context-window.d.ts +22 -0
  10. package/dist/agent-context-window.d.ts.map +1 -0
  11. package/dist/agent-context-window.js +40 -0
  12. package/dist/agent-context-window.js.map +1 -0
  13. package/dist/agent-no-reply.d.ts +11 -0
  14. package/dist/agent-no-reply.d.ts.map +1 -0
  15. package/dist/agent-no-reply.js +13 -0
  16. package/dist/agent-no-reply.js.map +1 -0
  17. package/dist/agent-step.d.ts +14 -0
  18. package/dist/agent-step.d.ts.map +1 -0
  19. package/dist/agent-step.js +26 -0
  20. package/dist/agent-step.js.map +1 -0
  21. package/dist/agent-work.d.ts +34 -0
  22. package/dist/agent-work.d.ts.map +1 -0
  23. package/dist/agent-work.js +80 -0
  24. package/dist/agent-work.js.map +1 -0
  25. package/dist/attachment-payload.d.ts +95 -0
  26. package/dist/attachment-payload.d.ts.map +1 -0
  27. package/dist/attachment-payload.js +155 -0
  28. package/dist/attachment-payload.js.map +1 -0
  29. package/dist/auto-approve.d.ts +9 -0
  30. package/dist/auto-approve.d.ts.map +1 -1
  31. package/dist/auto-approve.js +18 -0
  32. package/dist/auto-approve.js.map +1 -1
  33. package/dist/channel-auth.d.ts +3 -3
  34. package/dist/channel-auth.d.ts.map +1 -1
  35. package/dist/channel-auth.js +1 -1
  36. package/dist/channel.d.ts +1 -1
  37. package/dist/client-session.d.ts +9 -5
  38. package/dist/client-session.d.ts.map +1 -1
  39. package/dist/computer-control.d.ts +11 -11
  40. package/dist/computer-control.d.ts.map +1 -1
  41. package/dist/computer-control.js +1 -1
  42. package/dist/computer-control.js.map +1 -1
  43. package/dist/conversation-history.d.ts +50 -0
  44. package/dist/conversation-history.d.ts.map +1 -0
  45. package/dist/conversation-history.js +227 -0
  46. package/dist/conversation-history.js.map +1 -0
  47. package/dist/cron.d.ts +58 -0
  48. package/dist/cron.d.ts.map +1 -0
  49. package/dist/cron.js +320 -0
  50. package/dist/cron.js.map +1 -0
  51. package/dist/define.d.ts +2 -1
  52. package/dist/define.d.ts.map +1 -1
  53. package/dist/define.js +1 -0
  54. package/dist/define.js.map +1 -1
  55. package/dist/event-store.d.ts +1 -1
  56. package/dist/event-store.d.ts.map +1 -1
  57. package/dist/event-store.js +1 -0
  58. package/dist/event-store.js.map +1 -1
  59. package/dist/events.d.ts +23 -1
  60. package/dist/events.d.ts.map +1 -1
  61. package/dist/file-diff.d.ts +31 -0
  62. package/dist/file-diff.d.ts.map +1 -0
  63. package/dist/file-diff.js +196 -0
  64. package/dist/file-diff.js.map +1 -0
  65. package/dist/index.d.ts +23 -7
  66. package/dist/index.d.ts.map +1 -1
  67. package/dist/index.js +17 -4
  68. package/dist/index.js.map +1 -1
  69. package/dist/mode/agent-step-loop.d.ts +13 -0
  70. package/dist/mode/agent-step-loop.d.ts.map +1 -0
  71. package/dist/mode/agent-step-loop.js +578 -0
  72. package/dist/mode/agent-step-loop.js.map +1 -0
  73. package/dist/mode/checkpoint-gate.d.ts +25 -0
  74. package/dist/mode/checkpoint-gate.d.ts.map +1 -0
  75. package/dist/mode/checkpoint-gate.js +150 -0
  76. package/dist/mode/checkpoint-gate.js.map +1 -0
  77. package/dist/mode/checkpoint.d.ts +6 -0
  78. package/dist/mode/checkpoint.d.ts.map +1 -1
  79. package/dist/mode/collect-stream.d.ts +2 -0
  80. package/dist/mode/collect-stream.d.ts.map +1 -1
  81. package/dist/mode/collect-stream.js +1 -1
  82. package/dist/mode/collect-stream.js.map +1 -1
  83. package/dist/mode/project-messages.d.ts +3 -1
  84. package/dist/mode/project-messages.d.ts.map +1 -1
  85. package/dist/mode/project-messages.js +122 -81
  86. package/dist/mode/project-messages.js.map +1 -1
  87. package/dist/mode/react-loop-options.d.ts +113 -0
  88. package/dist/mode/react-loop-options.d.ts.map +1 -0
  89. package/dist/mode/react-loop-options.js +2 -0
  90. package/dist/mode/react-loop-options.js.map +1 -0
  91. package/dist/mode/react-loop.d.ts +2 -99
  92. package/dist/mode/react-loop.d.ts.map +1 -1
  93. package/dist/mode/react-loop.js +7 -145
  94. package/dist/mode/react-loop.js.map +1 -1
  95. package/dist/mode.d.ts +19 -7
  96. package/dist/mode.d.ts.map +1 -1
  97. package/dist/mode.js +1 -1
  98. package/dist/mode.js.map +1 -1
  99. package/dist/npm-registry.d.ts +22 -0
  100. package/dist/npm-registry.d.ts.map +1 -0
  101. package/dist/npm-registry.js +54 -0
  102. package/dist/npm-registry.js.map +1 -0
  103. package/dist/permission.d.ts +31 -0
  104. package/dist/permission.d.ts.map +1 -1
  105. package/dist/process-invocation.d.ts +2 -0
  106. package/dist/process-invocation.d.ts.map +1 -1
  107. package/dist/process-invocation.js +2 -2
  108. package/dist/process-invocation.js.map +1 -1
  109. package/dist/process-tree.d.ts +9 -0
  110. package/dist/process-tree.d.ts.map +1 -0
  111. package/dist/process-tree.js +40 -0
  112. package/dist/process-tree.js.map +1 -0
  113. package/dist/provider-login-bridge.js +1 -1
  114. package/dist/provider-login-bridge.js.map +1 -1
  115. package/dist/provider.d.ts +408 -0
  116. package/dist/provider.d.ts.map +1 -1
  117. package/dist/question-draft.d.ts +19 -0
  118. package/dist/question-draft.d.ts.map +1 -0
  119. package/dist/question-draft.js +30 -0
  120. package/dist/question-draft.js.map +1 -0
  121. package/dist/question.d.ts +204 -0
  122. package/dist/question.d.ts.map +1 -0
  123. package/dist/question.js +141 -0
  124. package/dist/question.js.map +1 -0
  125. package/dist/questions.d.ts +7 -0
  126. package/dist/questions.d.ts.map +1 -0
  127. package/dist/questions.js +7 -0
  128. package/dist/questions.js.map +1 -0
  129. package/dist/resolvers.d.ts +14 -1
  130. package/dist/resolvers.d.ts.map +1 -1
  131. package/dist/resolvers.js +33 -21
  132. package/dist/resolvers.js.map +1 -1
  133. package/dist/schemas.d.ts +53 -53
  134. package/dist/schemas.d.ts.map +1 -1
  135. package/dist/schemas.js +3 -2
  136. package/dist/schemas.js.map +1 -1
  137. package/dist/server.d.ts +2 -0
  138. package/dist/server.d.ts.map +1 -1
  139. package/dist/server.js +3 -0
  140. package/dist/server.js.map +1 -1
  141. package/dist/session-like.d.ts +19 -1
  142. package/dist/session-like.d.ts.map +1 -1
  143. package/dist/starting-model.d.ts +12 -0
  144. package/dist/starting-model.d.ts.map +1 -0
  145. package/dist/starting-model.js +9 -0
  146. package/dist/starting-model.js.map +1 -0
  147. package/dist/tool-dispatch.d.ts.map +1 -1
  148. package/dist/tool-dispatch.js +10 -4
  149. package/dist/tool-dispatch.js.map +1 -1
  150. package/dist/tool-display.d.ts +79 -3
  151. package/dist/tool-display.d.ts.map +1 -1
  152. package/dist/tool-display.js +117 -3
  153. package/dist/tool-display.js.map +1 -1
  154. package/dist/tool-modes.d.ts +1 -1
  155. package/dist/tool-modes.d.ts.map +1 -1
  156. package/dist/tool-modes.js +8 -2
  157. package/dist/tool-modes.js.map +1 -1
  158. package/dist/tool-output.d.ts +15 -0
  159. package/dist/tool-output.d.ts.map +1 -0
  160. package/dist/tool-output.js +39 -0
  161. package/dist/tool-output.js.map +1 -0
  162. package/dist/tool.d.ts +14 -1
  163. package/dist/tool.d.ts.map +1 -1
  164. package/dist/tunnel.d.ts +3 -3
  165. package/dist/tunnel.d.ts.map +1 -1
  166. package/dist/tunnel.js +2 -2
  167. package/dist/workflow-approval.d.ts +9 -9
  168. package/dist/workflow-approval.d.ts.map +1 -1
  169. package/dist/workflow-approval.js +1 -1
  170. package/dist/workflow-approval.js.map +1 -1
  171. package/package.json +37 -5
  172. package/src/agent-account-limit.test.ts +87 -0
  173. package/src/agent-account-limit.ts +89 -0
  174. package/src/agent-computer-use.test.ts +26 -0
  175. package/src/agent-computer-use.ts +29 -0
  176. package/src/agent-context-window.ts +51 -0
  177. package/src/agent-no-reply.ts +16 -0
  178. package/src/agent-step.ts +26 -0
  179. package/src/agent-work.ts +102 -0
  180. package/src/attachment-payload.test.ts +165 -0
  181. package/src/attachment-payload.ts +187 -0
  182. package/src/auto-approve.test.ts +13 -1
  183. package/src/auto-approve.ts +19 -0
  184. package/src/channel-auth.ts +3 -3
  185. package/src/channel.ts +1 -1
  186. package/src/client-session.ts +9 -2
  187. package/src/computer-control.ts +1 -1
  188. package/src/conversation-history.test.ts +184 -0
  189. package/src/conversation-history.ts +272 -0
  190. package/src/cron.test.ts +280 -0
  191. package/src/cron.ts +355 -0
  192. package/src/define.test.ts +1 -1
  193. package/src/define.ts +3 -1
  194. package/src/event-store.ts +2 -1
  195. package/src/events.ts +23 -1
  196. package/src/file-diff.test.ts +103 -0
  197. package/src/file-diff.ts +208 -0
  198. package/src/index.ts +140 -1
  199. package/src/json-file-store.test.ts +1 -1
  200. package/src/loop-helpers.test.ts +15 -0
  201. package/src/mode/agent-step-loop.ts +645 -0
  202. package/src/mode/checkpoint-gate.ts +198 -0
  203. package/src/mode/checkpoint.ts +6 -0
  204. package/src/mode/collect-stream.ts +1 -1
  205. package/src/mode/project-messages.test.ts +118 -1
  206. package/src/mode/project-messages.ts +136 -77
  207. package/src/mode/react-loop-options.ts +124 -0
  208. package/src/mode/react-loop.ts +10 -296
  209. package/src/mode.ts +19 -7
  210. package/src/npm-registry.test.ts +95 -0
  211. package/src/npm-registry.ts +57 -0
  212. package/src/permission.ts +30 -0
  213. package/src/process-invocation.test.ts +25 -0
  214. package/src/process-invocation.ts +8 -3
  215. package/src/process-tree.ts +37 -0
  216. package/src/provider-login-bridge.ts +1 -1
  217. package/src/provider-schema-constraints.test.ts +1 -1
  218. package/src/provider-utils.test.ts +1 -1
  219. package/src/provider.ts +346 -0
  220. package/src/question-draft.test.ts +57 -0
  221. package/src/question-draft.ts +47 -0
  222. package/src/question.test.ts +130 -0
  223. package/src/question.ts +229 -0
  224. package/src/questions.ts +6 -0
  225. package/src/resolvers.test.ts +84 -1
  226. package/src/resolvers.ts +40 -27
  227. package/src/schemas.test.ts +5 -0
  228. package/src/schemas.ts +3 -2
  229. package/src/server.ts +3 -0
  230. package/src/session-like.ts +19 -1
  231. package/src/skill-mentions.test.ts +1 -1
  232. package/src/starting-model.test.ts +19 -0
  233. package/src/starting-model.ts +11 -0
  234. package/src/token-efficiency.test.ts +1 -1
  235. package/src/tool-dispatch.ts +10 -4
  236. package/src/tool-display.test.ts +119 -0
  237. package/src/tool-display.ts +162 -4
  238. package/src/tool-gating.test.ts +1 -1
  239. package/src/tool-icon.test.ts +1 -1
  240. package/src/tool-modes.test.ts +18 -1
  241. package/src/tool-modes.ts +6 -2
  242. package/src/tool-output.test.ts +46 -0
  243. package/src/tool-output.ts +46 -0
  244. package/src/tool.ts +14 -1
  245. package/src/tunnel.ts +3 -3
  246. package/src/workflow-approval.ts +1 -1
@@ -0,0 +1,124 @@
1
+ import type { ModeContext } from '../mode.js';
2
+ import type { AgentGoalStatus, TokenUsage } from '../provider.js';
3
+ import type { StopReason } from '../provider-utils.js';
4
+ import type { StuckLoopReport, StuckTripInfo } from '../tool-dispatch.js';
5
+ import type { TurnCheckpoint } from './checkpoint.js';
6
+ import type { CollectedToolUse } from './collect-stream.js';
7
+
8
+ /** What a clean provider call produced — input to {@link ReactLoopOptions.onProviderSuccess}. */
9
+ export interface ProviderSuccessInfo {
10
+ readonly text: string;
11
+ readonly stopReason: StopReason;
12
+ readonly toolUses: ReadonlyArray<CollectedToolUse>;
13
+ readonly usage?: TokenUsage;
14
+ readonly iteration: number;
15
+ }
16
+
17
+ /** A completed tool batch — input to {@link ReactLoopOptions.onToolBatchEnd}. */
18
+ export interface ToolBatchInfo {
19
+ readonly toolUses: ReadonlyArray<CollectedToolUse>;
20
+ readonly iteration: number;
21
+ }
22
+
23
+ /** Hooks return this to end the turn; the hook emits its own wrap-up events first. */
24
+ export interface StopDirective {
25
+ readonly action: 'stop';
26
+ }
27
+
28
+ export interface ReactLoopOptions {
29
+ /** Name stamped on `mode_iteration` events (e.g. `'default'`, `'goal'`). */
30
+ readonly strategyName: string;
31
+ /**
32
+ * Iteration cap when the context doesn't supply one. Default 500. May be
33
+ * `Number.POSITIVE_INFINITY` for an uncapped loop (goal mode): the run then
34
+ * ends only via a terminal signal (checkpoint/hook stop, abort, fatal
35
+ * error) — an explicit `ctx.maxIterations` still takes precedence.
36
+ */
37
+ readonly defaultMaxIterations?: number;
38
+ /** Prefix for provider-error messages (goal mode uses `'goal: '`). */
39
+ readonly errorPrefix?: string;
40
+ /**
41
+ * When set, an already-aborted signal is reported (with this reason) BEFORE
42
+ * the first iteration — goal/collab preflight. When omitted the first
43
+ * iteration's own abort check covers it with the generic reason.
44
+ */
45
+ readonly preflightAbortReason?: string;
46
+ /** Turn-end gates, run in declared order. Omit/empty → plain ReAct loop. */
47
+ readonly checkpoints?: ReadonlyArray<TurnCheckpoint>;
48
+ /**
49
+ * Hard cap on checkpoint `inject`/`retry` rounds per idle EPISODE — i.e.
50
+ * consecutive gate rounds with no tool work between them; the count resets
51
+ * whenever a tool batch executes (default 3). When exhausted the turn ends
52
+ * with the model's answer as-is plus a visible warning — a
53
+ * permanently-failing gate degrades loudly instead of looping forever.
54
+ */
55
+ readonly maxInjections?: number;
56
+ /** Clamp on injected feedback length in characters (default 16_384). */
57
+ readonly maxInjectChars?: number;
58
+ /**
59
+ * Stuck-loop policy + wording overrides; sensible defaults from
60
+ * `strategyName`. `action` picks what a detector trip does:
61
+ *
62
+ * - `'abort'` (default): fail the batch and end the turn with a fatal
63
+ * error — the historical behavior, right for attended modes where the
64
+ * user is present to redirect.
65
+ * - `'nudge'`: never stop. The batch still executes (repeated calls are
66
+ * usually legitimate work — re-running a failing build between edits),
67
+ * a visible warning + `extraOnStuck` events are emitted, the detector
68
+ * resets, and `nudgeText` (or a default) rides the next provider call
69
+ * as a volatile steer. For unattended modes (goal) where a heuristic
70
+ * must never kill the run.
71
+ */
72
+ readonly stuck?: Partial<StuckLoopReport> & {
73
+ readonly action?: 'abort' | 'nudge';
74
+ /** Volatile steer for `'nudge'` trips; default wording when omitted. */
75
+ readonly nudgeText?: (info: StuckTripInfo) => string;
76
+ };
77
+ /**
78
+ * Runs after compaction/elision, before the provider call. May return a
79
+ * volatile user message to ride ONLY the next call (collab's inbox/pause
80
+ * awareness) — never appended to the log; the cache strategy is told via
81
+ * `volatileTailCount` so its tail breakpoint stays ahead of it.
82
+ */
83
+ readonly onIterationStart?: (
84
+ ctx: ModeContext,
85
+ iteration: number,
86
+ ) => Promise<{ readonly volatileUserText?: string } | undefined>;
87
+ /**
88
+ * Runs after every clean provider call (reasoning already logged, tools not
89
+ * yet executed). Return `{action: 'stop'}` to end the turn — the hook emits
90
+ * its own wrap-up events first (goal mode's token-budget backstop).
91
+ */
92
+ readonly onProviderSuccess?: (
93
+ ctx: ModeContext,
94
+ info: ProviderSuccessInfo,
95
+ ) => Promise<StopDirective | undefined>;
96
+ /**
97
+ * Runs after a tool batch executes cleanly. Return `{action: 'stop'}` to
98
+ * end the turn (goal/collab terminal-tool detection: `goal_complete`,
99
+ * `collab_done`); the hook emits its own completion events first.
100
+ */
101
+ readonly onToolBatchEnd?: (
102
+ ctx: ModeContext,
103
+ info: ToolBatchInfo,
104
+ ) => Promise<StopDirective | undefined>;
105
+ /**
106
+ * Runs after an agent connection's turn that reported the state of the
107
+ * agent's own goal. Return `{action: 'stop'}` to end the turn (goal mode
108
+ * wraps up a met goal); the hook emits its own completion events first.
109
+ */
110
+ readonly onAgentGoal?: (ctx: ModeContext, status: AgentGoalStatus) => Promise<StopDirective | undefined>;
111
+ /**
112
+ * Agent connections only: the agent finished its own plan (Markdown). Plan
113
+ * mode shows it as its plan card and ends the turn; without this hook the
114
+ * plan is shown as the agent's message.
115
+ */
116
+ readonly onAgentPlan?: (ctx: ModeContext, plan: string) => Promise<StopDirective | undefined>;
117
+ /**
118
+ * Replaces the default "exceeded maxIterations" fatal error — the hook
119
+ * emits its own cap-reached events (goal mode adds a plugin event and its
120
+ * own wording). The loop returns right after.
121
+ */
122
+ readonly onMaxIterations?: (ctx: ModeContext, maxIterations: number) => Promise<void>;
123
+ }
124
+
@@ -1,10 +1,7 @@
1
1
  import { isContextOverflowError, runCompactionIfNeeded } from '../compactor-helpers.js';
2
2
  import { runElisionIfNeeded } from '../elision-helpers.js';
3
3
  import type { MoxxyEvent } from '../events.js';
4
- import { asPluginId } from '../ids.js';
5
4
  import type { ModeContext } from '../mode.js';
6
- import type { TokenUsage } from '../provider.js';
7
- import type { StopReason } from '../provider-utils.js';
8
5
  import { usageEventFields } from '../token-accounting.js';
9
6
  import {
10
7
  emitRequestsAndDetectStuck,
@@ -14,8 +11,9 @@ import {
14
11
  type StuckTripInfo,
15
12
  } from '../tool-dispatch.js';
16
13
  import { nextBackoffMs, sleepWithAbort } from './abort-backoff.js';
17
- import type { CheckpointResult, TurnCheckpoint } from './checkpoint.js';
18
- import { collectProviderStream, type CollectedToolUse } from './collect-stream.js';
14
+ import { emitAbort, emitError, runCheckpointGate } from './checkpoint-gate.js';
15
+ import { runAgentStepLoop } from './agent-step-loop.js';
16
+ import { collectProviderStream } from './collect-stream.js';
19
17
  import { buildSystemPromptWithSkills, projectMessages, skillsWithinReach } from './project-messages.js';
20
18
  import { createStuckLoopDetector } from './stuck-loop.js';
21
19
 
@@ -44,8 +42,6 @@ const DEFAULT_MAX_ITERATIONS = 500;
44
42
  const MAX_REACTIVE_COMPACTIONS = 2;
45
43
  const DEFAULT_MAX_INJECTIONS = 3;
46
44
  const DEFAULT_MAX_INJECT_CHARS = 16_384;
47
- const DEFAULT_CHECKPOINT_TIMEOUT_MS = 120_000;
48
- const CHECKPOINT_EVENT_PLUGIN_ID = asPluginId('react-loop');
49
45
 
50
46
  // Abort-aware sleep, injectable for tests so back-off paths run instantly and
51
47
  // deterministically. Production delegates to sleepWithAbort: a real timer that
@@ -70,115 +66,18 @@ export function __setRetrySleepForTests(
70
66
  };
71
67
  }
72
68
 
73
- /** What a clean provider call produced — input to {@link ReactLoopOptions.onProviderSuccess}. */
74
- export interface ProviderSuccessInfo {
75
- readonly text: string;
76
- readonly stopReason: StopReason;
77
- readonly toolUses: ReadonlyArray<CollectedToolUse>;
78
- readonly usage?: TokenUsage;
79
- readonly iteration: number;
80
- }
81
-
82
- /** A completed tool batch — input to {@link ReactLoopOptions.onToolBatchEnd}. */
83
- export interface ToolBatchInfo {
84
- readonly toolUses: ReadonlyArray<CollectedToolUse>;
85
- readonly iteration: number;
86
- }
87
-
88
- /** Hooks return this to end the turn; the hook emits its own wrap-up events first. */
89
- export interface StopDirective {
90
- readonly action: 'stop';
91
- }
92
-
93
- export interface ReactLoopOptions {
94
- /** Name stamped on `mode_iteration` events (e.g. `'default'`, `'goal'`). */
95
- readonly strategyName: string;
96
- /**
97
- * Iteration cap when the context doesn't supply one. Default 500. May be
98
- * `Number.POSITIVE_INFINITY` for an uncapped loop (goal mode): the run then
99
- * ends only via a terminal signal (checkpoint/hook stop, abort, fatal
100
- * error) — an explicit `ctx.maxIterations` still takes precedence.
101
- */
102
- readonly defaultMaxIterations?: number;
103
- /** Prefix for provider-error messages (goal mode uses `'goal: '`). */
104
- readonly errorPrefix?: string;
105
- /**
106
- * When set, an already-aborted signal is reported (with this reason) BEFORE
107
- * the first iteration — goal/collab preflight. When omitted the first
108
- * iteration's own abort check covers it with the generic reason.
109
- */
110
- readonly preflightAbortReason?: string;
111
- /** Turn-end gates, run in declared order. Omit/empty → plain ReAct loop. */
112
- readonly checkpoints?: ReadonlyArray<TurnCheckpoint>;
113
- /**
114
- * Hard cap on checkpoint `inject`/`retry` rounds per idle EPISODE — i.e.
115
- * consecutive gate rounds with no tool work between them; the count resets
116
- * whenever a tool batch executes (default 3). When exhausted the turn ends
117
- * with the model's answer as-is plus a visible warning — a
118
- * permanently-failing gate degrades loudly instead of looping forever.
119
- */
120
- readonly maxInjections?: number;
121
- /** Clamp on injected feedback length in characters (default 16_384). */
122
- readonly maxInjectChars?: number;
123
- /**
124
- * Stuck-loop policy + wording overrides; sensible defaults from
125
- * `strategyName`. `action` picks what a detector trip does:
126
- *
127
- * - `'abort'` (default): fail the batch and end the turn with a fatal
128
- * error — the historical behavior, right for attended modes where the
129
- * user is present to redirect.
130
- * - `'nudge'`: never stop. The batch still executes (repeated calls are
131
- * usually legitimate work — re-running a failing build between edits),
132
- * a visible warning + `extraOnStuck` events are emitted, the detector
133
- * resets, and `nudgeText` (or a default) rides the next provider call
134
- * as a volatile steer. For unattended modes (goal) where a heuristic
135
- * must never kill the run.
136
- */
137
- readonly stuck?: Partial<StuckLoopReport> & {
138
- readonly action?: 'abort' | 'nudge';
139
- /** Volatile steer for `'nudge'` trips; default wording when omitted. */
140
- readonly nudgeText?: (info: StuckTripInfo) => string;
141
- };
142
- /**
143
- * Runs after compaction/elision, before the provider call. May return a
144
- * volatile user message to ride ONLY the next call (collab's inbox/pause
145
- * awareness) — never appended to the log; the cache strategy is told via
146
- * `volatileTailCount` so its tail breakpoint stays ahead of it.
147
- */
148
- readonly onIterationStart?: (
149
- ctx: ModeContext,
150
- iteration: number,
151
- ) => Promise<{ readonly volatileUserText?: string } | undefined>;
152
- /**
153
- * Runs after every clean provider call (reasoning already logged, tools not
154
- * yet executed). Return `{action: 'stop'}` to end the turn — the hook emits
155
- * its own wrap-up events first (goal mode's token-budget backstop).
156
- */
157
- readonly onProviderSuccess?: (
158
- ctx: ModeContext,
159
- info: ProviderSuccessInfo,
160
- ) => Promise<StopDirective | undefined>;
161
- /**
162
- * Runs after a tool batch executes cleanly. Return `{action: 'stop'}` to
163
- * end the turn (goal/collab terminal-tool detection: `goal_complete`,
164
- * `collab_done`); the hook emits its own completion events first.
165
- */
166
- readonly onToolBatchEnd?: (
167
- ctx: ModeContext,
168
- info: ToolBatchInfo,
169
- ) => Promise<StopDirective | undefined>;
170
- /**
171
- * Replaces the default "exceeded maxIterations" fatal error — the hook
172
- * emits its own cap-reached events (goal mode adds a plugin event and its
173
- * own wording). The loop returns right after.
174
- */
175
- readonly onMaxIterations?: (ctx: ModeContext, maxIterations: number) => Promise<void>;
176
- }
69
+ export type { ProviderSuccessInfo, ReactLoopOptions, StopDirective, ToolBatchInfo } from './react-loop-options.js';
70
+ import type { ReactLoopOptions } from './react-loop-options.js';
177
71
 
178
72
  export async function* runReactLoop(
179
73
  ctx: ModeContext,
180
74
  opts: ReactLoopOptions,
181
75
  ): AsyncIterable<MoxxyEvent> {
76
+ // An agent connection: the vendor's agent does each round, not this loop.
77
+ if (ctx.provider.agentStep) {
78
+ yield* runAgentStepLoop(ctx, opts);
79
+ return;
80
+ }
182
81
  if (opts.preflightAbortReason && ctx.signal.aborted) {
183
82
  yield await emitAbort(ctx, opts.preflightAbortReason);
184
83
  return;
@@ -499,145 +398,6 @@ export async function* runReactLoop(
499
398
  );
500
399
  }
501
400
 
502
- /** Gate outcome: end the turn, or loop again (optionally with a volatile nudge). */
503
- type GateVerdict = { readonly kind: 'end' } | { readonly kind: 'loop'; readonly volatileText?: string };
504
-
505
- interface GateRound {
506
- readonly candidateText: string;
507
- readonly stopReason: StopReason;
508
- readonly iteration: number;
509
- readonly consecutiveIdle: number;
510
- readonly injectionsUsed: number;
511
- readonly injectionBudget: number;
512
- readonly maxInjectChars: number;
513
- }
514
-
515
- async function* runCheckpointGate(
516
- ctx: ModeContext,
517
- checkpoints: ReadonlyArray<TurnCheckpoint>,
518
- round: GateRound,
519
- ): AsyncGenerator<MoxxyEvent, GateVerdict> {
520
- // Natural completions face every checkpoint; truncated/errored candidates
521
- // only face the idle-tolerant ones — reviewing a half-sentence as if it
522
- // were a completion claim wastes a checker run and confuses the model.
523
- const eligible = checkpoints.filter(
524
- (cp) =>
525
- (round.stopReason === 'end_turn' || (cp.gateOn ?? 'end_turn') === 'idle') && (cp.applies?.(ctx) ?? true),
526
- );
527
- if (eligible.length === 0) return { kind: 'end' };
528
-
529
- // Budget exhausted → the answer ships as-is, but LOUDLY: silent
530
- // degradation would let the user believe a gated answer passed its gates.
531
- if (round.injectionsUsed >= round.injectionBudget) {
532
- yield await emitError(
533
- ctx,
534
- 'retryable',
535
- `checkpoint budget exhausted (${round.injectionBudget} rounds) — ` +
536
- `ending the turn with unresolved checkpoint feedback; verify the result manually.`,
537
- );
538
- return { kind: 'end' };
539
- }
540
-
541
- for (const cp of eligible) {
542
- // User cancelled mid-gate: end the turn WITHOUT retracting the
543
- // already-logged answer — an abort must never un-say what was said.
544
- if (ctx.signal.aborted) return { kind: 'end' };
545
-
546
- yield await emitCheckpointEvent(ctx, 'checkpoint_started', {
547
- name: cp.name,
548
- iteration: round.iteration,
549
- });
550
-
551
- const timeoutMs = Math.max(1_000, cp.timeoutMs ?? DEFAULT_CHECKPOINT_TIMEOUT_MS);
552
- const timeoutCtl = new AbortController();
553
- const timer = setTimeout(() => timeoutCtl.abort(), timeoutMs);
554
- timer.unref?.();
555
- let result: CheckpointResult;
556
- try {
557
- result = await cp.run(
558
- {
559
- candidateText: round.candidateText,
560
- stopReason: round.stopReason,
561
- iteration: round.iteration,
562
- consecutiveIdle: round.consecutiveIdle,
563
- injectionsUsed: round.injectionsUsed,
564
- injectionBudget: round.injectionBudget,
565
- signal: AbortSignal.any([ctx.signal, timeoutCtl.signal]),
566
- },
567
- ctx,
568
- );
569
- } catch (err) {
570
- if (ctx.signal.aborted) return { kind: 'end' };
571
- // Fail OPEN, visibly. A crashed or timed-out checker downgrades the
572
- // gate to a warning — it must never wedge the turn (fail-closed is a
573
- // turn that can never end) and never crash it (the answer is already
574
- // logged).
575
- const why = timeoutCtl.signal.aborted
576
- ? `timed out after ${timeoutMs}ms`
577
- : `failed: ${err instanceof Error ? err.message : String(err)}`;
578
- yield await emitError(ctx, 'retryable', `checkpoint "${cp.name}" ${why} — proceeding unchecked`);
579
- continue;
580
- } finally {
581
- clearTimeout(timer);
582
- }
583
-
584
- switch (result.action) {
585
- case 'pass': {
586
- yield await emitCheckpointEvent(ctx, 'checkpoint_passed', { name: cp.name });
587
- continue;
588
- }
589
- case 'stop': {
590
- // The checkpoint already emitted its own wrap-up events.
591
- yield await emitCheckpointEvent(ctx, 'checkpoint_stopped', { name: cp.name });
592
- return { kind: 'end' };
593
- }
594
- case 'retry': {
595
- yield await emitCheckpointEvent(ctx, 'checkpoint_retry', { name: cp.name });
596
- return { kind: 'loop' };
597
- }
598
- case 'inject': {
599
- // Guard the guard: an inject with blank text would loop the turn
600
- // with no new signal for the model — a checker bug; fail open.
601
- const feedback = result.text?.trim();
602
- if (!feedback) {
603
- yield await emitError(
604
- ctx,
605
- 'retryable',
606
- `checkpoint "${cp.name}" injected empty feedback — ignored`,
607
- );
608
- continue;
609
- }
610
- const clamped = clampChars(feedback, round.maxInjectChars);
611
- if (result.volatile) {
612
- yield await emitCheckpointEvent(ctx, 'checkpoint_injected', {
613
- name: cp.name,
614
- volatile: true,
615
- });
616
- return { kind: 'loop', volatileText: clamped };
617
- }
618
- // Persistent: a checkpoint-origin user prompt. Projected as a
619
- // user-role message by the existing projection case, marked via the
620
- // same `origin` machinery trigger prompts use, cache-safe because it
621
- // is an ordinary append at the log tail.
622
- yield await ctx.emit({
623
- type: 'user_prompt',
624
- sessionId: ctx.sessionId,
625
- turnId: ctx.turnId,
626
- source: 'system',
627
- text: clamped,
628
- origin: { kind: 'checkpoint', name: cp.name },
629
- });
630
- yield await emitCheckpointEvent(ctx, 'checkpoint_injected', {
631
- name: cp.name,
632
- volatile: false,
633
- });
634
- return { kind: 'loop' };
635
- }
636
- }
637
- }
638
- return { kind: 'end' };
639
- }
640
-
641
401
  function defaultStuckNudge(trip: StuckTripInfo): string {
642
402
  return (
643
403
  `You have called the tool \`${trip.toolName}\` ${trip.count} times ${trip.how}. ` +
@@ -663,49 +423,3 @@ function buildStuckReport(opts: ReactLoopOptions): StuckLoopReport {
663
423
  ...(opts.stuck?.extraOnStuck ? { extraOnStuck: opts.stuck.extraOnStuck } : {}),
664
424
  };
665
425
  }
666
-
667
- function clampChars(text: string, max: number): string {
668
- if (text.length <= max) return text;
669
- return `${text.slice(0, max)}\n…[checkpoint feedback truncated: ${text.length - max} chars dropped]`;
670
- }
671
-
672
- async function emitAbort(ctx: ModeContext, reason: string): Promise<MoxxyEvent> {
673
- return ctx.emit({
674
- type: 'abort',
675
- sessionId: ctx.sessionId,
676
- turnId: ctx.turnId,
677
- source: 'system',
678
- reason,
679
- });
680
- }
681
-
682
- async function emitError(
683
- ctx: ModeContext,
684
- kind: 'retryable' | 'fatal',
685
- message: string,
686
- ): Promise<MoxxyEvent> {
687
- return ctx.emit({
688
- type: 'error',
689
- sessionId: ctx.sessionId,
690
- turnId: ctx.turnId,
691
- source: 'system',
692
- kind,
693
- message,
694
- });
695
- }
696
-
697
- async function emitCheckpointEvent(
698
- ctx: ModeContext,
699
- subtype: string,
700
- payload: unknown,
701
- ): Promise<MoxxyEvent> {
702
- return ctx.emit({
703
- type: 'plugin_event',
704
- sessionId: ctx.sessionId,
705
- turnId: ctx.turnId,
706
- source: 'system',
707
- pluginId: CHECKPOINT_EVENT_PLUGIN_ID,
708
- subtype,
709
- payload,
710
- });
711
- }
package/src/mode.ts CHANGED
@@ -8,6 +8,7 @@ import type { EventLogReader } from './log.js';
8
8
  import type { PermissionResolver } from './permission.js';
9
9
  import type { LLMProvider, ReasoningEffort } from './provider.js';
10
10
  import type { LoopGuardSettings } from './mode/stuck-loop.js';
11
+ import type { QuestionAskOptions, QuestionItem, QuestionOutcome } from './question.js';
11
12
  import type { Skill } from './skill.js';
12
13
  import type { SubagentSpawner } from './subagent.js';
13
14
  import type { ToolDef } from './tool.js';
@@ -89,6 +90,13 @@ export interface ModeContext {
89
90
  readonly systemPrompt?: string;
90
91
  readonly provider: LLMProvider;
91
92
  readonly tools: ToolRegistry;
93
+ /**
94
+ * Every tool of the session, in any mode (`tools` is this turn's mode's,
95
+ * plus any it adds for itself). An agent connection lists these to the
96
+ * vendor agent, which reads its tool list once, and refuses a call to one
97
+ * this mode does not offer.
98
+ */
99
+ readonly allTools?: ToolRegistry;
92
100
  readonly skills: SkillRegistry;
93
101
  readonly log: EventLogReader;
94
102
  readonly compactor: CompactorDef | null;
@@ -125,6 +133,11 @@ export interface ModeContext {
125
133
  * picked the default option, or fail closed depending on the strategy.
126
134
  */
127
135
  readonly approval?: ApprovalResolver;
136
+ /**
137
+ * Ask the person a Question in this turn (the conversation's Questions).
138
+ * Absent where nobody can be asked (subagents, synthetic test contexts).
139
+ */
140
+ readonly ask?: (questions: ReadonlyArray<QuestionItem>, opts?: QuestionAskOptions) => Promise<QuestionOutcome>;
128
141
  readonly hooks: HookDispatcher;
129
142
  readonly pluginHost: PluginHostHandle;
130
143
  /** Inter-plugin service registry (mirrors {@link AppContext.services}). */
@@ -246,14 +259,13 @@ export interface ModeDef {
246
259
  readonly badge?: ModeBadge;
247
260
  /**
248
261
  * Marks a "special" mode — one the user does NOT pick from `/mode`. Set this
249
- * and the mode is, uniformly across every surface (TUI, mobile, desktop):
262
+ * and the mode is, uniformly across every surface (TUI, desktop):
250
263
  * - **hidden** from every mode list / picker (presence), and
251
264
  * - **refused** by the normal mode-switch by name (execution) — it can only
252
265
  * be entered through its own invocation (a slash command, a spawned
253
266
  * agent's `MOXXY_MODE`, …) via the raw registry `setActive`.
254
267
  * The mode stays registered + runnable; it's just never *offered* or
255
- * name-switched. Extensible: any number of special modes (collaborative today,
256
- * more later) opt in the same way. See {@link isSelectableMode}.
268
+ * name-switched. Extensible: any number of special modes opt in the same way. See {@link isSelectableMode}.
257
269
  */
258
270
  readonly special?: ModeSpecial;
259
271
  /**
@@ -277,16 +289,16 @@ export interface ModeDef {
277
289
  /** Descriptor for a {@link ModeDef.special} mode — see that field. */
278
290
  export interface ModeSpecial {
279
291
  /**
280
- * Human-facing entry point that enters this mode, e.g. `'collab'` (the
281
- * `/collab` command). Surfaced when the user tries to pick/switch it ("entered
282
- * via /collab") so the UI can point at the right action.
292
+ * Human-facing entry point that enters this mode, e.g. `'goal'` (the
293
+ * `/goal` command). Surfaced when the user tries to pick/switch it ("entered
294
+ * via /goal") so the UI can point at the right action.
283
295
  */
284
296
  readonly invokedBy?: string;
285
297
  }
286
298
 
287
299
  /**
288
300
  * Whether a mode is user-selectable — i.e. NOT a {@link ModeDef.special} mode.
289
- * The single predicate every surface (TUI/mobile/desktop mode lists + the
301
+ * The single predicate every surface (TUI/desktop mode lists + the
290
302
  * by-name mode-switch) uses to filter/refuse special modes uniformly. The
291
303
  * parameter is intentionally loose (`{ special? }`) so it also applies to the
292
304
  * minimal `{ name }` rows the swap-UI registries expose — non-mode defs simply
@@ -0,0 +1,95 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { fetchNpmLatest as fetchLatest } from './npm-registry.js';
3
+
4
+ /** A fetch stub returning a JSON body with the given status. */
5
+ function jsonResponse(status: number, body: unknown): typeof fetch {
6
+ return vi.fn(async () => ({
7
+ ok: status >= 200 && status < 300,
8
+ status,
9
+ json: async () => body,
10
+ })) as unknown as typeof fetch;
11
+ }
12
+
13
+ describe('fetchNpmLatest', () => {
14
+ it('returns the version string for a 200 with {version}', async () => {
15
+ const fetchImpl = jsonResponse(200, { version: '1.2.3' });
16
+ await expect(fetchLatest('@moxxy/cli', { fetchImpl })).resolves.toBe('1.2.3');
17
+ });
18
+
19
+ it('returns null for a 200 whose body has no string version', async () => {
20
+ await expect(fetchLatest('@moxxy/cli', { fetchImpl: jsonResponse(200, {}) })).resolves.toBeNull();
21
+ await expect(
22
+ fetchLatest('@moxxy/cli', { fetchImpl: jsonResponse(200, { version: 42 }) }),
23
+ ).resolves.toBeNull();
24
+ });
25
+
26
+ it('rejects non-semver and prerelease dist tags before they reach the cache', async () => {
27
+ await expect(
28
+ fetchLatest('@moxxy/cli', { fetchImpl: jsonResponse(200, { version: '../../payload' }) }),
29
+ ).resolves.toBeNull();
30
+ await expect(
31
+ fetchLatest('@moxxy/cli', { fetchImpl: jsonResponse(200, { version: '1.2.3-beta.1' }) }),
32
+ ).resolves.toBeNull();
33
+ });
34
+
35
+ it('returns null for a 404 (and never reads the body)', async () => {
36
+ const json = vi.fn(async () => ({ version: 'should-not-be-read' }));
37
+ const fetchImpl = vi.fn(async () => ({ ok: false, status: 404, json })) as unknown as typeof fetch;
38
+ await expect(fetchLatest('@moxxy/cli', { fetchImpl })).resolves.toBeNull();
39
+ expect(json).not.toHaveBeenCalled();
40
+ });
41
+
42
+ it('returns null for a 500', async () => {
43
+ await expect(
44
+ fetchLatest('@moxxy/cli', { fetchImpl: jsonResponse(500, {}) }),
45
+ ).resolves.toBeNull();
46
+ });
47
+
48
+ it('returns null when the fetch implementation rejects (offline)', async () => {
49
+ const fetchImpl = vi.fn(async () => {
50
+ throw new Error('network down');
51
+ }) as unknown as typeof fetch;
52
+ await expect(fetchLatest('@moxxy/cli', { fetchImpl })).resolves.toBeNull();
53
+ });
54
+
55
+ it('encodes the package name so a hostile pkg cannot escape the registry path', async () => {
56
+ let seenUrl = '';
57
+ const fetchImpl = vi.fn(async (url: string) => {
58
+ seenUrl = url;
59
+ return { ok: true, status: 200, json: async () => ({ version: '1.0.0' }) };
60
+ }) as unknown as typeof fetch;
61
+ await fetchLatest('@moxxy/cli', { fetchImpl });
62
+ expect(seenUrl).toBe('https://registry.npmjs.org/%40moxxy%2Fcli/latest');
63
+
64
+ // A name containing traversal / a host segment must stay inside the path:
65
+ // encodeURIComponent escapes the separators (`/` → %2F, npm's own scoped-
66
+ // package encoding) and the query (`?` → %3F), so the hostile name becomes a
67
+ // single inert path segment. Bare dots are harmless — what must NOT survive
68
+ // is an unescaped `../` traversal or a raw `?` query injection.
69
+ await fetchLatest('../../evil?x=1', { fetchImpl });
70
+ expect(seenUrl).toBe('https://registry.npmjs.org/..%2F..%2Fevil%3Fx%3D1/latest');
71
+ expect(seenUrl.startsWith('https://registry.npmjs.org/')).toBe(true);
72
+ expect(seenUrl.endsWith('/latest')).toBe(true);
73
+ expect(seenUrl).not.toContain('../');
74
+ expect(seenUrl).not.toContain('?x=1');
75
+ expect(seenUrl).toContain('%2F');
76
+ });
77
+
78
+ it('aborts and resolves null when the request outlives the timeout', async () => {
79
+ let abortSeen = false;
80
+ // A fetch that never resolves on its own — it only settles when the
81
+ // AbortController fires, proving the timeout path clears nothing it
82
+ // shouldn't and returns null fail-soft.
83
+ const fetchImpl = vi.fn((_url: string, init?: { signal?: AbortSignal }) => {
84
+ return new Promise((_resolve, reject) => {
85
+ init?.signal?.addEventListener('abort', () => {
86
+ abortSeen = true;
87
+ reject(new Error('aborted'));
88
+ });
89
+ });
90
+ }) as unknown as typeof fetch;
91
+
92
+ await expect(fetchLatest('@moxxy/cli', { fetchImpl, timeoutMs: 1 })).resolves.toBeNull();
93
+ expect(abortSeen).toBe(true);
94
+ });
95
+ });